From 3ec1577a173e31b9f16e993e35eb510690c468ed Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:03:13 +0800 Subject: [PATCH 1/3] feat(tabs): per-tab context menu with worktree tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a tab chip (strip) or row (sidebar) opens a context menu: rename, New Worktree Tab, splits, copy working directory, and a close group (tab / others / to the right or below). New Worktree Tab opens a sheet pre-filled with a generated two-word name (used for both the directory and the new branch) and the current branch as the start point — all three editable, with a live path preview. The checkout lands in the repository's own .tty7/worktrees/, kept out of git status by an auto-written self-ignoring .tty7/.gitignore. The entry only appears when the tab's cwd is inside a git repository. Closing a tab that sat in a managed worktree offers to remove the checkout (and its branch when fully merged); dirty checkouts default to keeping and make the discard explicit. --- src/core/mod.rs | 1 + src/core/worktree.rs | 492 ++++++++++++++++++++++++++++++++++++++ src/ui/app.rs | 183 ++++++++++++++ src/ui/mod.rs | 1 + src/ui/tab_sidebar.rs | 9 +- src/ui/tab_strip.rs | 135 ++++++++++- src/ui/worktree_prompt.rs | 233 ++++++++++++++++++ 7 files changed, 1049 insertions(+), 5 deletions(-) create mode 100644 src/core/worktree.rs create mode 100644 src/ui/worktree_prompt.rs diff --git a/src/core/mod.rs b/src/core/mod.rs index bd65e704..073b26a7 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -26,3 +26,4 @@ pub mod ssh_profile; pub mod threads; pub mod update; pub mod window_state; +pub mod worktree; diff --git a/src/core/worktree.rs b/src/core/worktree.rs new file mode 100644 index 00000000..d8cfcd37 --- /dev/null +++ b/src/core/worktree.rs @@ -0,0 +1,492 @@ +//! Git-worktree support for the tab context menu's "New Worktree Tab": derive +//! the repo from a pane's cwd, propose an unused two-word name (editable in the +//! sheet, see `ui::worktree_prompt`), and run `git worktree add -b` under the +//! repository's own `.tty7/worktrees/` (kept out of `git status` by an +//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an +//! isolated checkout on its own branch, physically next to the code it forks. +//! Blocking (spawns `git`); callers run it on the background executor, except +//! [`is_inside_repo`], which is a pure filesystem probe cheap enough for +//! menu-open time. + +use std::path::{Path, PathBuf}; + +/// Word pools for generated branch names (`quiet-otter`). Short, lowercase, +/// branch-safe; two pools of 24 give 576 combinations before the numeric +/// fallback in [`defaults`] kicks in. +const ADJECTIVES: [&str; 24] = [ + "quiet", "amber", "bold", "calm", "cedar", "coral", "dusky", "early", "fable", "gold", "hazel", + "ivory", "jade", "keen", "lunar", "mossy", "noble", "ochre", "pale", "rapid", "sunny", "tidal", + "vivid", "wild", +]; +const NOUNS: [&str; 24] = [ + "otter", "heron", "lynx", "wren", "fox", "elk", "crane", "finch", "gecko", "ibis", "koala", + "llama", "marten", "newt", "osprey", "puffin", "quail", "raven", "seal", "tern", "urchin", + "vole", "walrus", "yak", +]; + +/// A freshly created worktree: where it lives and the branch checked out in it. +#[derive(Debug)] +pub struct NewWorktree { + pub path: PathBuf, + pub branch: String, +} + +/// What to create, as confirmed (or edited) in the sheet: the checkout's +/// directory name under the managed root, the new branch's name, and the +/// commit-ish it starts from. +#[derive(Debug, Clone)] +pub struct WorktreeRequest { + pub name: String, + pub branch: String, + pub base: String, +} + +/// Pre-filled values for the sheet: an unused two-word candidate (offered as +/// both directory name and branch), the branch currently checked out (the +/// natural start point; `"HEAD"` when detached), and the directory the new +/// checkout would land in, for the live path preview. +#[derive(Debug, Clone)] +pub struct WorktreeDefaults { + pub name: String, + pub base: String, + pub dir: PathBuf, +} + +/// Whether `cwd` sits inside a git repository — an upward scan for `.git` +/// (a directory in a primary checkout, a file in a linked worktree or +/// submodule). No subprocess: the tab context menu calls this while opening. +pub fn is_inside_repo(cwd: &Path) -> bool { + cwd.ancestors().any(|d| d.join(".git").exists()) +} + +/// Run `git -C `, returning trimmed stdout on success and trimmed +/// stderr as the error otherwise. +fn git(dir: &Path, args: &[&str]) -> Result { + let out = std::process::Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .map_err(|e| format!("failed to run git: {e}"))?; + if out.status.success() { + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } +} + +/// Whether `name` already exists as a local branch in the repo at `repo_root`. +/// A failed probe (`--verify --quiet` exits non-zero) means it's free. +fn branch_exists(repo_root: &Path, name: &str) -> bool { + git( + repo_root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("refs/heads/{name}"), + ], + ) + .is_ok() +} + +/// A tiny xorshift over a time+pid seed — enough randomness to spread branch +/// names without pulling in a `rand` dependency. +fn seed() -> u64 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0x9E37_79B9_7F4A_7C15); + nanos ^ ((std::process::id() as u64) << 32) | 1 +} + +fn next(state: &mut u64) -> u64 { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *state +} + +/// One `adjective-noun` candidate from the pools. +fn candidate(state: &mut u64) -> String { + let a = ADJECTIVES[(next(state) % ADJECTIVES.len() as u64) as usize]; + let n = NOUNS[(next(state) % NOUNS.len() as u64) as usize]; + format!("{a}-{n}") +} + +/// A tty7-managed worktree a closing tab sat in, resolved for the +/// close-time cleanup offer: where it is, its branch, the repository it +/// belongs to, and whether it holds uncommitted changes. +#[derive(Debug, Clone)] +pub struct ManagedWorktree { + pub path: PathBuf, + pub branch: String, + pub main_root: PathBuf, + pub dirty: bool, +} + +/// Resolve `cwd` to the tty7-managed worktree containing it, or `None` when it +/// sits anywhere else. Only checkouts under the main repository's +/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered +/// for removal. Blocking (spawns `git`). +pub fn managed(cwd: &Path) -> Option { + // Canonicalize before the component test: git reports resolved physical + // paths (`/private/var/…` on macOS), while `cwd` may arrive through + // symlinks — a textual comparison would then never match. The `.tty7/ + // worktrees` ancestor check is a cheap pure-filesystem pre-filter, so the + // common case (every ordinary tab close) never spawns git. + let cwd = std::fs::canonicalize(cwd).ok()?; + if !cwd + .ancestors() + .any(|a| a.ends_with(Path::new(".tty7").join("worktrees"))) + { + return None; + } + let path = PathBuf::from(git(&cwd, &["rev-parse", "--show-toplevel"]).ok()?); + let main_root = git( + &path, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(PathBuf::from)? + .parent()? + .to_path_buf(); + // The checkout must really sit in *this* repository's managed directory — + // both paths come from git, so they compare on equal (physical) footing. + if !path.starts_with(main_root.join(".tty7").join("worktrees")) { + return None; + } + let branch = git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?; + let dirty = !git(&path, &["status", "--porcelain"]).ok()?.is_empty(); + Some(ManagedWorktree { + path, + branch, + main_root, + dirty, + }) +} + +/// 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. +pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> { + let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; + let mut args = vec!["worktree", "remove"]; + if force { + args.push("--force"); + } + args.push(path); + git(&wt.main_root, &args)?; + let _ = git(&wt.main_root, &["branch", "-d", &wt.branch]); + Ok(()) +} + +/// Locate the repository containing `cwd` and the directory its managed +/// worktrees live in: `(repo_root, /.tty7/worktrees)`. Anchored on +/// the *main* repository even when `cwd` is itself inside a linked worktree +/// (a worktree tab spawning another worktree), so checkouts never nest. The +/// common git-dir is `
/.git`, whose parent is the main root. +fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> { + let repo_root = git(cwd, &["rev-parse", "--show-toplevel"]) + .map_err(|_| "not inside a git repository".to_string())?; + let repo_root = PathBuf::from(repo_root); + let main_root = git( + cwd, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .map(PathBuf::from) + .and_then(|d| d.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| repo_root.clone()); + let dir = main_root.join(".tty7").join("worktrees"); + Ok((repo_root, dir)) +} + +/// Compute the sheet's pre-filled values: a generated `adjective-noun` name +/// (retried until both the branch and the directory are unused, with a +/// numeric-suffix fallback so a saturated pool still terminates) and the +/// currently checked-out branch as the start point. +pub fn defaults(cwd: &Path) -> Result { + let (repo_root, dir) = repo_dir(cwd)?; + + let mut state = seed(); + let mut name = candidate(&mut state); + for attempt in 0..64 { + // Both the ref and the directory must be free — a stale directory from a + // hand-removed worktree would make `git worktree add` fail either way. + if !branch_exists(&repo_root, &name) && !dir.join(&name).exists() { + break; + } + name = if attempt < 32 { + candidate(&mut state) + } else { + format!("{}-{}", candidate(&mut state), next(&mut state) % 1000) + }; + } + + // Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD. + let base = git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) + .unwrap_or_else(|_| "HEAD".to_string()); + Ok(WorktreeDefaults { name, base, dir }) +} + +/// Create the requested worktree for the repository containing `cwd`, at +/// `/.tty7/worktrees/`, on new branch `branch` starting from +/// `base`. Branch and base validity is git's to judge; the directory name only +/// has to stay a single path component so it can't escape the managed root. +pub fn create(cwd: &Path, req: &WorktreeRequest) -> Result { + if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) + { + return Err(format!("invalid worktree name \"{}\"", req.name)); + } + let (repo_root, dir) = repo_dir(cwd)?; + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + // A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included, + // the ignore file itself too) out of the repository's `git status`, without + // ever editing the repo's own .gitignore. Best-effort: a failed write only + // costs status noise, never the worktree. + let ignore = dir + .parent() + .expect(".tty7/worktrees has a parent") + .join(".gitignore"); + if !ignore.exists() { + let _ = std::fs::write(&ignore, "*\n"); + } + + let path = dir.join(&req.name); + if path.exists() { + return Err(format!("{} already exists", path.display())); + } + git( + &repo_root, + &[ + "worktree", + "add", + "-b", + &req.branch, + path.to_str().ok_or("worktree path is not valid UTF-8")?, + &req.base, + ], + )?; + Ok(NewWorktree { + path, + branch: req.branch.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh scratch dir under the system temp location, unique per test — + /// the same std-only pattern the config tests use (no tempfile dep). + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn sh(dir: &Path, args: &[&str]) { + assert!( + std::process::Command::new(args[0]) + .args(&args[1..]) + .current_dir(dir) + .output() + .unwrap() + .status + .success(), + "command failed: {args:?}" + ); + } + + /// A throwaway repo with one commit, so `worktree add` has a HEAD to branch + /// from. + fn temp_repo(name: &str) -> PathBuf { + let dir = scratch(name); + sh(&dir, &["git", "init", "-q"]); + sh(&dir, &["git", "config", "user.email", "t@t"]); + sh(&dir, &["git", "config", "user.name", "t"]); + std::fs::write(dir.join("a.txt"), "a").unwrap(); + sh(&dir, &["git", "add", "."]); + sh(&dir, &["git", "commit", "-q", "-m", "init"]); + dir + } + + /// The simplest sensible request: directory and branch share `name`, + /// starting from HEAD — what the sheet submits when nothing is edited. + fn req(name: &str) -> WorktreeRequest { + WorktreeRequest { + name: name.into(), + branch: name.into(), + base: "HEAD".into(), + } + } + + #[test] + fn candidate_is_two_pool_words() { + let mut state = seed(); + let name = candidate(&mut state); + let (a, n) = name.split_once('-').unwrap(); + assert!(ADJECTIVES.contains(&a)); + assert!(NOUNS.contains(&n)); + } + + #[test] + fn is_inside_repo_scans_upward_for_dot_git() { + let repo = temp_repo("probe"); + let sub = repo.join("deep/nested"); + std::fs::create_dir_all(&sub).unwrap(); + assert!(is_inside_repo(&sub)); + let plain = scratch("probe-plain"); + assert!(!is_inside_repo(&plain)); + let _ = std::fs::remove_dir_all(&repo); + let _ = std::fs::remove_dir_all(&plain); + } + + #[test] + fn defaults_proposes_fresh_name_current_branch_and_target_dir() { + let repo = temp_repo("dflt"); + let d = defaults(&repo).unwrap(); + assert!(!branch_exists(&repo, &d.name)); + let head = git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(d.base, head); + // The target dir is the repo's own `.tty7/worktrees` (git reports the + // canonical root: /var → /private/var on macOS). + let canon = std::fs::canonicalize(&repo).unwrap(); + assert_eq!(d.dir, canon.join(".tty7").join("worktrees")); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_makes_worktree_on_new_branch_inside_the_repo() { + let repo = temp_repo("repo"); + let wt = create(&repo, &req("quiet-otter")).unwrap(); + assert!(wt.path.join("a.txt").exists()); + assert!(branch_exists(&repo, &wt.branch)); + // The worktree lands under `/.tty7/worktrees/`… + let canon = std::fs::canonicalize(&repo).unwrap(); + assert_eq!(wt.path, canon.join(".tty7/worktrees/quiet-otter")); + // …on the new branch… + let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, wt.branch); + // …and the auto-written `.tty7/.gitignore` keeps the main repo's + // status clean despite the checkout living inside it. + assert_eq!( + std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(), + "*\n" + ); + assert_eq!(git(&repo, &["status", "--porcelain"]).unwrap(), ""); + // A second request colliding on the directory is refused up front. + assert!( + create(&repo, &req("quiet-otter")) + .unwrap_err() + .contains("already exists") + ); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_honors_custom_branch_and_base() { + let repo = temp_repo("base"); + // A `stable` branch one commit behind the default branch's HEAD. + sh(&repo, &["git", "branch", "stable"]); + std::fs::write(repo.join("b.txt"), "b").unwrap(); + sh(&repo, &["git", "add", "."]); + sh(&repo, &["git", "commit", "-q", "-m", "second"]); + let wt = create( + &repo, + &WorktreeRequest { + name: "my-dir".into(), + branch: "feat/my-branch".into(), + base: "stable".into(), + }, + ) + .unwrap(); + // Directory and branch names diverge as requested… + assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); + let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); + assert_eq!(head, "feat/my-branch"); + // …and the checkout starts from `stable` (no b.txt yet). + assert!(wt.path.join("a.txt").exists()); + assert!(!wt.path.join("b.txt").exists()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_rejects_escaping_names() { + let repo = temp_repo("names"); + for bad in ["", ".", "..", "a/b", "a\\b"] { + let mut r = req("x"); + r.name = bad.into(); + assert!( + create(&repo, &r) + .unwrap_err() + .contains("invalid worktree name"), + "{bad:?} should be rejected" + ); + } + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn create_from_a_linked_worktree_lands_in_the_main_repo() { + let repo = temp_repo("nest"); + let first = create(&repo, &req("first-wt")).unwrap(); + // Spawn the second worktree from *inside* the first: it must land in + // the main repo's `.tty7/worktrees`, not nest inside the first checkout. + let second = create(&first.path, &req("second-wt")).unwrap(); + assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); + let _ = std::fs::remove_dir_all(&repo); + } + + #[test] + fn managed_resolves_managed_checkouts_and_remove_cleans_up() { + let repo = temp_repo("mg"); + let wt = create(&repo, &req("mg-wt")).unwrap(); + // The repo root itself is never "managed"… + assert!(managed(&repo).is_none()); + // …nor is a linked worktree the user made outside `.tty7/worktrees`. + let own = scratch("mg-own"); + let _ = std::fs::remove_dir_all(&own); + sh( + &repo, + &[ + "git", + "worktree", + "add", + "-b", + "own-branch", + own.to_str().unwrap(), + ], + ); + assert!(managed(&own).is_none()); + // Any path inside the managed checkout resolves to it, initially clean. + let sub = wt.path.join("sub"); + std::fs::create_dir_all(&sub).unwrap(); + let m = managed(&sub).unwrap(); + assert_eq!(m.branch, wt.branch); + assert_eq!(m.path, wt.path); + assert!(!m.dirty); + // Uncommitted changes flip `dirty` and block a plain remove; --force + // discards them. The branch (no unique commits) is deleted with it. + std::fs::write(wt.path.join("b.txt"), "b").unwrap(); + let m = managed(&wt.path).unwrap(); + assert!(m.dirty); + assert!(remove(&m, false).is_err()); + remove(&m, true).unwrap(); + assert!(!wt.path.exists()); + assert!(!branch_exists(&repo, &wt.branch)); + let _ = std::fs::remove_dir_all(&repo); + let _ = std::fs::remove_dir_all(&own); + } + + #[test] + fn create_outside_a_repo_errors() { + let plain = scratch("plain"); + let err = create(&plain, &req("x")).unwrap_err(); + assert_eq!(err, "not inside a git repository"); + assert_eq!(defaults(&plain).unwrap_err(), "not inside a git repository"); + let _ = std::fs::remove_dir_all(&plain); + } +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 9e686d93..9213da30 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -282,6 +282,9 @@ pub struct Tty7App { pub(crate) closed: Vec, /// `Some` while a tab label is being renamed inline; `None` otherwise. pub(crate) renaming: Option, + /// `Some` while the "New Worktree Tab" sheet is open (see + /// `ui::worktree_prompt`); `None` otherwise. + pub(crate) worktree_prompt: Option, /// When `Some`, the active tab renders only this one leaf full-window /// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change. maximized: Option>, @@ -529,6 +532,7 @@ impl Tty7App { palette_sub: None, closed: Vec::new(), renaming: None, + worktree_prompt: None, maximized: None, mod_hint_badges: false, mod_hint_gen: 0, @@ -1922,6 +1926,10 @@ impl Tty7App { // A rename in progress stores a fixed tab index; removing a tab shifts // indices and would let the pending edit commit onto the wrong tab. Drop it. self.renaming = None; + // Capture the tab's cwd *before* its panes are killed (the daemon can't + // report it afterwards): if it sat in a tty7-managed worktree, the + // cleanup offer below needs it. + let worktree_cwd = self.tab_cwd(index, window, cx); // Snapshot the tab (layout + each pane's current cwd + name) onto the // recently-closed stack so Cmd+Shift+T can bring it back. let snapshot = tab_to_session(&self.tabs[index], cx); @@ -1950,6 +1958,173 @@ impl Tty7App { self.focus_active(window, cx); self.save_session(cx); cx.notify(); + // The tab is gone; if it lived in a tty7-managed worktree, offer to + // clean the checkout up rather than letting them pile up silently. + self.offer_worktree_cleanup(worktree_cwd, cx); + } + + /// After closing a tab that sat in a tty7-managed worktree (see + /// [`crate::core::worktree::managed`]), offer to remove the checkout: a + /// 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`). + /// 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 }; + cx.spawn(async move |this, cx| { + let Some(wt) = cx + .background_spawn(async move { crate::core::worktree::managed(&cwd) }) + .await + else { + return; + }; + let detail = if wt.dirty { + format!( + "The closed tab's worktree at {} has uncommitted changes.", + wt.path.display() + ) + } else { + format!( + "The closed tab's worktree at {} is clean.", + wt.path.display() + ) + }; + let title = format!("Remove worktree \"{}\"?", wt.branch); + let level = if wt.dirty { + PromptLevel::Warning + } else { + PromptLevel::Info + }; + let remove_label = if wt.dirty { + "Discard Changes & Remove" + } else { + "Remove Worktree" + }; + let Ok(answer) = this.update_in(cx, |_, window, cx| { + window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx) + }) else { + return; + }; + if !matches!(answer.await, Ok(1)) { + return; + } + let force = wt.dirty; + let branch = wt.branch.clone(); + let result = cx + .background_spawn(async move { crate::core::worktree::remove(&wt, force) }) + .await; + let _ = this.update_in(cx, |_, window, cx| match result { + Ok(()) => window.push_notification(format!("Removed worktree \"{branch}\""), cx), + Err(e) => window.push_notification(format!("Worktree removal failed: {e}"), cx), + }); + }) + .detach(); + } + + /// Close every tab except `index` ("Close Other Tabs"). Iterates from the + /// end so removals never shift an index still to visit. Tabs holding a live + /// warn-on-close SSH session are skipped outright — the per-tab confirm + /// sheet is keyed by index, which a bulk close would immediately + /// invalidate — so they simply survive the sweep. + pub(crate) fn close_other_tabs( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + if index >= self.tabs.len() { + return; + } + for i in (0..self.tabs.len()).rev() { + if i == index || self.tab_has_warn_ssh(i, cx) { + continue; + } + self.close_tab(i, window, cx); + } + } + + /// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs + /// Below" in the sidebar). Same end-first iteration and warn-SSH skip as + /// [`close_other_tabs`](Self::close_other_tabs). + pub(crate) fn close_tabs_right_of( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + for i in ((index + 1)..self.tabs.len()).rev() { + if self.tab_has_warn_ssh(i, cx) { + continue; + } + self.close_tab(i, window, cx); + } + } + + /// The cwd of the tab's label-driving terminal (focused leaf, else first) — + /// what the tab context menu's "Copy Working Directory" copies and "New + /// Worktree Tab" derives the repo from. + pub(crate) fn tab_cwd( + &self, + index: usize, + window: &Window, + cx: &App, + ) -> Option { + self.tabs + .get(index)? + .pane + .focused_or_first(window, cx) + .and_then(|leaf| leaf.read(cx).cwd()) + } + + /// "New Worktree Tab": probe the repository containing the tab's cwd for + /// defaults (a fresh generated name, the current branch as start point) on + /// the background executor, then open the confirmation sheet + /// (`ui::worktree_prompt`) where name/branch/base can be edited before + /// anything is created. Failure to probe lands as a notification. + pub(crate) fn new_worktree_tab( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + let Some(cwd) = self.tab_cwd(index, window, cx) else { + window.push_notification("This tab has no working directory yet", cx); + return; + }; + cx.spawn(async move |this, cx| { + let probe_cwd = cwd.clone(); + let result = cx + .background_spawn(async move { crate::core::worktree::defaults(&probe_cwd) }) + .await; + let _ = this.update_in(cx, |this, window, cx| match result { + Ok(defaults) => this.open_worktree_prompt(cwd, defaults, window, cx), + Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx), + }); + }) + .detach(); + } + + /// Open the tab for a just-created worktree: a default-shell terminal in + /// the worktree directory, with the tab pre-named after its branch so a + /// strip of parallel worktrees stays tellable-apart. Mirrors + /// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point). + pub(crate) fn open_worktree_tab( + &mut self, + wt: crate::core::worktree::NewWorktree, + window: &mut Window, + cx: &mut Context, + ) { + let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx); + self.remember_active_pane(window, cx); + self.maximized = None; + let insert_at = self.new_tab_insert_at(cx); + let mut tab = Tab::new(Pane::leaf(view)); + tab.name = Some(wt.branch); + self.tabs.insert(insert_at, tab); + self.active = insert_at; + self.focus_active(window, cx); + self.save_session(cx); + cx.notify(); } /// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop). @@ -3449,6 +3624,10 @@ impl Render for Tty7App { .when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| { this.child(el) }) + // "New Worktree Tab" confirmation sheet (from the tab context menu). + .when_some(self.render_worktree_prompt_overlay(cx), |this, el| { + this.child(el) + }) // Working-tree diff overlay (clicked from a sidebar git line) — // last child, so it paints over every pane-contextual element // above. It covers only the body: the sidebar stays interactive. @@ -3642,6 +3821,10 @@ impl Render for Tty7App { .when_some(settings_overlay, |this, overlay| this.child(overlay)) // Command palette overlay, layered above everything when open. .when_some(self.palette.clone(), |this, palette| this.child(palette)) + // Toast layer for `window.push_notification` (worktree/SSH errors). + // gpui-component's Root only *stores* the list; the root view must + // render the layer — without this child every toast was invisible. + .children(gpui_component::Root::render_notification_layer(window, cx)) } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 72856df4..ec8f20b0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,3 +23,4 @@ pub mod ssh_prompt; pub mod tab_sidebar; pub mod tab_strip; pub mod theme; +pub mod worktree_prompt; diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index a55567e1..19488087 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -16,6 +16,7 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; +use gpui_component::menu::ContextMenuExt as _; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use std::cell::Cell; use std::rc::Rc; @@ -318,7 +319,13 @@ impl Tty7App { .into_any_element() }); - list = list.child(row); + // Per-tab right-click menu, shared with the strip's chips; + // `below_wording` flips the trailing close to "Close Tabs Below" + // to match the vertical layout. + let menu_app = cx.entity().downgrade(); + list = list.child(row.context_menu(move |menu, window, cx| { + Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx) + })); } // Top control bar: a right-aligned "+" new-tab button (the same shell diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index c8ff4a35..7f29370b 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -5,12 +5,12 @@ //! orchestration rather than chrome rendering. use gpui::{ - App, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, prelude::*, - px, + App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, + prelude::*, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; -use gpui_component::menu::{DropdownMenu as _, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex}; use crate::core::actions::{OpenSettings, TogglePalette}; @@ -377,6 +377,128 @@ impl Tty7App { }) } + /// Build the per-tab right-click menu, shared by the strip's chips and the + /// sidebar's rows (which passes `below_wording` so the trailing close reads + /// "Close Tabs Below" in the vertical list). Live state — tab count, the + /// tab's cwd — is read at open time through the weak `app` handle, so the + /// render loop never pays a per-frame cwd syscall and the enablement can't + /// go stale between render and click. + pub(crate) fn tab_context_menu( + menu: PopupMenu, + index: usize, + below_wording: bool, + app: &gpui::WeakEntity, + window: &Window, + cx: &App, + ) -> PopupMenu { + let Some(entity) = app.upgrade() else { + return menu; + }; + let this = entity.read(cx); + let tab_count = this.tabs.len(); + let cwd = this.tab_cwd(index, window, cx); + let has_cwd = cwd.is_some(); + let mut menu = menu.min_w(px(200.)); + + // Rename — the same inline edit a label double-click starts, given a + // discoverable entry point. + menu = menu.item(PopupMenuItem::new("Rename Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.start_rename(index, window, cx)); + } + })); + + // Worktree: an isolated checkout of this tab's repo on a fresh branch, + // opened as a new tab — parallel-agent fuel. Only offered when the + // tab's cwd actually sits in a git repository (a filesystem-only + // probe, cheap enough at open time); outside one the entry would be + // pure noise. + let in_repo = cwd + .as_deref() + .is_some_and(crate::core::worktree::is_inside_repo); + if in_repo { + menu = menu + .separator() + .item(PopupMenuItem::new("New Worktree Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.new_worktree_tab(index, window, cx)); + } + })); + } + + // Splits act on the right-clicked tab: activate it first (a no-op when + // it already is), then split its focused pane — one code path with the + // keyboard actions. + menu = menu + .separator() + .item(PopupMenuItem::new("Split Right").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.activate(index, window, cx); + this.split(Axis::Horizontal, window, cx); + }); + } + })) + .item(PopupMenuItem::new("Split Down").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.activate(index, window, cx); + this.split(Axis::Vertical, window, cx); + }); + } + })); + + menu = menu.separator().item( + PopupMenuItem::new("Copy Working Directory") + .disabled(!has_cwd) + .on_click(move |_, _window, cx| { + if let Some(cwd) = cwd.as_ref() { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + cwd.display().to_string(), + )); + } + }), + ); + + menu.separator() + .item(PopupMenuItem::new("Close Tab").on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| this.close_tab(index, window, cx)); + } + })) + .item( + PopupMenuItem::new("Close Other Tabs") + .disabled(tab_count <= 1) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.close_other_tabs(index, window, cx)); + } + }), + ) + .item( + PopupMenuItem::new(if below_wording { + "Close Tabs Below" + } else { + "Close Tabs to the Right" + }) + .disabled(index + 1 >= tab_count) + .on_click({ + let app = app.clone(); + move |_, window, cx| { + let _ = + app.update(cx, |this, cx| this.close_tabs_right_of(index, window, cx)); + } + }), + ) + } + /// The horizontal tab strip rendered into the title bar. `show_chips` draws /// the per-tab chip row; passing `false` (the vertical-sidebar mode, where /// the sidebar owns the tab list) keeps only the "+" and "⋯" chrome so the @@ -639,7 +761,12 @@ impl Tty7App { .into_any_element() }); - chips = chips.child(chip); + // Per-tab right-click menu (rename / worktree / split / copy cwd / + // close group) — the same builder the sidebar rows use. + let menu_app = cx.entity().downgrade(); + chips = chips.child(chip.context_menu(move |menu, window, cx| { + Self::tab_context_menu(menu, i, false, &menu_app, window, cx) + })); } // "+" new-tab button — click opens the shell picker. The default shell diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs new file mode 100644 index 00000000..f9f3f5b0 --- /dev/null +++ b/src/ui/worktree_prompt.rs @@ -0,0 +1,233 @@ +//! The "New Worktree Tab" sheet: confirms (or edits) the generated worktree +//! name, the new branch, and the branch it starts from before anything touches +//! git. Opened from the tab context menu (`tab_strip::tab_context_menu`); the +//! defaults are probed off the UI thread in `Tty7App::new_worktree_tab`. + +use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px}; +use gpui_component::button::{Button, ButtonVariants as _}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _, h_flex, v_flex, +}; + +use crate::core::worktree::{WorktreeDefaults, WorktreeRequest}; +use crate::ui::app::Tty7App; + +/// State for the open sheet. Held on [`Tty7App`] so it survives re-renders and +/// tab switches; there is at most one, app-wide. +pub(crate) struct WorktreePrompt { + /// The directory the repo was derived from (the right-clicked tab's cwd) — + /// what the eventual `git worktree add` resolves the repository through. + cwd: std::path::PathBuf, + /// Where the checkout will land (`/`), for the live path + /// preview under the name field. + dir: std::path::PathBuf, + name: Entity, + branch: Entity, + base: Entity, + /// True while `git worktree add` runs, so a second Enter can't double-create. + busy: bool, + _subs: Vec, +} + +impl Tty7App { + /// Open the sheet with probed defaults: the generated candidate fills both + /// the name and the branch (edit either independently), the current branch + /// fills the start point. Focus lands on the name field; Enter anywhere + /// submits, Esc cancels. + pub(crate) fn open_worktree_prompt( + &mut self, + cwd: std::path::PathBuf, + defaults: WorktreeDefaults, + window: &mut Window, + cx: &mut Context, + ) { + let name = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name.clone())); + let branch = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name)); + let base = cx.new(|cx| InputState::new(window, cx).default_value(defaults.base)); + name.update(cx, |state, cx| state.focus(window, cx)); + let subs = [&name, &branch, &base] + .into_iter() + .map(|input| { + cx.subscribe_in(input, window, |this, _, ev: &InputEvent, window, cx| { + match ev { + InputEvent::PressEnter { .. } => this.submit_worktree_prompt(window, cx), + // Keep the path preview tracking the name field. + InputEvent::Change => cx.notify(), + _ => {} + } + }) + }) + .collect(); + self.worktree_prompt = Some(WorktreePrompt { + cwd, + dir: defaults.dir, + name, + branch, + base, + busy: false, + _subs: subs, + }); + cx.notify(); + } + + fn cancel_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) { + if self.worktree_prompt.take().is_some() { + self.focus_active(window, cx); + cx.notify(); + } + } + + /// Validate the fields and run the creation off the UI thread. Blanking + /// one of name/branch falls back to the other (one name is enough); a + /// blank start point means the repo's HEAD. On failure the sheet stays up + /// with the values intact, so a typo'd branch is a fix away. + fn submit_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) { + let Some(p) = self.worktree_prompt.as_ref() else { + return; + }; + if p.busy { + return; + } + let name = p.name.read(cx).value().trim().to_string(); + let branch = p.branch.read(cx).value().trim().to_string(); + let base = p.base.read(cx).value().trim().to_string(); + let (name, branch) = match (name.is_empty(), branch.is_empty()) { + (true, true) => { + window.push_notification("The worktree needs a name", cx); + return; + } + (true, false) => (branch.clone(), branch), + (false, true) => (name.clone(), name), + (false, false) => (name, branch), + }; + let req = WorktreeRequest { + name, + branch, + base: if base.is_empty() { + "HEAD".to_string() + } else { + base + }, + }; + let p = self.worktree_prompt.as_mut().expect("checked above"); + p.busy = true; + let cwd = p.cwd.clone(); + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { crate::core::worktree::create(&cwd, &req) }) + .await; + let _ = this.update_in(cx, |this, window, cx| match result { + Ok(wt) => { + this.worktree_prompt = None; + this.open_worktree_tab(wt, window, cx); + } + Err(e) => { + if let Some(p) = this.worktree_prompt.as_mut() { + p.busy = false; + } + window.push_notification(format!("New worktree failed: {e}"), cx); + cx.notify(); + } + }); + }) + .detach(); + } + + /// The sheet itself, floated near the top of the terminal area like the + /// SSH auth sheet. `None` while no prompt is open. + pub(crate) fn render_worktree_prompt_overlay( + &self, + cx: &mut Context, + ) -> Option { + let p = self.worktree_prompt.as_ref()?; + let muted = cx.theme().muted_foreground; + let field = |label: &'static str, input: &Entity| { + v_flex() + .gap_1() + .child(div().text_xs().text_color(muted).child(label)) + .child(Input::new(input).small()) + }; + // Live preview of where the checkout will land, following the name field. + let name_now = p.name.read(cx).value().trim().to_string(); + let preview = p + .dir + .join(if name_now.is_empty() { + "…" + } else { + &name_now + }) + .display() + .to_string(); + + let card = v_flex() + .occlude() + .w(px(420.)) + .gap_3() + .p_4() + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .shadow_lg() + // Esc cancels from anywhere in the sheet (Enter submits via the + // inputs' PressEnter events). + .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { + if ev.keystroke.key == "escape" { + this.cancel_worktree_prompt(window, cx); + } + })) + .child( + div() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child("New Worktree Tab"), + ) + .child(field("Worktree Name", &p.name)) + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(muted) + .child(preview), + ) + .child(field("New Branch", &p.branch)) + .child(field("Start From", &p.base)) + .child( + h_flex() + .gap_2() + .child( + Button::new("worktree-create") + .label(if p.busy { "Creating…" } else { "Create" }) + .small() + .primary() + .disabled(p.busy) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_worktree_prompt(window, cx) + })), + ) + .child( + Button::new("worktree-cancel") + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.cancel_worktree_prompt(window, cx) + })), + ), + ); + + Some( + div() + .absolute() + .inset_0() + .flex() + .flex_col() + .items_center() + .justify_start() + .pt(px(48.)) + .child(card) + .into_any_element(), + ) + } +} From 27669acf4208bc0cb2e988e0206500aad11b687d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:24:04 +0800 Subject: [PATCH 2/3] fix(worktree): skip the cleanup offer while another pane still lives in the checkout New tabs inherit the current cwd, so two tabs sharing one managed worktree is common; removing the checkout on the first close would yank the directory out from under the surviving shell. Before offering, scan every leaf of every surviving tab (splits included) and stay quiet if any cwd still sits inside the worktree. --- src/core/worktree.rs | 24 ++++++++++++++++++++++++ src/ui/app.rs | 16 +++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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; From 0033e957b56cd2feeef0e49ebbc50f687bf7548c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:32:09 +0800 Subject: [PATCH 3/3] fix(worktree): compare canonicalized paths so Windows CI passes Windows' std::fs::canonicalize returns \\?\-prefixed verbatim paths while git reports plain absolute paths, so occupied()'s starts_with never matched (silently disabling the shared-checkout guard) and two path-equality test asserts failed. Canonicalize both sides in occupied(); strip the verbatim prefix in the tests. --- src/core/worktree.rs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/core/worktree.rs b/src/core/worktree.rs index f58131fe..d88796d7 100644 --- a/src/core/worktree.rs +++ b/src/core/worktree.rs @@ -169,11 +169,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. +/// Both sides are canonicalized before the ancestor test — cwds may arrive +/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix +/// that git-reported paths lack, so comparing raw would never match. A +/// vanished path never counts as occupying. pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool { + let Ok(path) = std::fs::canonicalize(path) else { + return false; + }; cwds.iter() - .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(path))) + .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(&path))) } /// Remove a managed worktree (`git worktree remove`, `--force` to discard @@ -297,6 +302,13 @@ mod tests { dir } + /// Strip Windows' `\\?\` verbatim prefix so `std::fs::canonicalize` output + /// compares equal to the plain absolute paths git reports; a no-op on Unix. + fn plain(p: &Path) -> PathBuf { + let s = p.to_string_lossy(); + PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()) + } + fn sh(dir: &Path, args: &[&str]) { assert!( std::process::Command::new(args[0]) @@ -363,8 +375,8 @@ mod tests { assert_eq!(d.base, head); // The target dir is the repo's own `.tty7/worktrees` (git reports the // canonical root: /var → /private/var on macOS). - let canon = std::fs::canonicalize(&repo).unwrap(); - assert_eq!(d.dir, canon.join(".tty7").join("worktrees")); + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees")); let _ = std::fs::remove_dir_all(&repo); } @@ -375,8 +387,8 @@ mod tests { assert!(wt.path.join("a.txt").exists()); assert!(branch_exists(&repo, &wt.branch)); // The worktree lands under `/.tty7/worktrees/`… - let canon = std::fs::canonicalize(&repo).unwrap(); - assert_eq!(wt.path, canon.join(".tty7/worktrees/quiet-otter")); + let canon = plain(&std::fs::canonicalize(&repo).unwrap()); + assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); // …on the new branch… let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(head, wt.branch);