@@ -57,6 +57,7 @@ impl super::ModeHandler for ContainerMode {
5757
5858 let mut allocated_ports: Vec < ( String , u16 ) > = vec ! [ ] ;
5959 let mut written_overlays: Vec < String > = vec ! [ ] ;
60+ let mut compose_overlays: Vec < crate :: state:: ComposeOverlay > = vec ! [ ] ;
6061
6162 // Copy ports for skipped docker services from existing session.
6263 for svc in & docker_svcs_config {
@@ -178,6 +179,10 @@ impl super::ModeHandler for ContainerMode {
178179 return Err ( e) ;
179180 }
180181
182+ compose_overlays. push ( crate :: state:: ComposeOverlay {
183+ compose : compose_str,
184+ overlay : overlay_str. clone ( ) ,
185+ } ) ;
181186 written_overlays. push ( overlay_str) ;
182187 }
183188 } // end if !docker_svcs_to_start.is_empty()
@@ -221,6 +226,11 @@ impl super::ModeHandler for ContainerMode {
221226 return Err ( e) ;
222227 }
223228
229+ compose_overlays. push ( crate :: state:: ComposeOverlay {
230+ compose : compose_str,
231+ overlay : overlay_str. clone ( ) ,
232+ } ) ;
233+
224234 allocated_ports = compose_data
225235 . services
226236 . iter ( )
@@ -322,6 +332,7 @@ impl super::ModeHandler for ContainerMode {
322332 compose_project : Some ( project) ,
323333 overlay_file : primary_overlay,
324334 overlay_files : extra_overlays,
335+ compose_overlays,
325336 app_port,
326337 started_at : Utc :: now ( ) . to_rfc3339 ( ) ,
327338 port_overrides : stored_port_overrides,
@@ -376,21 +387,36 @@ impl super::ModeHandler for ContainerMode {
376387 }
377388
378389 if let Some ( project) = & session. compose_project {
379- let all_overlays: Vec < String > = session
380- . overlay_file
381- . iter ( )
382- . cloned ( )
383- . chain ( session. overlay_files . iter ( ) . cloned ( ) )
384- . collect ( ) ;
385-
386- if !all_overlays. is_empty ( ) {
390+ if !session. compose_overlays . is_empty ( ) {
387391 log. step ( "Stopping docker services..." ) ;
388- }
392+ for pair in & session. compose_overlays {
393+ let _ = docker:: compose_down (
394+ project,
395+ & pair. compose ,
396+ Some ( & pair. overlay ) ,
397+ !keep_volumes,
398+ ) ;
399+ let _ = std:: fs:: remove_file ( & pair. overlay ) ;
400+ }
401+ } else {
402+ // Legacy state without compose_overlays: reconstruct compose
403+ // paths from overlay filenames.
404+ let all_overlays: Vec < String > = session
405+ . overlay_file
406+ . iter ( )
407+ . cloned ( )
408+ . chain ( session. overlay_files . iter ( ) . cloned ( ) )
409+ . collect ( ) ;
410+
411+ if !all_overlays. is_empty ( ) {
412+ log. step ( "Stopping docker services..." ) ;
413+ }
389414
390- tear_down_all_overlays ( project, root, & all_overlays, !keep_volumes) ;
415+ tear_down_all_overlays ( project, root, & all_overlays, !keep_volumes) ;
391416
392- for ov in & all_overlays {
393- let _ = std:: fs:: remove_file ( ov) ;
417+ for ov in & all_overlays {
418+ let _ = std:: fs:: remove_file ( ov) ;
419+ }
394420 }
395421 }
396422
@@ -415,3 +441,80 @@ impl super::ModeHandler for ContainerMode {
415441 Ok ( ( ) )
416442 }
417443}
444+
445+ #[ cfg( test) ]
446+ mod tests {
447+ use super :: * ;
448+ use crate :: config:: { HookConfig , Mode } ;
449+ use crate :: modes:: ModeHandler ;
450+ use crate :: state:: { ComposeOverlay , Session } ;
451+ use tempfile:: TempDir ;
452+
453+ // Teardown must use the recorded (compose, overlay) pairs — including for
454+ // a hyphenated slug whose suffix matches a real subdirectory, where the
455+ // legacy filename parser would target the wrong compose file.
456+ #[ test]
457+ fn bring_down_uses_recorded_pairs_and_removes_overlays ( ) {
458+ let dir = TempDir :: new ( ) . unwrap ( ) ;
459+ let overlays = dir. path ( ) . join ( ".ecluse/overlays" ) ;
460+ std:: fs:: create_dir_all ( & overlays) . unwrap ( ) ;
461+ std:: fs:: create_dir_all ( dir. path ( ) . join ( "worker" ) ) . unwrap ( ) ;
462+ std:: fs:: write (
463+ dir. path ( ) . join ( "worker/docker-compose.yml" ) ,
464+ "services: {}\n " ,
465+ )
466+ . unwrap ( ) ;
467+ std:: fs:: write ( dir. path ( ) . join ( "docker-compose.yml" ) , "services: {}\n " ) . unwrap ( ) ;
468+ let root_overlay = overlays. join ( "feat-worker.yml" ) ;
469+ std:: fs:: write ( & root_overlay, "services: {}\n " ) . unwrap ( ) ;
470+
471+ let session = Session {
472+ slug : "feat-worker" . into ( ) ,
473+ mode : Mode :: Container ,
474+ slot : 1 ,
475+ branch : "feat-worker" . into ( ) ,
476+ worktree_path : dir. path ( ) . join ( "wt" ) . display ( ) . to_string ( ) ,
477+ compose_project : Some ( "ecluse_feat-worker" . into ( ) ) ,
478+ overlay_file : Some ( root_overlay. display ( ) . to_string ( ) ) ,
479+ overlay_files : vec ! [ ] ,
480+ compose_overlays : vec ! [ ComposeOverlay {
481+ compose: dir. path( ) . join( "docker-compose.yml" ) . display( ) . to_string( ) ,
482+ overlay: root_overlay. display( ) . to_string( ) ,
483+ } ] ,
484+ app_port : None ,
485+ started_at : "2026-01-01T00:00:00Z" . into ( ) ,
486+ port_overrides : std:: collections:: HashMap :: new ( ) ,
487+ process_manager : None ,
488+ tmux_session : None ,
489+ pid_files : vec ! [ ] ,
490+ log_dir : None ,
491+ services_subset : None ,
492+ } ;
493+ let config = Config {
494+ mode : Mode :: Container ,
495+ max_slots : 8 ,
496+ prefix : "ecluse" . into ( ) ,
497+ worktree_dir : ".ecluse/worktrees" . into ( ) ,
498+ app_label : "ecluse.role" . into ( ) ,
499+ app_label_value : "app" . into ( ) ,
500+ strict_port : false ,
501+ port_search_range : 10 ,
502+ slot_stride : 1 ,
503+ services : vec ! [ ] ,
504+ hooks : HookConfig :: default ( ) ,
505+ inherit_env : vec ! [ ] ,
506+ } ;
507+ let log = crate :: log:: StepLogger :: new ( true ) ;
508+
509+ // keep_worktree=true: no git interaction; compose_down is best-effort
510+ // and ignored when no docker daemon is available.
511+ ContainerMode
512+ . bring_down ( & session, & config, dir. path ( ) , true , true , & log)
513+ . unwrap ( ) ;
514+
515+ assert ! (
516+ !root_overlay. exists( ) ,
517+ "overlay from the recorded pair must be removed"
518+ ) ;
519+ }
520+ }
0 commit comments