diff --git a/src/core/config.rs b/src/core/config.rs index a0eb6746..7acd1d40 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -352,7 +352,8 @@ pub enum TabBarPosition { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SidebarGrouping { - /// Group tabs under a header per git work-tree root, with non-repo tabs + /// Group tabs under a header per git repository (linked worktrees fold + /// into their main checkout's group), with non-repo tabs /// collected in a trailing "Scratch" group. Branch changes and cds inside /// a repo never move a tab; only changing repos does. #[default] diff --git a/src/core/session.rs b/src/core/session.rs index d6b4ca8e..ce6fcaa3 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -77,7 +77,8 @@ pub struct SessionTab { #[serde(default)] pub name: Option, pub pane: SessionPane, - /// The tab's last-known sidebar repo group (its work-tree root), so a + /// The tab's last-known sidebar repo group (its repository home — the + /// main checkout's root, shared by all its linked worktrees), so a /// restored session renders grouped immediately instead of starting flat /// and reshuffling as git probes land. `None` = Scratch / never resolved. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index da654b5d..098165d6 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -44,8 +44,14 @@ pub struct GitStatus { #[derive(Clone, PartialEq, Eq, Debug)] pub struct RepoSnapshot { /// The work tree root (`git rev-parse --show-toplevel`) — the cache key - /// every pane inside this repo shares. + /// every pane inside this work tree shares. For a linked worktree this is + /// the worktree's own directory, not the main checkout's. pub root: PathBuf, + /// The *repository* the work tree belongs to: the main checkout's root + /// when `root` is a linked worktree, otherwise `root` itself. The + /// sidebar's grouping key — every worktree of one repo shares it, while + /// branch/diff state stays per work tree under `root`. + pub home: PathBuf, pub branch: String, pub counts: Option<(u32, u32)>, } @@ -61,12 +67,46 @@ pub fn probe(cwd: &Path) -> Option { let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); let branch = branch_name(cwd)?; Some(RepoSnapshot { + home: repo_home(cwd, &root), root, branch, counts: diff_numstat(cwd), }) } +/// The repository "home" every checkout of one repo shares: for a linked +/// worktree (its git dir differs from the common git dir) the main work +/// tree's root — the parent of `
/.git`; for the main checkout itself, +/// a submodule, or any failure to tell, the work-tree root unchanged. A bare +/// common dir (no trailing `.git` component, the bare-repo-plus-worktrees +/// layout) anchors on the bare directory itself — still one shared key. +fn repo_home(cwd: &Path, root: &Path) -> PathBuf { + let both = git( + cwd, + &[ + "rev-parse", + "--path-format=absolute", + "--git-dir", + "--git-common-dir", + ], + ); + let Some(both) = both else { + return root.to_path_buf(); + }; + let mut lines = both.lines(); + let (Some(git_dir), Some(common)) = (lines.next(), lines.next()) else { + return root.to_path_buf(); + }; + if git_dir == common { + return root.to_path_buf(); + } + let common = Path::new(common); + match (common.file_name(), common.parent()) { + (Some(name), Some(parent)) if name == ".git" => parent.to_path_buf(), + _ => common.to_path_buf(), + } +} + /// The process-wide snapshot store (a gpui [`Global`](gpui::Global)): pane /// cwds grouped by work-tree root, one [`GitStatus`] per root. Views read /// through [`status_for`](Self::status_for); the probe loop in @@ -81,6 +121,10 @@ pub fn probe(cwd: &Path) -> Option { pub struct GitStatusCache { /// cwd → its work-tree root; `None` = probed and found not to be a repo. roots: HashMap>, + /// work-tree root → the repository home it belongs to (see + /// [`RepoSnapshot::home`]). Identity for a plain checkout; the main + /// root for a linked worktree, so the sidebar groups them together. + homes: HashMap, /// root → the snapshot every pane in that tree shares. status: HashMap, /// cwds with a probe currently in flight, so concurrent triggers fold @@ -102,13 +146,22 @@ impl GitStatusCache { self.status.get(root).cloned() } - /// What the cache *knows* about `cwd`'s work-tree root, three-valued for - /// the sidebar's repo grouping: `None` = no probe has answered yet (the - /// caller should keep whatever grouping it had, not reshuffle on a guess); - /// `Some(None)` = probed and confirmed outside any work tree; - /// `Some(Some(root))` = probed and inside the repo at `root`. - pub fn known_root_for(&self, cwd: &Path) -> Option> { - self.roots.get(cwd).cloned() + /// What the cache *knows* about the repository `cwd` belongs to, + /// three-valued for the sidebar's repo grouping: `None` = no probe has + /// answered yet (the caller should keep whatever grouping it had, not + /// reshuffle on a guess); `Some(None)` = probed and confirmed outside any + /// work tree; `Some(Some(home))` = probed and inside the repo at `home`. + /// `home` is the repository home, not the work-tree root — a linked + /// worktree answers with the main checkout's root, so every worktree of + /// one repo lands in one sidebar group. + pub fn known_repo_for(&self, cwd: &Path) -> Option> { + let root = self.roots.get(cwd)?; + Some(root.as_ref().map(|root| { + self.homes + .get(root) + .cloned() + .unwrap_or_else(|| root.clone()) + })) } /// Claim a probe for `cwd`. `false` means one is already in flight — the @@ -146,6 +199,7 @@ impl GitStatusCache { removed, }, ); + self.homes.insert(snap.root.clone(), snap.home); self.roots.insert(cwd.to_path_buf(), Some(snap.root)); } // Not a repo (or the dir vanished). The root's entry stays for @@ -249,11 +303,22 @@ mod tests { fn snap(root: &str, branch: &str, counts: Option<(u32, u32)>) -> RepoSnapshot { RepoSnapshot { root: PathBuf::from(root), + home: PathBuf::from(root), branch: branch.into(), counts, } } + /// A snapshot for a linked worktree: its own root, a shared repo home. + fn wt_snap(root: &str, home: &str, branch: &str) -> RepoSnapshot { + RepoSnapshot { + root: PathBuf::from(root), + home: PathBuf::from(home), + branch: branch.into(), + counts: Some((0, 0)), + } + } + /// Two cwds landing in the same work tree share one entry: a probe from /// either updates what both read (the group-by-root contract). #[test] @@ -310,12 +375,12 @@ mod tests { assert!(cache.status_for(b).is_some()); } - /// The three-valued `known_root_for` the sidebar's repo grouping reads: - /// unprobed → `None`, probed-and-in-a-repo → `Some(Some(root))`, + /// The three-valued `known_repo_for` the sidebar's repo grouping reads: + /// unprobed → `None`, probed-and-in-a-repo → `Some(Some(home))`, /// probed-and-not-a-repo → `Some(None)`. The three cases are what let a /// sticky group key hold across an in-flight cd instead of flickering. #[test] - fn known_root_for_is_three_valued() { + fn known_repo_for_is_three_valued() { let mut cache = GitStatusCache::default(); let (repo, plain, unseen) = ( Path::new("/repo/a"), @@ -325,14 +390,35 @@ mod tests { cache.finish_probe(repo, Some(snap("/repo", "main", Some((1, 0))))); cache.finish_probe(plain, None); - // Inside a work tree: the resolved root, wrapped twice. + // Inside a work tree: the resolved repo home, wrapped twice. assert_eq!( - cache.known_root_for(repo), + cache.known_repo_for(repo), Some(Some(PathBuf::from("/repo"))) ); // Probed and confirmed outside any repo: a definite "not a repo". - assert_eq!(cache.known_root_for(plain), Some(None)); + assert_eq!(cache.known_repo_for(plain), Some(None)); // Never probed: no answer yet — the caller keeps its sticky key. - assert_eq!(cache.known_root_for(unseen), None); + assert_eq!(cache.known_repo_for(unseen), None); + } + + /// Linked worktrees of one repository share a *group* (`known_repo_for` + /// answers the main root for both) while their *status* stays per work + /// tree — different branches never clobber each other. + #[test] + fn worktrees_share_a_repo_but_not_a_status() { + let mut cache = GitStatusCache::default(); + let (main, wt) = (Path::new("/repo"), Path::new("/repo/.wt/feat")); + cache.finish_probe(main, Some(wt_snap("/repo", "/repo", "main"))); + cache.finish_probe(wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x"))); + + // One sidebar group… + assert_eq!( + cache.known_repo_for(main), + Some(Some(PathBuf::from("/repo"))) + ); + assert_eq!(cache.known_repo_for(wt), Some(Some(PathBuf::from("/repo")))); + // …two independent branch lines. + assert_eq!(cache.status_for(main).unwrap().branch, "main"); + assert_eq!(cache.status_for(wt).unwrap().branch, "feat/x"); } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 448d89b0..744bcf7d 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -101,13 +101,14 @@ pub struct Tab { /// switching back restores it; closing the tab drops it. Only the active /// tab's overlay is rendered. See [`crate::ui::diff_overlay`]. pub(crate) diff_overlay: Option, - /// The sidebar group this tab last *definitively* belonged to: the git - /// work-tree root of its first pane's cwd (deliberately not the focused - /// pane's — switching focus between splits must not relocate the row), or - /// `None` for outside any repo (the "Scratch" group). Sticky on purpose: - /// it only moves when + /// The sidebar group this tab last *definitively* belonged to: the + /// repository home of its first pane's cwd — the main checkout's root, so + /// linked worktrees of one repo share a group (deliberately not the + /// focused pane's cwd — switching focus between splits must not relocate + /// the row), or `None` for outside any repo (the "Scratch" group). Sticky + /// on purpose: it only moves when /// the git cache has a landed answer for the current cwd - /// ([`GitStatusCache::known_root_for`](crate::terminal::git_status::GitStatusCache::known_root_for) + /// ([`GitStatusCache::known_repo_for`](crate::terminal::git_status::GitStatusCache::known_repo_for) /// returns `Some`), so a cd whose probe is still in flight — or a pane /// with no cwd reported yet — keeps the row where it was instead of /// flickering through the Scratch group and back. A `RefCell` because the diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index ede7b262..304f7dae 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -566,8 +566,9 @@ impl Tty7App { .child(handle) } - /// Each tab's sidebar group key, in tab order: the git work-tree root of - /// its *first* pane's cwd, resolved through the tab's sticky + /// Each tab's sidebar group key, in tab order: the *repository home* of + /// its *first* pane's cwd (the main checkout's root — linked worktrees of + /// one repo share a group), resolved through the tab's sticky /// `sidebar_group` cell — only a landed probe answer moves a tab (see the /// field's doc), so an in-flight cd never reshuffles the list. The first /// pane rather than the focused one (which the branch line follows), so @@ -589,7 +590,7 @@ impl Tty7App { .first_leaf() .and_then(|leaf| leaf.read(cx).git_status_cwd().map(|p| p.to_path_buf())); if let Some(known) = - cwd.and_then(|cwd| cx.global::().known_root_for(&cwd)) + cwd.and_then(|cwd| cx.global::().known_repo_for(&cwd)) { *tab.sidebar_group.borrow_mut() = known; }