diff --git a/src/core/worktree.rs b/src/core/worktree.rs index d8cfcd37..f58131fe 100644 --- a/src/core/worktree.rs +++ b/src/core/worktree.rs @@ -166,6 +166,16 @@ pub fn managed(cwd: &Path) -> Option { }) } +/// Whether any of `cwds` still lives inside the worktree at `path` — removing +/// the checkout then would pull the directory out from under a live shell (new +/// tabs inherit the current cwd, so two tabs sharing one worktree is common). +/// Each cwd is canonicalized before the ancestor test to match the physical +/// path git reported; a vanished cwd never counts as occupying. +pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool { + cwds.iter() + .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(path))) +} + /// Remove a managed worktree (`git worktree remove`, `--force` to discard /// uncommitted changes), then best-effort delete its branch with `-d` — so a /// branch carrying unmerged commits survives the cleanup. @@ -481,6 +491,20 @@ mod tests { let _ = std::fs::remove_dir_all(&own); } + #[test] + fn occupied_detects_live_cwds_inside_the_worktree() { + let repo = temp_repo("occ"); + let wt = create(&repo, &req("occ-wt")).unwrap(); + let inside = wt.path.join("deep"); + std::fs::create_dir_all(&inside).unwrap(); + assert!(occupied(&wt.path, &[repo.clone(), inside])); + // Cwds elsewhere in the repo don't count… + assert!(!occupied(&wt.path, &[repo.clone()])); + // …and neither does a cwd that no longer exists. + assert!(!occupied(&wt.path, &[wt.path.join("gone")])); + let _ = std::fs::remove_dir_all(&repo); + } + #[test] fn create_outside_a_repo_errors() { let plain = scratch("plain"); diff --git a/src/ui/app.rs b/src/ui/app.rs index 9213da30..4c2ab6b3 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1968,12 +1968,26 @@ impl Tty7App { /// clean worktree gets a plain keep/remove prompt; one with uncommitted /// changes defaults to keeping and makes discarding explicit. Removal also /// deletes the branch when it carries no unmerged commits (`branch -d`). + /// No offer while any surviving pane still has its cwd inside the checkout + /// (new tabs inherit the current cwd, so shared worktrees are common) — + /// removal would yank the directory out from under a live shell. /// Detection, the dirty probe, and removal all run off the UI thread. fn offer_worktree_cleanup(&mut self, cwd: Option, cx: &mut Context) { let Some(cwd) = cwd else { return }; + // Every leaf of every surviving tab, not just focused panes — a shell + // tucked away in a split occupies the worktree all the same. + let open_cwds: Vec = self + .tabs + .iter() + .flat_map(|tab| tab.pane.leaves()) + .filter_map(|leaf| leaf.read(cx).cwd()) + .collect(); cx.spawn(async move |this, cx| { let Some(wt) = cx - .background_spawn(async move { crate::core::worktree::managed(&cwd) }) + .background_spawn(async move { + crate::core::worktree::managed(&cwd) + .filter(|wt| !crate::core::worktree::occupied(&wt.path, &open_cwds)) + }) .await else { return;