fix(sidebar): stop ungrouping tabs when a git probe fails (#1011)

A tab's auto group comes from probing its cwd for a repository. Any
failure of that probe - git failing to spawn, the macOS /usr/bin/git shim
dying while Xcode is mid-switch, a remote link dropping - came back as
"not a repository", overwrote the tab's remembered group, and was never
re-checked while the pane sat idle. Tabs in perfectly good repositories
dropped into Ungrouped and stayed there.

- Read root, home and branch in-process on this machine with
  gix-discover instead of three git processes. Only the line counts
  still come from `git diff --numstat`, so they keep matching the diff
  views; a detached HEAD is still named by git for the same reason.
- Remote hosts keep asking their own git (a new control request would
  force a dialect bump), but the probe now tells "not a repository"
  (exit 128, or the directory is gone) apart from a failure.
- A failed probe leaves the cache as it was, so a tab keeps its
  remembered group, and an unanswered cwd is retried every 10s.
This commit is contained in:
l0ng-ai
2026-09-29 16:55:03 +08:00
committed by GitHub
parent 8cf31c718d
commit 12ec0c92a9
9 changed files with 797 additions and 18 deletions
+45 -1
View File
@@ -2,7 +2,7 @@ use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
pub use crate::core::git::{GitStatus, RepoSnapshot, probe};
pub use crate::core::git::{GitStatus, RepoProbe, RepoSnapshot, probe_repo};
use crate::ui::host_ops::{ByHost, HostId, InFlight};
/// The spelling a directory is keyed by in here.
@@ -213,6 +213,22 @@ impl GitStatusCache {
}
rerun
}
/// Land a probe that got no answer ([`RepoProbe::Failed`]).
///
/// Only the bookkeeping moves: the probe is no longer in flight, and the
/// throttle counts from now so a retry waits its turn. What the cache
/// knew about `cwd` stays exactly as it was — a repository it had found
/// is still that repository, and a cwd it had never answered stays
/// unanswered, which is what lets a tab keep the group it remembers
/// instead of dropping into Ungrouped on a `git` that did not run.
pub fn fail_probe(&mut self, host: HostId, cwd: &Path) -> bool {
let cwd = key(host, cwd);
let rerun = !self.probes.finish(&(host, cwd.to_path_buf()));
let throttle = self.throttle_key(host, &cwd).to_path_buf();
self.last_probe.insert(host, throttle, Instant::now());
rerun
}
}
#[cfg(test)]
@@ -403,6 +419,34 @@ mod tests {
assert!(cache.status_for(L, b).is_some());
}
#[test]
fn a_failed_probe_leaves_what_was_known() {
let mut cache = GitStatusCache::default();
let (repo, unseen) = (Path::new("/repo/a"), Path::new("/repo/b"));
cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0)))));
assert!(cache.begin_probe(L, repo));
assert!(!cache.fail_probe(L, repo));
assert_eq!(
cache.known_repo_for(L, repo),
Some(Some(PathBuf::from("/repo"))),
"a git that did not run says nothing about the repository"
);
assert_eq!(cache.status_for(L, repo).unwrap().branch, "main");
assert!(cache.begin_probe(L, unseen));
cache.fail_probe(L, unseen);
assert_eq!(
cache.known_repo_for(L, unseen),
None,
"still unknown, not \"not a repository\""
);
assert!(
!cache.begin_probe_throttled(L, unseen, Duration::from_secs(60)),
"the retry waits out the throttle"
);
}
#[test]
fn known_repo_for_is_three_valued() {
let mut cache = GitStatusCache::default();
+33 -3
View File
@@ -661,6 +661,13 @@ const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::fro
const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500);
/// How long a pane whose repository is still unanswered waits before asking
/// again. Only a probe that failed leaves it that way, and whatever broke it
/// (a remote link mid-reconnect, a `git` that would not start) is gone in
/// seconds, not milliseconds — nor worth a request every poll tick while it
/// lasts.
const GIT_RETRY_GAP: std::time::Duration = std::time::Duration::from_secs(10);
/// How long a title the program set has to stand before the tab adopts it.
///
/// Long enough that a command which is over almost as soon as it started never
@@ -705,6 +712,9 @@ const MAX_HISTORY_BYTES: u64 = 4 << 20;
enum GitRefresh {
Edge,
Opportunistic,
/// Nothing happened; the cache just has no answer for this pane's cwd
/// yet, because the last probe failed. Throttled by [`GIT_RETRY_GAP`].
Retry,
}
fn known_pty_shim(fg: &str) -> Option<&'static str> {
@@ -2157,6 +2167,20 @@ impl TerminalView {
self.git_status_cwd.as_deref()
}
/// This pane sits somewhere the repo cache cannot place yet — neither a
/// repository nor "not one". A probe in flight looks the same, and the
/// throttle turns the retry away; the case this exists for is a probe that
/// failed, which leaves the answer open instead of recording a wrong one,
/// and would otherwise wait for the cwd to change or a command to finish
/// before anyone asked again.
fn git_repo_unanswered(&self, cx: &App) -> bool {
let Some(cwd) = self.git_status_cwd.as_deref() else {
return false;
};
cx.try_global::<crate::terminal::git_status::GitStatusCache>()
.is_none_or(|cache| cache.known_repo_for(self.host_id, cwd).is_none())
}
/// Plant the cwd the git-status poll would have found. For tests that
/// need a pane to look like it is sitting somewhere known — a real poll
/// needs a live shell reporting a directory, which a quiet test pane has
@@ -4011,6 +4035,8 @@ impl TerminalView {
self.refresh_git_status(cwd_now, GitRefresh::Edge, cx);
} else if tool_activity {
self.refresh_git_status(cwd_now, GitRefresh::Opportunistic, cx);
} else if self.git_repo_unanswered(cx) {
self.refresh_git_status(cwd_now, GitRefresh::Retry, cx);
}
self.follow_history_scope(cx);
@@ -4219,6 +4245,7 @@ impl TerminalView {
GitRefresh::Opportunistic => {
cache.begin_probe_throttled(id, &cwd, OPPORTUNISTIC_GIT_GAP)
}
GitRefresh::Retry => cache.begin_probe_throttled(id, &cwd, GIT_RETRY_GAP),
});
if !claimed {
return;
@@ -4228,10 +4255,13 @@ impl TerminalView {
crate::ui::host_ops::HostOps::run_detached(
host,
cx,
move |h| crate::terminal::git_status::probe(h, &probe_cwd),
move |h| crate::terminal::git_status::probe_repo(h, &probe_cwd),
move |cx, result| {
let rerun = cx.update_global::<GitStatusCache, _>(|cache, _| {
cache.finish_probe(id, &cwd, result)
use crate::terminal::git_status::RepoProbe;
let rerun = cx.update_global::<GitStatusCache, _>(|cache, _| match result {
RepoProbe::Repo(snap) => cache.finish_probe(id, &cwd, Some(snap)),
RepoProbe::NotARepo => cache.finish_probe(id, &cwd, None),
RepoProbe::Failed => cache.fail_probe(id, &cwd),
});
if rerun {
let _ = pane.update(cx, |view, cx| {