Skip to content

Commit b29d97f

Browse files
committed
Merge pull request #23 from hefgi/claude/magical-einstein-7m4g40-overlays
fix(state): record (compose, overlay) pairs — stop reconstructing compose paths from overlay filenames https://claude.ai/code/session_017UcuvzMKHVfyBCcq8ipAko # Conflicts: # src/state.rs
2 parents 712417d + 0bcc278 commit b29d97f

8 files changed

Lines changed: 212 additions & 33 deletions

File tree

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1684,6 +1684,7 @@ fn cmd_sync(args: cli::SyncArgs) -> Result<()> {
16841684
compose_project: None,
16851685
overlay_file: None,
16861686
overlay_files: vec![],
1687+
compose_overlays: vec![],
16871688
started_at: chrono::Utc::now().to_rfc3339(),
16881689
tmux_session: None,
16891690
services_subset: None,

src/modes/container.rs

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/modes/host.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ impl super::ModeHandler for HostMode {
165165
compose_project: None,
166166
overlay_file: None,
167167
overlay_files: vec![],
168+
compose_overlays: vec![],
168169
app_port,
169170
started_at: Utc::now().to_rfc3339(),
170171
port_overrides: stored_port_overrides,

src/modes/hybrid.rs

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ impl super::ModeHandler for HybridMode {
5959

6060
let mut allocated_docker_ports: Vec<(String, u16)> = vec![];
6161
let mut written_overlays: Vec<String> = vec![];
62+
let mut compose_overlays: Vec<crate::state::ComposeOverlay> = vec![];
6263

6364
// Copy ports for skipped docker services from existing session.
6465
for svc in &docker_svcs_config {
@@ -185,6 +186,10 @@ impl super::ModeHandler for HybridMode {
185186
return Err(e);
186187
}
187188

189+
compose_overlays.push(crate::state::ComposeOverlay {
190+
compose: compose_str,
191+
overlay: overlay_str.clone(),
192+
});
188193
written_overlays.push(overlay_str);
189194
}
190195
} // end if !docker_svcs_to_start.is_empty()
@@ -241,6 +246,11 @@ impl super::ModeHandler for HybridMode {
241246
return Err(e);
242247
}
243248

249+
compose_overlays.push(crate::state::ComposeOverlay {
250+
compose: compose_str,
251+
overlay: overlay_str.clone(),
252+
});
253+
244254
for (name, svc) in &compose_data.services {
245255
if data_svcs.contains(name) {
246256
if let Some(p) = compose::service_host_port(svc, slot as u16) {
@@ -437,6 +447,7 @@ impl super::ModeHandler for HybridMode {
437447
compose_project: Some(project),
438448
overlay_file: primary_overlay,
439449
overlay_files: extra_overlays,
450+
compose_overlays,
440451
app_port,
441452
started_at: Utc::now().to_rfc3339(),
442453
port_overrides: all_ports,
@@ -518,30 +529,44 @@ impl super::ModeHandler for HybridMode {
518529
process::remove_env_preamble(std::path::Path::new(&session.worktree_path), &session.slug);
519530

520531
if let Some(project) = &session.compose_project {
521-
let all_overlays: Vec<String> = session
522-
.overlay_file
523-
.iter()
524-
.cloned()
525-
.chain(session.overlay_files.iter().cloned())
526-
.collect();
527-
528532
log.step("Stopping docker services...");
529533

530-
if all_overlays.is_empty() {
531-
// No overlay paths recorded in state — fall back to the root compose file
532-
// so containers are always stopped even if state was written without overlays.
533-
if let Some(cp) = compose::find_compose_file(root) {
534-
let _ = crate::docker::compose_down(
534+
if !session.compose_overlays.is_empty() {
535+
for pair in &session.compose_overlays {
536+
let _ = docker::compose_down(
535537
project,
536-
&cp.to_string_lossy(),
537-
None,
538+
&pair.compose,
539+
Some(&pair.overlay),
538540
!keep_volumes,
539541
);
542+
let _ = std::fs::remove_file(&pair.overlay);
540543
}
541544
} else {
542-
tear_down_all_overlays(project, root, &all_overlays, !keep_volumes);
543-
for ov in &all_overlays {
544-
let _ = std::fs::remove_file(ov);
545+
// Legacy state without compose_overlays: reconstruct compose
546+
// paths from overlay filenames.
547+
let all_overlays: Vec<String> = session
548+
.overlay_file
549+
.iter()
550+
.cloned()
551+
.chain(session.overlay_files.iter().cloned())
552+
.collect();
553+
554+
if all_overlays.is_empty() {
555+
// No overlay paths recorded in state — fall back to the root compose file
556+
// so containers are always stopped even if state was written without overlays.
557+
if let Some(cp) = compose::find_compose_file(root) {
558+
let _ = crate::docker::compose_down(
559+
project,
560+
&cp.to_string_lossy(),
561+
None,
562+
!keep_volumes,
563+
);
564+
}
565+
} else {
566+
tear_down_all_overlays(project, root, &all_overlays, !keep_volumes);
567+
for ov in &all_overlays {
568+
let _ = std::fs::remove_file(ov);
569+
}
545570
}
546571
}
547572
}

src/modes/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,11 @@ pub fn overlay_name_for_compose(slug: &str, compose_path: &Path, root: &Path) ->
9797
format!("{}-{}.yml", slug, stem)
9898
}
9999

100-
/// Tear down all (compose, overlay) pairs. For each overlay, reconstruct the
101-
/// compose file path from the overlay filename, falling back to the root compose.
100+
/// Legacy teardown for state files that predate `Session.compose_overlays`:
101+
/// reconstructs each overlay's compose file from the overlay *filename*,
102+
/// falling back to the root compose. Filename reconstruction is ambiguous for
103+
/// hyphenated slugs (see `compose_file_for_overlay`) — sessions written by
104+
/// current versions carry explicit pairs and never go through this path.
102105
pub fn tear_down_all_overlays(
103106
project: &str,
104107
root: &Path,

src/slot.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mod tests {
4040
compose_project: None,
4141
overlay_file: None,
4242
overlay_files: vec![],
43+
compose_overlays: vec![],
4344
app_port: None,
4445
started_at: "2026-01-01T00:00:00Z".into(),
4546
port_overrides: std::collections::HashMap::new(),

0 commit comments

Comments
 (0)