diff --git a/src/main.rs b/src/main.rs index 4442aae..4afc2b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1649,6 +1649,7 @@ fn cmd_sync(args: cli::SyncArgs) -> Result<()> { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], started_at: chrono::Utc::now().to_rfc3339(), tmux_session: None, services_subset: None, diff --git a/src/modes/container.rs b/src/modes/container.rs index 578bba6..be5c1f0 100644 --- a/src/modes/container.rs +++ b/src/modes/container.rs @@ -57,6 +57,7 @@ impl super::ModeHandler for ContainerMode { let mut allocated_ports: Vec<(String, u16)> = vec![]; let mut written_overlays: Vec = vec![]; + let mut compose_overlays: Vec = vec![]; // Copy ports for skipped docker services from existing session. for svc in &docker_svcs_config { @@ -178,6 +179,10 @@ impl super::ModeHandler for ContainerMode { return Err(e); } + compose_overlays.push(crate::state::ComposeOverlay { + compose: compose_str, + overlay: overlay_str.clone(), + }); written_overlays.push(overlay_str); } } // end if !docker_svcs_to_start.is_empty() @@ -221,6 +226,11 @@ impl super::ModeHandler for ContainerMode { return Err(e); } + compose_overlays.push(crate::state::ComposeOverlay { + compose: compose_str, + overlay: overlay_str.clone(), + }); + allocated_ports = compose_data .services .iter() @@ -322,6 +332,7 @@ impl super::ModeHandler for ContainerMode { compose_project: Some(project), overlay_file: primary_overlay, overlay_files: extra_overlays, + compose_overlays, app_port, started_at: Utc::now().to_rfc3339(), port_overrides: stored_port_overrides, @@ -376,21 +387,36 @@ impl super::ModeHandler for ContainerMode { } if let Some(project) = &session.compose_project { - let all_overlays: Vec = session - .overlay_file - .iter() - .cloned() - .chain(session.overlay_files.iter().cloned()) - .collect(); - - if !all_overlays.is_empty() { + if !session.compose_overlays.is_empty() { log.step("Stopping docker services..."); - } + for pair in &session.compose_overlays { + let _ = docker::compose_down( + project, + &pair.compose, + Some(&pair.overlay), + !keep_volumes, + ); + let _ = std::fs::remove_file(&pair.overlay); + } + } else { + // Legacy state without compose_overlays: reconstruct compose + // paths from overlay filenames. + let all_overlays: Vec = session + .overlay_file + .iter() + .cloned() + .chain(session.overlay_files.iter().cloned()) + .collect(); + + if !all_overlays.is_empty() { + log.step("Stopping docker services..."); + } - tear_down_all_overlays(project, root, &all_overlays, !keep_volumes); + tear_down_all_overlays(project, root, &all_overlays, !keep_volumes); - for ov in &all_overlays { - let _ = std::fs::remove_file(ov); + for ov in &all_overlays { + let _ = std::fs::remove_file(ov); + } } } @@ -415,3 +441,80 @@ impl super::ModeHandler for ContainerMode { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{HookConfig, Mode}; + use crate::modes::ModeHandler; + use crate::state::{ComposeOverlay, Session}; + use tempfile::TempDir; + + // Teardown must use the recorded (compose, overlay) pairs — including for + // a hyphenated slug whose suffix matches a real subdirectory, where the + // legacy filename parser would target the wrong compose file. + #[test] + fn bring_down_uses_recorded_pairs_and_removes_overlays() { + let dir = TempDir::new().unwrap(); + let overlays = dir.path().join(".ecluse/overlays"); + std::fs::create_dir_all(&overlays).unwrap(); + std::fs::create_dir_all(dir.path().join("worker")).unwrap(); + std::fs::write( + dir.path().join("worker/docker-compose.yml"), + "services: {}\n", + ) + .unwrap(); + std::fs::write(dir.path().join("docker-compose.yml"), "services: {}\n").unwrap(); + let root_overlay = overlays.join("feat-worker.yml"); + std::fs::write(&root_overlay, "services: {}\n").unwrap(); + + let session = Session { + slug: "feat-worker".into(), + mode: Mode::Container, + slot: 1, + branch: "feat-worker".into(), + worktree_path: dir.path().join("wt").display().to_string(), + compose_project: Some("ecluse_feat-worker".into()), + overlay_file: Some(root_overlay.display().to_string()), + overlay_files: vec![], + compose_overlays: vec![ComposeOverlay { + compose: dir.path().join("docker-compose.yml").display().to_string(), + overlay: root_overlay.display().to_string(), + }], + app_port: None, + started_at: "2026-01-01T00:00:00Z".into(), + port_overrides: std::collections::HashMap::new(), + process_manager: None, + tmux_session: None, + pid_files: vec![], + log_dir: None, + services_subset: None, + }; + let config = Config { + mode: Mode::Container, + max_slots: 8, + prefix: "ecluse".into(), + worktree_dir: ".ecluse/worktrees".into(), + app_label: "ecluse.role".into(), + app_label_value: "app".into(), + strict_port: false, + port_search_range: 10, + slot_stride: 1, + services: vec![], + hooks: HookConfig::default(), + inherit_env: vec![], + }; + let log = crate::log::StepLogger::new(true); + + // keep_worktree=true: no git interaction; compose_down is best-effort + // and ignored when no docker daemon is available. + ContainerMode + .bring_down(&session, &config, dir.path(), true, true, &log) + .unwrap(); + + assert!( + !root_overlay.exists(), + "overlay from the recorded pair must be removed" + ); + } +} diff --git a/src/modes/host.rs b/src/modes/host.rs index e6c84da..ddafcc2 100644 --- a/src/modes/host.rs +++ b/src/modes/host.rs @@ -165,6 +165,7 @@ impl super::ModeHandler for HostMode { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port, started_at: Utc::now().to_rfc3339(), port_overrides: stored_port_overrides, diff --git a/src/modes/hybrid.rs b/src/modes/hybrid.rs index e3fda02..d04be95 100644 --- a/src/modes/hybrid.rs +++ b/src/modes/hybrid.rs @@ -59,6 +59,7 @@ impl super::ModeHandler for HybridMode { let mut allocated_docker_ports: Vec<(String, u16)> = vec![]; let mut written_overlays: Vec = vec![]; + let mut compose_overlays: Vec = vec![]; // Copy ports for skipped docker services from existing session. for svc in &docker_svcs_config { @@ -185,6 +186,10 @@ impl super::ModeHandler for HybridMode { return Err(e); } + compose_overlays.push(crate::state::ComposeOverlay { + compose: compose_str, + overlay: overlay_str.clone(), + }); written_overlays.push(overlay_str); } } // end if !docker_svcs_to_start.is_empty() @@ -241,6 +246,11 @@ impl super::ModeHandler for HybridMode { return Err(e); } + compose_overlays.push(crate::state::ComposeOverlay { + compose: compose_str, + overlay: overlay_str.clone(), + }); + for (name, svc) in &compose_data.services { if data_svcs.contains(name) { if let Some(p) = compose::service_host_port(svc, slot as u16) { @@ -437,6 +447,7 @@ impl super::ModeHandler for HybridMode { compose_project: Some(project), overlay_file: primary_overlay, overlay_files: extra_overlays, + compose_overlays, app_port, started_at: Utc::now().to_rfc3339(), port_overrides: all_ports, @@ -517,30 +528,44 @@ impl super::ModeHandler for HybridMode { } if let Some(project) = &session.compose_project { - let all_overlays: Vec = session - .overlay_file - .iter() - .cloned() - .chain(session.overlay_files.iter().cloned()) - .collect(); - log.step("Stopping docker services..."); - if all_overlays.is_empty() { - // No overlay paths recorded in state — fall back to the root compose file - // so containers are always stopped even if state was written without overlays. - if let Some(cp) = compose::find_compose_file(root) { - let _ = crate::docker::compose_down( + if !session.compose_overlays.is_empty() { + for pair in &session.compose_overlays { + let _ = docker::compose_down( project, - &cp.to_string_lossy(), - None, + &pair.compose, + Some(&pair.overlay), !keep_volumes, ); + let _ = std::fs::remove_file(&pair.overlay); } } else { - tear_down_all_overlays(project, root, &all_overlays, !keep_volumes); - for ov in &all_overlays { - let _ = std::fs::remove_file(ov); + // Legacy state without compose_overlays: reconstruct compose + // paths from overlay filenames. + let all_overlays: Vec = session + .overlay_file + .iter() + .cloned() + .chain(session.overlay_files.iter().cloned()) + .collect(); + + if all_overlays.is_empty() { + // No overlay paths recorded in state — fall back to the root compose file + // so containers are always stopped even if state was written without overlays. + if let Some(cp) = compose::find_compose_file(root) { + let _ = crate::docker::compose_down( + project, + &cp.to_string_lossy(), + None, + !keep_volumes, + ); + } + } else { + tear_down_all_overlays(project, root, &all_overlays, !keep_volumes); + for ov in &all_overlays { + let _ = std::fs::remove_file(ov); + } } } } diff --git a/src/modes/mod.rs b/src/modes/mod.rs index e808ece..12011a9 100644 --- a/src/modes/mod.rs +++ b/src/modes/mod.rs @@ -90,8 +90,11 @@ pub fn overlay_name_for_compose(slug: &str, compose_path: &Path, root: &Path) -> format!("{}-{}.yml", slug, stem) } -/// Tear down all (compose, overlay) pairs. For each overlay, reconstruct the -/// compose file path from the overlay filename, falling back to the root compose. +/// Legacy teardown for state files that predate `Session.compose_overlays`: +/// reconstructs each overlay's compose file from the overlay *filename*, +/// falling back to the root compose. Filename reconstruction is ambiguous for +/// hyphenated slugs (see `compose_file_for_overlay`) — sessions written by +/// current versions carry explicit pairs and never go through this path. pub fn tear_down_all_overlays( project: &str, root: &Path, diff --git a/src/slot.rs b/src/slot.rs index 50cef5d..c9222f6 100644 --- a/src/slot.rs +++ b/src/slot.rs @@ -40,6 +40,7 @@ mod tests { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port: None, started_at: "2026-01-01T00:00:00Z".into(), port_overrides: std::collections::HashMap::new(), diff --git a/src/state.rs b/src/state.rs index 1e96958..8ffd1bf 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,6 +29,15 @@ fn default_version() -> u8 { 1 } +/// A compose file together with the overlay ecluse generated for it. +/// Persisted as a pair so teardown never has to reconstruct which compose +/// file an overlay belongs to from its filename. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct ComposeOverlay { + pub compose: String, + pub overlay: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Session { pub slug: String, @@ -37,11 +46,18 @@ pub struct Session { pub branch: String, pub worktree_path: String, pub compose_project: Option, + /// Legacy: primary overlay path. Still written for older binaries; + /// teardown prefers `compose_overlays`. pub overlay_file: Option, - /// Additional overlay files when multiple compose files are involved (monorepo). - /// Indexed alongside the compose file they override. + /// Legacy: extra overlay paths (monorepo). Still written for older + /// binaries; teardown prefers `compose_overlays`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub overlay_files: Vec, + /// (compose, overlay) pairs recorded at bring_up. The authoritative + /// source for teardown; empty for sessions written by older versions, + /// which fall back to the legacy fields above. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub compose_overlays: Vec, pub app_port: Option, pub started_at: String, /// Actual allocated ports (may differ from nominal if auto-bump kicked in). @@ -246,6 +262,7 @@ mod tests { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port: None, started_at: "2026-01-01T00:00:00Z".into(), port_overrides: std::collections::HashMap::new(), @@ -424,6 +441,7 @@ mod tests { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port: Some(3001), started_at: "2026-01-01T00:00:00Z".into(), port_overrides: std::collections::HashMap::new(), @@ -458,6 +476,7 @@ mod tests { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port: None, started_at: "2026-01-01T00:00:00Z".into(), port_overrides: std::collections::HashMap::new(), @@ -489,6 +508,7 @@ mod tests { compose_project: Some("ecluse_compose-sess".into()), overlay_file: Some("/tmp/overlay.yml".into()), overlay_files: vec![], + compose_overlays: vec![], app_port: Some(3001), started_at: "2026-01-01T00:00:00Z".into(), port_overrides: std::collections::HashMap::new(), @@ -530,4 +550,28 @@ mod tests { let json = serde_json::to_string(&s).unwrap(); assert!(!json.contains("services_subset"), "got: {json}"); } + + // ── compose_overlays ────────────────────────────────────────────────────── + + #[test] + fn compose_overlays_roundtrip() { + let mut s = make_session("pairs", 1); + s.compose_overlays = vec![ComposeOverlay { + compose: "/repo/docker-compose.yml".into(), + overlay: "/repo/.ecluse/overlays/pairs.yml".into(), + }]; + let json = serde_json::to_string(&s).unwrap(); + let back: Session = serde_json::from_str(&json).unwrap(); + assert_eq!(back.compose_overlays, s.compose_overlays); + } + + #[test] + fn legacy_state_without_compose_overlays_defaults_to_empty() { + // state.json written by an older ecluse has no compose_overlays field. + let s = make_session("old", 1); + let mut json = serde_json::to_value(&s).unwrap(); + json.as_object_mut().unwrap().remove("compose_overlays"); + let back: Session = serde_json::from_value(json).unwrap(); + assert!(back.compose_overlays.is_empty()); + } } diff --git a/src/whose_pid.rs b/src/whose_pid.rs index 6a2b8c0..1c57517 100644 --- a/src/whose_pid.rs +++ b/src/whose_pid.rs @@ -152,6 +152,7 @@ mod tests { compose_project: None, overlay_file: None, overlay_files: vec![], + compose_overlays: vec![], app_port: None, started_at: "2026-01-01T00:00:00Z".into(), port_overrides: HashMap::new(),