From aaf6b7cae7f195b78fa098c029e606aeaa85fe2c Mon Sep 17 00:00:00 2001 From: "dahyman91@gmail.com" Date: Sun, 22 Mar 2026 15:41:32 -0400 Subject: [PATCH 1/2] [dh] fix: resolve -w flag by directory name, not just branch name coast assign -w foo now finds .claude/worktrees/foo even when its branch is worktree-foo. The worktree resolution order is: 1. Directory name match in local worktree dirs 2. Directory name then branch name match in external dirs 3. Branch name match in local dirs (new) 4. Auto-detected git worktree dir (for new worktree creation) 5. Default fallback Previously try_git_detected ran first and short-circuited the directory-name lookup, causing Coast to create a duplicate worktree instead of mounting the existing one. Also splits match_porcelain_to_external into two passes (directory first, branch second) so directory matches are always preferred. --- coast-daemon/src/handlers/assign/services.rs | 370 +++++++++++++++++-- 1 file changed, 348 insertions(+), 22 deletions(-) diff --git a/coast-daemon/src/handlers/assign/services.rs b/coast-daemon/src/handlers/assign/services.rs index a5ae298..a38086b 100644 --- a/coast-daemon/src/handlers/assign/services.rs +++ b/coast-daemon/src/handlers/assign/services.rs @@ -221,6 +221,7 @@ async fn discover_and_classify( (actions, all_hot) } +#[allow(clippy::cognitive_complexity)] // Linear phase chain — reads clearly top to bottom. async fn detect_worktree_path( project_root: &Option, worktree_dirs: &[String], @@ -230,26 +231,39 @@ async fn detect_worktree_path( let root = project_root.as_ref()?; let step_t = std::time::Instant::now(); + // Phase 1: Directory name match in local worktree dirs. + if let Some(loc) = find_worktree_in_local_dirs(root, worktree_dirs, worktree_name) { + info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "resolved worktree by directory name (local)"); + return Some(loc); + } + + // Phase 2: Directory name + branch name match in external dirs (directory preferred). + if let Some(loc) = find_worktree_in_external_dirs(root, worktree_dirs, worktree_name).await { + info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "found worktree in external dir"); + return Some(loc); + } + + // Phase 3: Branch name match in local worktree dirs. + if let Some(loc) = + find_worktree_by_branch_in_local_dirs(root, worktree_dirs, worktree_name).await + { + info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "found worktree by branch name (local)"); + return Some(loc); + } + + // Phase 4: Auto-detected git worktree dir (for new worktree creation). let root_clone = root.clone(); let git_detected = tokio::task::spawn_blocking(move || detect_worktree_dir_from_git(&root_clone)) .await .ok() .flatten(); - - let loc = try_git_detected(root, git_detected.as_deref(), worktree_name) - .or_else(|| find_worktree_in_local_dirs(root, worktree_dirs, worktree_name)); - - if let Some(loc) = loc { - info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "resolved worktree location"); - return Some(loc); - } - - if let Some(loc) = find_worktree_in_external_dirs(root, worktree_dirs, worktree_name).await { - info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "found worktree in external dir"); + if let Some(loc) = try_git_detected(root, git_detected.as_deref(), worktree_name) { + info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %loc.wt_dir, "using git-detected worktree directory"); return Some(loc); } + // Phase 5: Default fallback. let path = root.join(default_wt_dir).join(worktree_name); let mount_src = format!("/host-project/{default_wt_dir}/{worktree_name}"); info!(elapsed_ms = step_t.elapsed().as_millis() as u64, wt_dir = %default_wt_dir, "using default worktree directory"); @@ -346,40 +360,151 @@ async fn find_worktree_in_external_dirs( match_porcelain_to_external(&stdout, worktree_name, &external_dirs) } -fn match_porcelain_to_external( - porcelain: &str, +/// Find an existing worktree by branch name in local (non-external) worktree dirs. +async fn find_worktree_by_branch_in_local_dirs( + project_root: &std::path::Path, + worktree_dirs: &[String], worktree_name: &str, - external_dirs: &[(usize, String, std::path::PathBuf)], ) -> Option { + use coast_core::coastfile::Coastfile; + + let local_dirs: Vec<&String> = worktree_dirs + .iter() + .filter(|d| !Coastfile::is_external_worktree_dir(d)) + .collect(); + if local_dirs.is_empty() { + return None; + } + + let output = tokio::process::Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(project_root) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let canonical_root = project_root.canonicalize().ok()?; + let entries = parse_porcelain_entries(&stdout); + + for entry in &entries { + let branch_name = if let Some(branch_ref) = entry.branch_line.strip_prefix("branch ") { + branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref) + } else { + continue; + }; + + if branch_name != worktree_name { + continue; + } + + let canonical = entry + .path + .canonicalize() + .unwrap_or_else(|_| entry.path.clone()); + if let Ok(relative) = canonical.strip_prefix(&canonical_root) { + let rel_str = relative.display().to_string(); + for dir in &local_dirs { + let dir_prefix = format!("{}/", dir); + if rel_str.starts_with(&dir_prefix) { + return Some(WorktreeLocation { + wt_dir: (*dir).clone(), + host_path: canonical, + container_mount_src: format!("/host-project/{rel_str}"), + }); + } + } + } + } + + None +} + +/// Parsed entry from `git worktree list --porcelain`. +struct PorcelainEntry { + path: std::path::PathBuf, + /// The "branch refs/heads/..." or "detached" line. + branch_line: String, +} + +fn parse_porcelain_entries(porcelain: &str) -> Vec { + let mut entries = Vec::new(); let mut current_path: Option = None; for line in porcelain.lines() { if let Some(path_str) = line.strip_prefix("worktree ") { current_path = Some(std::path::PathBuf::from(path_str)); } else if line.starts_with("branch ") || line == "detached" { - if let Some(loc) = - try_match_external_worktree(line, ¤t_path, worktree_name, external_dirs) - { - return Some(loc); + if let Some(path) = current_path.take() { + entries.push(PorcelainEntry { + path, + branch_line: line.to_string(), + }); } } else if line.is_empty() { current_path = None; } } + entries +} + +/// Whether to match by directory name (relative path) or branch name. +#[derive(Clone, Copy, PartialEq, Eq)] +enum MatchMode { + DirOnly, + BranchOnly, +} + +fn match_porcelain_to_external( + porcelain: &str, + worktree_name: &str, + external_dirs: &[(usize, String, std::path::PathBuf)], +) -> Option { + let entries = parse_porcelain_entries(porcelain); + + // First pass: directory name (relative path) match — more specific. + for entry in &entries { + if let Some(loc) = try_match_external_worktree( + &entry.branch_line, + &entry.path, + worktree_name, + external_dirs, + MatchMode::DirOnly, + ) { + return Some(loc); + } + } + + // Second pass: branch name match. + for entry in &entries { + if let Some(loc) = try_match_external_worktree( + &entry.branch_line, + &entry.path, + worktree_name, + external_dirs, + MatchMode::BranchOnly, + ) { + return Some(loc); + } + } + None } fn try_match_external_worktree( line: &str, - current_path: &Option, + wt_path: &std::path::Path, worktree_name: &str, external_dirs: &[(usize, String, std::path::PathBuf)], + mode: MatchMode, ) -> Option { use coast_core::coastfile::Coastfile; - let wt_path = current_path.as_ref()?; - let wt_canonical = wt_path.canonicalize().unwrap_or_else(|_| wt_path.clone()); + let wt_canonical = wt_path.canonicalize().unwrap_or_else(|_| wt_path.to_path_buf()); let branch_name = if let Some(branch_ref) = line.strip_prefix("branch ") { branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref) @@ -396,7 +521,11 @@ fn try_match_external_worktree( .strip_prefix(&canon_ext) .unwrap_or(&wt_canonical); let relative_str = relative.display().to_string(); - if branch_name == worktree_name || relative_str == worktree_name { + let matches = match mode { + MatchMode::DirOnly => relative_str == worktree_name, + MatchMode::BranchOnly => branch_name == worktree_name, + }; + if matches { let ext_mount = Coastfile::external_mount_path(*idx); let mount_src = format!("{ext_mount}/{relative_str}"); return Some(WorktreeLocation { @@ -1808,4 +1937,201 @@ mod tests { "failed syncs must not leave behind a success marker" ); } + + // ----------------------------------------------------------------------- + // Worktree matching tests + // ----------------------------------------------------------------------- + + #[test] + fn test_find_worktree_in_local_dirs_by_dirname() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + git_in(root, &["init", "-b", "main"]); + git_in(root, &["commit", "--allow-empty", "-m", "init"]); + + // Create worktree with mismatched branch name (simulates Claude Code). + let wt_parent = root.join(".claude").join("worktrees"); + std::fs::create_dir_all(&wt_parent).unwrap(); + git_in(root, &["branch", "worktree-foo"]); + git_in( + root, + &[ + "worktree", + "add", + &wt_parent.join("foo").to_string_lossy(), + "worktree-foo", + ], + ); + + let dirs = vec![".claude/worktrees".to_string(), ".worktrees".to_string()]; + // Directory name "foo" should match even though branch is "worktree-foo". + let loc = find_worktree_in_local_dirs(root, &dirs, "foo"); + assert!(loc.is_some(), "should find worktree by directory name"); + let loc = loc.unwrap(); + assert_eq!(loc.wt_dir, ".claude/worktrees"); + assert!(loc.host_path.ends_with(".claude/worktrees/foo")); + assert_eq!(loc.container_mount_src, "/host-project/.claude/worktrees/foo"); + } + + #[test] + fn test_find_worktree_in_local_dirs_branch_name_does_not_match() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + git_in(root, &["init", "-b", "main"]); + git_in(root, &["commit", "--allow-empty", "-m", "init"]); + + let wt_parent = root.join(".claude").join("worktrees"); + std::fs::create_dir_all(&wt_parent).unwrap(); + git_in(root, &["branch", "worktree-foo"]); + git_in( + root, + &[ + "worktree", + "add", + &wt_parent.join("foo").to_string_lossy(), + "worktree-foo", + ], + ); + + let dirs = vec![".claude/worktrees".to_string()]; + // Branch name "worktree-foo" should NOT match via directory name lookup. + let loc = find_worktree_in_local_dirs(root, &dirs, "worktree-foo"); + assert!(loc.is_none(), "branch name should not match in directory-name lookup"); + } + + #[tokio::test] + async fn test_find_worktree_by_branch_in_local_dirs_matches() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + git_in(root, &["init", "-b", "main"]); + git_in(root, &["commit", "--allow-empty", "-m", "init"]); + + let wt_parent = root.join(".claude").join("worktrees"); + std::fs::create_dir_all(&wt_parent).unwrap(); + git_in(root, &["branch", "worktree-foo"]); + git_in( + root, + &[ + "worktree", + "add", + &wt_parent.join("foo").to_string_lossy(), + "worktree-foo", + ], + ); + + let dirs = vec![".claude/worktrees".to_string()]; + // Branch name "worktree-foo" should match via branch lookup. + let loc = find_worktree_by_branch_in_local_dirs(root, &dirs, "worktree-foo").await; + assert!(loc.is_some(), "should find worktree by branch name"); + let loc = loc.unwrap(); + assert_eq!(loc.wt_dir, ".claude/worktrees"); + assert!(loc.host_path.ends_with(".claude/worktrees/foo")); + } + + #[tokio::test] + async fn test_find_worktree_by_branch_skips_external_dirs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + + git_in(root, &["init", "-b", "main"]); + git_in(root, &["commit", "--allow-empty", "-m", "init"]); + + let wt_parent = root.join(".worktrees"); + std::fs::create_dir_all(&wt_parent).unwrap(); + git_in(root, &["branch", "feat-a"]); + git_in( + root, + &[ + "worktree", + "add", + &wt_parent.join("feat-a").to_string_lossy(), + "feat-a", + ], + ); + + // Only external dirs configured — should not find via local branch scan. + let dirs = vec!["~/external/worktrees".to_string()]; + let loc = find_worktree_by_branch_in_local_dirs(root, &dirs, "feat-a").await; + assert!(loc.is_none(), "should not match worktrees in external-only config"); + } + + #[test] + fn test_match_porcelain_prefers_dirname_over_branch() { + // Worktree A: relative path "foo", branch "bar" + // Worktree B: relative path "baz", branch "foo" + // Searching for "foo" should prefer A (directory match) over B (branch match). + let ext_dir = tempfile::tempdir().unwrap(); + let ext_path = ext_dir.path().to_path_buf(); + + let wt_a = ext_path.join("foo"); + let wt_b = ext_path.join("baz"); + std::fs::create_dir_all(&wt_a).unwrap(); + std::fs::create_dir_all(&wt_b).unwrap(); + + let porcelain = format!( + "worktree {}\nbranch refs/heads/bar\n\nworktree {}\nbranch refs/heads/foo\n\n", + wt_a.display(), + wt_b.display(), + ); + + let external_dirs = vec![(0_usize, "~/ext".to_string(), ext_path)]; + + let loc = match_porcelain_to_external(&porcelain, "foo", &external_dirs); + assert!(loc.is_some(), "should find a match"); + let loc = loc.unwrap(); + // Should match worktree A by directory name, not worktree B by branch name. + assert!( + loc.host_path.ends_with("foo"), + "should prefer directory match: got {:?}", + loc.host_path + ); + } + + #[test] + fn test_match_porcelain_falls_back_to_branch() { + let ext_dir = tempfile::tempdir().unwrap(); + let ext_path = ext_dir.path().to_path_buf(); + + let wt = ext_path.join("some-dir"); + std::fs::create_dir_all(&wt).unwrap(); + + let porcelain = format!( + "worktree {}\nbranch refs/heads/my-branch\n\n", + wt.display(), + ); + + let external_dirs = vec![(0_usize, "~/ext".to_string(), ext_path)]; + + // No directory match for "my-branch", but branch matches. + let loc = match_porcelain_to_external(&porcelain, "my-branch", &external_dirs); + assert!(loc.is_some(), "should fall back to branch name match"); + } + + #[test] + fn test_parse_porcelain_entries() { + let porcelain = "/root\nbranch refs/heads/main\n\n/root/.worktrees/feat\nbranch refs/heads/feat\n\n"; + // Prefix "worktree " is required. + let porcelain = "worktree /root\nbranch refs/heads/main\n\nworktree /root/.worktrees/feat\nbranch refs/heads/feat\n\n"; + let entries = parse_porcelain_entries(porcelain); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].path, std::path::PathBuf::from("/root")); + assert_eq!(entries[0].branch_line, "branch refs/heads/main"); + assert_eq!( + entries[1].path, + std::path::PathBuf::from("/root/.worktrees/feat") + ); + assert_eq!(entries[1].branch_line, "branch refs/heads/feat"); + } + + #[test] + fn test_parse_porcelain_entries_detached() { + let porcelain = + "worktree /root/.worktrees/abc\ndetached\n\n"; + let entries = parse_porcelain_entries(porcelain); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].branch_line, "detached"); + } } From a68c21d7ada338638c043929814f7b2e8ed40650 Mon Sep 17 00:00:00 2001 From: Jamie Sunderland Date: Sun, 22 Mar 2026 14:02:43 -0700 Subject: [PATCH 2/2] [jrs] fix: resolve worktree by directory name, fix coast_home path for coast-dev --- coast-cli/src/commands/doctor.rs | 4 ++- coast-cli/src/commands/mod.rs | 7 ++-- coast-daemon/src/handlers/assign/services.rs | 30 ++++++++++------ coast-daemon/src/handlers/assign/util.rs | 37 +++++++------------- coast-daemon/src/handlers/builds.rs | 22 ++++++++++-- coast-daemon/src/handlers/mod.rs | 18 +++++++--- coast-daemon/src/handlers/start.rs | 33 ++++++++++++----- coast-daemon/src/handlers/stop.rs | 4 +++ 8 files changed, 100 insertions(+), 55 deletions(-) diff --git a/coast-cli/src/commands/doctor.rs b/coast-cli/src/commands/doctor.rs index de6cad0..61c5872 100644 --- a/coast-cli/src/commands/doctor.rs +++ b/coast-cli/src/commands/doctor.rs @@ -801,7 +801,9 @@ mod tests { #[test] fn test_active_state_db_path_uses_coast_home_env() { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let prev = std::env::var_os("COAST_HOME"); unsafe { std::env::set_var("COAST_HOME", "/tmp/coast-dev-test-home"); diff --git a/coast-cli/src/commands/mod.rs b/coast-cli/src/commands/mod.rs index 9b115ad..a924009 100644 --- a/coast-cli/src/commands/mod.rs +++ b/coast-cli/src/commands/mod.rs @@ -888,9 +888,12 @@ mod tests { #[test] fn test_socket_path() { - with_temp_coast_home(|coast_home| { + with_temp_coast_home(|_| { let path = socket_path(); - assert_eq!(path, coast_home.join("coastd.sock")); + let expected = coast_core::artifact::coast_home() + .expect("coast_home") + .join("coastd.sock"); + assert_eq!(path, expected); }); } diff --git a/coast-daemon/src/handlers/assign/services.rs b/coast-daemon/src/handlers/assign/services.rs index a38086b..be3b183 100644 --- a/coast-daemon/src/handlers/assign/services.rs +++ b/coast-daemon/src/handlers/assign/services.rs @@ -504,7 +504,9 @@ fn try_match_external_worktree( ) -> Option { use coast_core::coastfile::Coastfile; - let wt_canonical = wt_path.canonicalize().unwrap_or_else(|_| wt_path.to_path_buf()); + let wt_canonical = wt_path + .canonicalize() + .unwrap_or_else(|_| wt_path.to_path_buf()); let branch_name = if let Some(branch_ref) = line.strip_prefix("branch ") { branch_ref.strip_prefix("refs/heads/").unwrap_or(branch_ref) @@ -1971,7 +1973,10 @@ mod tests { let loc = loc.unwrap(); assert_eq!(loc.wt_dir, ".claude/worktrees"); assert!(loc.host_path.ends_with(".claude/worktrees/foo")); - assert_eq!(loc.container_mount_src, "/host-project/.claude/worktrees/foo"); + assert_eq!( + loc.container_mount_src, + "/host-project/.claude/worktrees/foo" + ); } #[test] @@ -1998,7 +2003,10 @@ mod tests { let dirs = vec![".claude/worktrees".to_string()]; // Branch name "worktree-foo" should NOT match via directory name lookup. let loc = find_worktree_in_local_dirs(root, &dirs, "worktree-foo"); - assert!(loc.is_none(), "branch name should not match in directory-name lookup"); + assert!( + loc.is_none(), + "branch name should not match in directory-name lookup" + ); } #[tokio::test] @@ -2055,7 +2063,10 @@ mod tests { // Only external dirs configured — should not find via local branch scan. let dirs = vec!["~/external/worktrees".to_string()]; let loc = find_worktree_by_branch_in_local_dirs(root, &dirs, "feat-a").await; - assert!(loc.is_none(), "should not match worktrees in external-only config"); + assert!( + loc.is_none(), + "should not match worktrees in external-only config" + ); } #[test] @@ -2098,10 +2109,7 @@ mod tests { let wt = ext_path.join("some-dir"); std::fs::create_dir_all(&wt).unwrap(); - let porcelain = format!( - "worktree {}\nbranch refs/heads/my-branch\n\n", - wt.display(), - ); + let porcelain = format!("worktree {}\nbranch refs/heads/my-branch\n\n", wt.display(),); let external_dirs = vec![(0_usize, "~/ext".to_string(), ext_path)]; @@ -2112,7 +2120,8 @@ mod tests { #[test] fn test_parse_porcelain_entries() { - let porcelain = "/root\nbranch refs/heads/main\n\n/root/.worktrees/feat\nbranch refs/heads/feat\n\n"; + let porcelain = + "/root\nbranch refs/heads/main\n\n/root/.worktrees/feat\nbranch refs/heads/feat\n\n"; // Prefix "worktree " is required. let porcelain = "worktree /root\nbranch refs/heads/main\n\nworktree /root/.worktrees/feat\nbranch refs/heads/feat\n\n"; let entries = parse_porcelain_entries(porcelain); @@ -2128,8 +2137,7 @@ mod tests { #[test] fn test_parse_porcelain_entries_detached() { - let porcelain = - "worktree /root/.worktrees/abc\ndetached\n\n"; + let porcelain = "worktree /root/.worktrees/abc\ndetached\n\n"; let entries = parse_porcelain_entries(porcelain); assert_eq!(entries.len(), 1); assert_eq!(entries[0].branch_line, "detached"); diff --git a/coast-daemon/src/handlers/assign/util.rs b/coast-daemon/src/handlers/assign/util.rs index 8c5930a..9ce2b62 100644 --- a/coast-daemon/src/handlers/assign/util.rs +++ b/coast-daemon/src/handlers/assign/util.rs @@ -22,11 +22,14 @@ pub(super) struct CoastfileData { pub has_compose: bool, } -pub(super) fn load_coastfile_data(project: &str) -> CoastfileData { - let home = dirs::home_dir().unwrap_or_default(); - let coastfile_path = home - .join(".coast") +fn coast_images_dir() -> std::path::PathBuf { + coast_core::artifact::coast_home() + .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".coast")) .join("images") +} + +pub(super) fn load_coastfile_data(project: &str) -> CoastfileData { + let coastfile_path = coast_images_dir() .join(project) .join("latest") .join("coastfile.toml"); @@ -49,10 +52,7 @@ pub(super) fn load_coastfile_data(project: &str) -> CoastfileData { } pub fn has_compose(project: &str) -> bool { - let home = dirs::home_dir().unwrap_or_default(); - let coastfile_path = home - .join(".coast") - .join("images") + let coastfile_path = coast_images_dir() .join(project) .join("latest") .join("coastfile.toml"); @@ -65,8 +65,7 @@ pub fn has_compose(project: &str) -> bool { } pub fn read_project_root(project: &str) -> Option { - let home = dirs::home_dir()?; - let project_dir = home.join(".coast").join("images").join(project); + let project_dir = coast_images_dir().join(project); let manifest_path = project_dir.join("latest").join("manifest.json"); let content = std::fs::read_to_string(manifest_path).ok()?; let manifest: serde_json::Value = serde_json::from_str(&content).ok()?; @@ -100,23 +99,11 @@ pub(super) async fn revert_assign_status( } pub(super) fn check_has_bare_install(project: &str, build_id: Option<&str>) -> bool { - let home = dirs::home_dir().unwrap_or_default(); + let images = coast_images_dir(); let cf_path = build_id - .map(|bid| { - home.join(".coast") - .join("images") - .join(project) - .join(bid) - .join("coastfile.toml") - }) + .map(|bid| images.join(project).join(bid).join("coastfile.toml")) .filter(|p| p.exists()) - .unwrap_or_else(|| { - home.join(".coast") - .join("images") - .join(project) - .join("latest") - .join("coastfile.toml") - }); + .unwrap_or_else(|| images.join(project).join("latest").join("coastfile.toml")); coast_core::coastfile::Coastfile::from_file(&cf_path) .map(|cf| cf.services.iter().any(|s| !s.install.is_empty())) .unwrap_or(false) diff --git a/coast-daemon/src/handlers/builds.rs b/coast-daemon/src/handlers/builds.rs index 2622e8f..bff929d 100644 --- a/coast-daemon/src/handlers/builds.rs +++ b/coast-daemon/src/handlers/builds.rs @@ -880,16 +880,31 @@ mod tests { use super::*; use crate::state::StateDb; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + struct EnvGuard { key: &'static str, previous: Option, + _lock: MutexGuard<'static, ()>, } impl EnvGuard { fn set(key: &'static str, value: &std::path::Path) -> Self { + let _lock = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::env::var(key).ok(); std::env::set_var(key, value); - Self { key, previous } + Self { + key, + previous, + _lock, + } } } @@ -1080,7 +1095,10 @@ mod tests { let resolved = resolve_build_dir("overhead", Some("latest")); assert_eq!(resolved, Some(project_dir.join("latest"))); - assert_eq!(std::fs::canonicalize(resolved.unwrap()).unwrap(), build_dir); + assert_eq!( + std::fs::canonicalize(resolved.unwrap()).unwrap(), + std::fs::canonicalize(&build_dir).unwrap() + ); } #[test] diff --git a/coast-daemon/src/handlers/mod.rs b/coast-daemon/src/handlers/mod.rs index e899139..dc895f3 100644 --- a/coast-daemon/src/handlers/mod.rs +++ b/coast-daemon/src/handlers/mod.rs @@ -18,8 +18,10 @@ use crate::server::AppState; /// Build artifacts are stored at `~/.coast/images/{project}/{build_id}/coastfile.toml`. /// When no `build_id` is provided, falls back to the `latest` symlink. pub fn artifact_coastfile_path(project: &str, build_id: Option<&str>) -> std::path::PathBuf { - let home = dirs::home_dir().unwrap_or_default(); - let mut base = home.join(".coast").join("images").join(project); + let mut base = coast_core::artifact::coast_home() + .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default().join(".coast")) + .join("images") + .join(project); if let Some(build_id) = build_id { base = base.join(build_id); } else { @@ -101,7 +103,9 @@ mod compose_context_tests { #[test] fn test_clear_checked_out_state_clears_pids_and_updates_status() { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); unsafe { std::env::remove_var("WSL_DISTRO_NAME"); std::env::remove_var("WSL_INTEROP"); @@ -174,7 +178,9 @@ mod compose_context_tests { #[test] fn test_clear_checked_out_state_succeeds_with_stale_zombie_pid() { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); unsafe { std::env::remove_var("WSL_DISTRO_NAME"); std::env::remove_var("WSL_INTEROP"); @@ -220,7 +226,9 @@ mod compose_context_tests { #[test] fn test_clear_checked_out_state_keeps_checked_out_when_wsl_bridge_removal_fails() { - let _guard = env_lock().lock().unwrap(); + let _guard = env_lock() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let db = crate::state::StateDb::open_in_memory().unwrap(); let instance = coast_core::types::CoastInstance { name: "dev-1".to_string(), diff --git a/coast-daemon/src/handlers/start.rs b/coast-daemon/src/handlers/start.rs index 5f9aeb4..8e9c3e8 100644 --- a/coast-daemon/src/handlers/start.rs +++ b/coast-daemon/src/handlers/start.rs @@ -435,12 +435,6 @@ fn compute_start_mount_src( .as_ref() .and_then(|root| super::assign::detect_worktree_dir_from_git(root)); - if let Some(ref d) = detected { - if !Coastfile::is_external_worktree_dir(d) { - return format!("/host-project/{d}/{wt}"); - } - } - let worktree_dirs = coastfile .map(|cf| cf.worktree_dirs.clone()) .unwrap_or_else(|| vec![".worktrees".to_string()]); @@ -448,6 +442,20 @@ fn compute_start_mount_src( .map(|cf| cf.default_worktree_dir.clone()) .unwrap_or_else(|| ".worktrees".to_string()); + // Phase 1: Directory name match in local worktree dirs (handles branch != dir name). + if let Some(ref root) = project_root { + for dir in &worktree_dirs { + if Coastfile::is_external_worktree_dir(dir) { + continue; + } + let candidate = root.join(dir).join(wt); + if candidate.exists() { + return format!("/host-project/{dir}/{wt}"); + } + } + } + + // Phase 2: External worktree dirs (directory + branch match). if let Some(ref root) = project_root { for (idx, dir) in worktree_dirs.iter().enumerate() { if Coastfile::is_external_worktree_dir(dir) { @@ -455,10 +463,17 @@ fn compute_start_mount_src( if let Some(mount) = find_external_wt_mount_src(root, &resolved, idx, wt) { return mount; } - } else { - let candidate = root.join(dir).join(wt); + } + } + } + + // Phase 3: Git-detected worktree dir (creation fallback). + if let Some(ref d) = detected { + if !Coastfile::is_external_worktree_dir(d) { + if let Some(ref root) = project_root { + let candidate = root.join(d).join(wt); if candidate.exists() { - return format!("/host-project/{dir}/{wt}"); + return format!("/host-project/{d}/{wt}"); } } } diff --git a/coast-daemon/src/handlers/stop.rs b/coast-daemon/src/handlers/stop.rs index 2565024..fc2991b 100644 --- a/coast-daemon/src/handlers/stop.rs +++ b/coast-daemon/src/handlers/stop.rs @@ -339,6 +339,10 @@ mod tests { #[tokio::test] async fn test_stop_checked_out_instance() { + unsafe { + std::env::remove_var("WSL_DISTRO_NAME"); + std::env::remove_var("WSL_INTEROP"); + } let state = test_state(); { let db = state.db.lock().await;