fix(git-status): share one per-repo snapshot across panes

Each pane used to compute and hold its own git branch/diff snapshot,
refreshed only by its own events (cwd change, command end, agent turn
end). Tabs sitting idle in the same repo kept whatever they last saw, so
rows for one directory showed different +/− counts — or none at all when
a tab's last probe landed on a clean tree.

Snapshots now live in a process-wide GitStatusCache keyed by work-tree
root: every pane whose cwd resolves into the same repo reads the same
entry, refreshed by whichever pane probed last, and the sidebar observes
the cache so all rows repaint together. In-flight probes are deduped per
cwd (concurrent triggers fold into one git shell-out, with a rerun if
re-triggered mid-flight), and a failed git diff keeps the previous
counts instead of rendering the tree as suddenly clean.
This commit is contained in:
l0ng-ai
2026-07-15 18:45:25 +08:00
parent 35a16638f1
commit 0182a072cc
3 changed files with 257 additions and 63 deletions
+196 -26
View File
@@ -3,17 +3,25 @@
//! line (`⎇ feat/x +6 5`): each session fronted with its branch and change
//! count.
//!
//! Snapshots are shared through [`GitStatusCache`], a process-wide map keyed
//! by work-tree root: every pane whose cwd resolves into the same repo reads
//! the *same* entry, so ten tabs in one repo show one truth, refreshed by
//! whichever pane probed last — not ten drifting copies refreshed on ten
//! different schedules. Probes stay per-trigger (a pane's cwd change, command
//! end, or agent-turn end — see [`crate::terminal::view`]) but are deduped
//! in-flight, so simultaneous triggers from panes in the same directory cost
//! one `git` shell-out, not one per pane.
//!
//! Deliberately shell-out simple: one `git` invocation per field, run on a
//! background thread by the caller (see [`crate::terminal::view`]) so the UI
//! never blocks on a slow repo. Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status
//! polling from ever taking `index.lock` and fighting a real git command the
//! user is running. Returns `None` when the cwd isn't inside a git work tree,
//! so the sidebar simply omits the line.
//! background thread by the caller so the UI never blocks on a slow repo.
//! Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status polling from ever taking
//! `index.lock` and fighting a real git command the user is running.
use std::path::Path;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// A pane's git snapshot: the branch it's on and how much the working tree has
/// A repo's git snapshot: the branch it's on and how much the working tree has
/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
/// and untracked files don't contribute a line count.
@@ -28,24 +36,120 @@ pub struct GitStatus {
pub removed: u32,
}
/// Compute the git snapshot for `cwd`, or `None` when it isn't a git work tree
/// (or the path is gone). Blocking — call it on a background executor.
pub fn compute(cwd: &Path) -> Option<GitStatus> {
/// One raw probe result, before it's folded into the cache: which work tree
/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the
/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) —
/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the
/// previous numbers instead of pretending the tree went clean.
#[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.
pub root: PathBuf,
pub branch: String,
pub counts: Option<(u32, u32)>,
}
/// Probe the git snapshot for `cwd`, or `None` when it isn't inside a git work
/// tree (or the path is gone). Blocking — call it on a background executor.
pub fn probe(cwd: &Path) -> Option<RepoSnapshot> {
if !cwd.exists() {
return None;
}
// Doubles as the "is this a git repo" gate: fails outside a work tree.
let root = git(cwd, &["rev-parse", "--show-toplevel"])?;
let root = PathBuf::from(root.trim_end_matches(['\n', '\r']));
let branch = branch_name(cwd)?;
let (added, removed) = diff_numstat(cwd).unwrap_or((0, 0));
Some(GitStatus {
Some(RepoSnapshot {
root,
branch,
added,
removed,
counts: diff_numstat(cwd),
})
}
/// The current branch name, or a short sha for a detached HEAD. Doubles as the
/// "is this a git repo" gate: both probes failing (not a work tree) yields
/// `None`.
/// 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
/// [`crate::terminal::view`] brackets each background probe with
/// [`begin_probe`](Self::begin_probe) / [`finish_probe`](Self::finish_probe).
///
/// In-flight dedup is keyed by cwd (the root isn't known until a first probe
/// answers), so two panes at the same directory share one probe; panes in
/// *different* subdirectories of one repo can still race a redundant probe —
/// rare, and both land the same answer.
#[derive(Default)]
pub struct GitStatusCache {
/// cwd → its work-tree root; `None` = probed and found not to be a repo.
roots: HashMap<PathBuf, Option<PathBuf>>,
/// root → the snapshot every pane in that tree shares.
status: HashMap<PathBuf, GitStatus>,
/// cwds with a probe currently in flight, so concurrent triggers fold
/// into one shell-out.
in_flight: HashSet<PathBuf>,
/// In-flight cwds re-triggered meanwhile — reprobed once their flight
/// lands, so the newest trigger's state is never skipped.
dirty: HashSet<PathBuf>,
}
impl gpui::Global for GitStatusCache {}
impl GitStatusCache {
/// The snapshot for a pane at `cwd`: resolved through its work-tree root,
/// so every pane in the same repo answers identically. `None` before the
/// first probe lands or when `cwd` isn't in a repo.
pub fn status_for(&self, cwd: &Path) -> Option<GitStatus> {
let root = self.roots.get(cwd)?.as_ref()?;
self.status.get(root).cloned()
}
/// Claim a probe for `cwd`. `false` means one is already in flight — the
/// caller must *not* spawn another; the landed flight will reprobe once
/// (the cwd is marked dirty) so this trigger's state still gets observed.
pub fn begin_probe(&mut self, cwd: &Path) -> bool {
if self.in_flight.contains(cwd) {
self.dirty.insert(cwd.to_path_buf());
false
} else {
self.in_flight.insert(cwd.to_path_buf());
true
}
}
/// Fold a landed probe for `cwd` into the cache. A failed diff inside a
/// live repo keeps the root's previous counts (a transient `git` error is
/// not "the tree went clean"). Returns whether the cwd was re-triggered
/// while this probe flew — the caller should start one more probe.
pub fn finish_probe(&mut self, cwd: &Path, snapshot: Option<RepoSnapshot>) -> bool {
self.in_flight.remove(cwd);
match snapshot {
Some(snap) => {
let (added, removed) = snap.counts.unwrap_or_else(|| {
self.status
.get(&snap.root)
.map(|g| (g.added, g.removed))
.unwrap_or((0, 0))
});
self.status.insert(
snap.root.clone(),
GitStatus {
branch: snap.branch,
added,
removed,
},
);
self.roots.insert(cwd.to_path_buf(), Some(snap.root));
}
// Not a repo (or the dir vanished). The root's entry stays for
// other cwds that still live in it.
None => {
self.roots.insert(cwd.to_path_buf(), None);
}
}
self.dirty.remove(cwd)
}
}
/// The current branch name, or a short sha for a detached HEAD.
fn branch_name(cwd: &Path) -> Option<String> {
// On a branch — even before the first commit — `symbolic-ref` names it.
if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
@@ -62,6 +166,7 @@ fn branch_name(cwd: &Path) -> Option<String> {
/// Sum added/removed lines across the working tree vs `HEAD` from
/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
/// `None` when the invocation itself failed — the caller keeps old counts.
fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
let out = git(cwd, &["diff", "--numstat", "HEAD"])?;
let mut added = 0u32;
@@ -102,29 +207,94 @@ fn git(cwd: &Path, args: &[&str]) -> Option<String> {
mod tests {
use super::*;
/// A tmp path that is not a git repo yields no status (and never panics).
/// A tmp path that is not a git repo yields no snapshot (and never panics).
#[test]
fn non_repo_is_none() {
let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
let _ = std::fs::create_dir_all(&dir);
assert_eq!(compute(&dir), None);
assert_eq!(probe(&dir), None);
}
/// A path that doesn't exist is `None`, not a panic.
#[test]
fn missing_path_is_none() {
assert_eq!(compute(Path::new("/no/such/tty7/path/here")), None);
assert_eq!(probe(Path::new("/no/such/tty7/path/here")), None);
}
/// This repo (the crate root is inside the tty7 work tree) reports a branch,
/// exercising the real `git` probe end-to-end.
/// This repo (the crate root is inside the tty7 work tree) reports a branch
/// and a root, exercising the real `git` probe end-to-end.
#[test]
fn own_repo_has_a_branch() {
fn own_repo_has_a_branch_and_root() {
let here = env!("CARGO_MANIFEST_DIR");
if let Some(status) = compute(Path::new(here)) {
assert!(!status.branch.is_empty());
if let Some(snap) = probe(Path::new(here)) {
assert!(!snap.branch.is_empty());
assert!(Path::new(here).starts_with(&snap.root));
}
// If the crate is built outside a work tree (e.g. a vendored tarball),
// `None` is the correct answer and the assertion above is skipped.
// `None` is the correct answer and the assertions above are skipped.
}
fn snap(root: &str, branch: &str, counts: Option<(u32, u32)>) -> RepoSnapshot {
RepoSnapshot {
root: PathBuf::from(root),
branch: branch.into(),
counts,
}
}
/// 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]
fn cwds_in_one_repo_share_a_snapshot() {
let mut cache = GitStatusCache::default();
let (a, b) = (Path::new("/repo/sub/a"), Path::new("/repo"));
cache.finish_probe(a, Some(snap("/repo", "main", Some((5, 2)))));
cache.finish_probe(b, Some(snap("/repo", "main", Some((5, 2)))));
// A later probe from `a` refreshes the numbers `b` reads too.
cache.finish_probe(a, Some(snap("/repo", "main", Some((200, 42)))));
for cwd in [a, b] {
let got = cache.status_for(cwd).unwrap();
assert_eq!((got.added, got.removed), (200, 42), "cwd {cwd:?}");
}
}
/// A failed `git diff` (counts `None`) keeps the previous numbers rather
/// than rendering the tree as suddenly clean; the branch still updates.
#[test]
fn failed_diff_keeps_previous_counts() {
let mut cache = GitStatusCache::default();
let cwd = Path::new("/repo");
cache.finish_probe(cwd, Some(snap("/repo", "main", Some((200, 42)))));
cache.finish_probe(cwd, Some(snap("/repo", "feat/x", None)));
let got = cache.status_for(cwd).unwrap();
assert_eq!(got.branch, "feat/x");
assert_eq!((got.added, got.removed), (200, 42));
}
/// In-flight dedup: a second trigger while a probe flies doesn't claim a
/// new one, but marks the cwd dirty so the landing reports "go again".
#[test]
fn concurrent_triggers_fold_into_one_probe_then_rerun() {
let mut cache = GitStatusCache::default();
let cwd = Path::new("/repo");
assert!(cache.begin_probe(cwd));
assert!(!cache.begin_probe(cwd)); // deduped, marked dirty
assert!(cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
// The rerun claims cleanly and lands with nothing pending.
assert!(cache.begin_probe(cwd));
assert!(!cache.finish_probe(cwd, Some(snap("/repo", "main", Some((1, 0))))));
}
/// A cwd that leaves the repo (dir deleted / not a work tree) stops
/// answering, without disturbing the root entry other cwds still use.
#[test]
fn non_repo_cwd_clears_only_itself() {
let mut cache = GitStatusCache::default();
let (a, b) = (Path::new("/repo/a"), Path::new("/repo/b"));
cache.finish_probe(a, Some(snap("/repo", "main", Some((3, 1)))));
cache.finish_probe(b, Some(snap("/repo", "main", Some((3, 1)))));
cache.finish_probe(a, None);
assert_eq!(cache.status_for(a), None);
assert!(cache.status_for(b).is_some());
}
}
+45 -34
View File
@@ -247,19 +247,13 @@ pub struct TerminalView {
/// while this is true, so a result you've already seen stops nagging. Blue
/// (working) / amber (waiting) are unaffected — they track live state.
agent_result_unread: bool,
/// The pane's last-computed git snapshot (branch + working-tree diff size),
/// shown as the sidebar row's third line. Computed off-thread by
/// [`refresh_git_status`](Self::refresh_git_status) on a cwd change or a
/// command finishing; `None` outside a git work tree (or before the first
/// probe lands).
git_status: Option<crate::terminal::git_status::GitStatus>,
/// The cwd `git_status` was last computed (or scheduled) for, so the poll
/// loop only reprobes when the working directory actually changes.
/// The cwd this pane's git line reads from (and last scheduled a probe
/// for), so the poll loop only reprobes when the working directory
/// actually changes. The snapshot itself lives in the process-wide
/// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), keyed
/// by work-tree root — panes in one repo share one entry instead of each
/// computing (and staling) its own.
git_status_cwd: Option<std::path::PathBuf>,
/// Monotonic tag bumped on every git reprobe; a background result is dropped
/// unless it still matches, so a slow probe from a since-changed cwd can't
/// overwrite a fresher one (same guard as `completion_generation`).
git_status_gen: u64,
/// The inline command line editor. Live only while the shell sits idle
/// at its prompt (`input_active`): there the terminal keeps keyboard focus and
/// we run our own line editor (so we own Tab / ↑ / ↓ for completion and
@@ -950,9 +944,7 @@ impl TerminalView {
agent_turn_started: None,
agent_was_rich: false,
agent_result_unread: false,
git_status: None,
git_status_cwd: None,
git_status_gen: 0,
cmd: CmdEditor::new(),
typeahead: Typeahead::new(),
hold: GapHold::new(),
@@ -1035,11 +1027,15 @@ impl TerminalView {
self.agent_result_unread
}
/// The pane's last-computed git snapshot (branch + working-tree diff), for
/// the sidebar row's branch line. `None` outside a git work tree or before
/// the first background probe lands.
pub fn git_status(&self) -> Option<crate::terminal::git_status::GitStatus> {
self.git_status.clone()
/// The git snapshot for this pane's cwd (branch + working-tree diff), for
/// the sidebar row's branch line — read from the shared per-repo
/// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), so
/// every pane in one work tree reports the same numbers. `None` outside a
/// git work tree or before the repo's first background probe lands.
pub fn git_status(&self, cx: &App) -> Option<crate::terminal::git_status::GitStatus> {
let cwd = self.git_status_cwd.as_ref()?;
cx.try_global::<crate::terminal::git_status::GitStatusCache>()?
.status_for(cwd)
}
/// The current grid selection as text, if any non-blank one exists — the
@@ -2456,33 +2452,48 @@ impl TerminalView {
}
}
/// Kick off an off-thread git probe for `cwd` and fold the result back on
/// the main thread, tagged with a generation so a stale probe (cwd changed
/// meanwhile) is dropped. Clears the status when there's no cwd (e.g. a
/// native-SSH pane pre-OSC-7, where a local `git` would be meaningless).
/// Kick off an off-thread git probe for `cwd` and fold the result into the
/// shared per-repo [`GitStatusCache`] on the main thread. The cache
/// brackets the flight (`begin_probe`/`finish_probe`): a probe already in
/// flight for the same cwd absorbs this trigger instead of spawning a
/// duplicate `git` shell-out, and reruns once when it lands. With no cwd
/// (e.g. a native-SSH pane pre-OSC-7, where a local `git` would be
/// meaningless) the pane simply stops reading a status.
///
/// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache
fn refresh_git_status(&mut self, cwd: Option<std::path::PathBuf>, cx: &mut Context<Self>) {
use crate::terminal::git_status::GitStatusCache;
let changed = self.git_status_cwd != cwd;
self.git_status_cwd = cwd.clone();
let Some(cwd) = cwd else {
if self.git_status.take().is_some() {
if changed {
cx.notify();
}
return;
};
self.git_status_gen += 1;
let generation = self.git_status_gen;
cx.default_global::<GitStatusCache>(); // first probe of the process creates it
if !cx.update_global::<GitStatusCache, _>(|cache, _| cache.begin_probe(&cwd)) {
return;
}
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { crate::terminal::git_status::compute(&cwd) })
.spawn({
let cwd = cwd.clone();
async move { crate::terminal::git_status::probe(&cwd) }
})
.await;
let _ = this.update(cx, |view, cx| {
// Drop a probe whose cwd has since been superseded.
if view.git_status_gen != generation {
return;
}
if view.git_status != result {
view.git_status = result;
cx.notify();
// Landing through `update_global` wakes the sidebar's
// `observe_global`, so every pane in the repo repaints — not
// just this one.
let rerun = cx
.update_global::<GitStatusCache, _>(|cache, _| cache.finish_probe(&cwd, result));
// A trigger arrived while we flew; go once more so its state
// is observed — unless this pane has since left that cwd.
if rerun && view.git_status_cwd.as_deref() == Some(&cwd) {
view.refresh_git_status(Some(cwd), cx);
}
});
})
+16 -3
View File
@@ -134,8 +134,9 @@ impl Tab {
/// The git snapshot (branch + working-tree diff) of the tab's label-driving
/// terminal — the focused leaf with a `window`, else the first — for the
/// sidebar row's branch line (the branch and change count shown under the
/// title). `None` when that leaf isn't inside a git work tree, or before
/// its first probe lands.
/// title). Read through the shared per-repo cache, so tabs in one work
/// tree always agree. `None` when that leaf isn't inside a git work tree,
/// or before the repo's first probe lands.
pub(crate) fn git_status(
&self,
window: Option<&Window>,
@@ -145,7 +146,7 @@ impl Tab {
Some(window) => self.pane.focused_or_first(window, cx),
None => self.pane.first_leaf(),
}?;
leaf.read(cx).git_status()
leaf.read(cx).git_status(cx)
}
/// The coding agent running in this tab, or `None`. Any leaf counts (a
@@ -262,6 +263,11 @@ pub struct Tty7App {
/// by then — this window never gets that `ModifiersChanged`, so without
/// this the badges stuck on until some later keypress. Never read.
_activation_watch: Subscription,
/// Keeps the `observe_global::<GitStatusCache>` subscription alive: a git
/// probe landing (from *any* pane) repaints the sidebar, so every row in
/// the same repo shows the just-refreshed branch/diff line, not a stale
/// per-row copy. Never read.
_git_status_watch: Subscription,
/// `Some` while the command palette overlay is open; `None` when closed.
/// The view owns its search input, filtered list and keyboard handling and
/// emits a `PaletteEvent`; we build the catalog and run the chosen command.
@@ -436,6 +442,12 @@ impl Tty7App {
// and colors are handled separately by `apply_theme`; here we cover the
// font knobs that live on `Tty7App`/the panes.
let config_watch = cx.observe_global::<Config>(|this, cx| this.reload_from_config(cx));
// Repaint when any pane's git probe lands in the shared cache — the
// sidebar's branch/diff lines read from it, and the probing pane's own
// notify wouldn't re-render rows belonging to *other* panes.
cx.default_global::<crate::terminal::git_status::GitStatusCache>();
let git_status_watch = cx
.observe_global::<crate::terminal::git_status::GitStatusCache>(|_, cx| cx.notify());
// Any real keypress means "chord, not a bare hold": cancel the held-⌘
// tab badges and whatever reveal is pending (see `ui::hints`).
let this = cx.weak_entity();
@@ -497,6 +509,7 @@ impl Tty7App {
_config_watch: config_watch,
_keystroke_watch: keystroke_watch,
_activation_watch: activation_watch,
_git_status_watch: git_status_watch,
palette: None,
palette_sub: None,
closed: Vec::new(),