Skip to content

Commit 0a244cd

Browse files
liam-russellclaude
andcommitted
perf(git): compute worktree ahead/behind via git status instead of rev-list
getWorktreeHealth spawned up to 3 git subprocesses per worktree (rev-parse for the upstream name, log for last-commit date, and a conditional rev-list to count ahead/behind). `git status --porcelain=v2 --branch` reports the upstream name and its ahead/behind counts natively in one call, cutting the common case down to 2 subprocesses (status + log) — the rev-list fallback now only runs for the less common no-upstream+baseRef case, where git's own status can't compute ahead/behind for us. Motivated by a reproducible Windows-only E2E failure on this branch (daily-workflow.spec.ts, stash-cherry-pick-workflow.spec.ts) that didn't reproduce on main or other concurrent PRs at the same point in time: the extra concurrent git.exe spawns this feature added on every workspace/ worktree-list load were tipping the odds of an existing, separately tracked directory-deletion race (WORKSPACE_CLOSE waiting for in-flight git ops, #162) specifically on Windows, where spawning git is markedly slower than macOS/Linux. A prior attempt to fix this by slowing the poll interval did not help, since TanStack Query fires the initial fetch immediately on mount regardless of staleTime/refetchInterval — the actual burst this targets happens right when worktrees load, not on the steady-state poll. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent f06951f commit 0a244cd

1 file changed

Lines changed: 54 additions & 16 deletions

File tree

packages/git/src/health.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,35 +11,49 @@ const DEFAULT_CONCURRENCY = 4;
1111
* otherwise — matching how `git status` reports ahead/behind when tracking
1212
* is configured, while still giving branches with no upstream a meaningful
1313
* comparison.
14+
*
15+
* Only spawns two git subprocesses per worktree in the common case (one
16+
* `status --branch`, which reports the upstream name and its ahead/behind
17+
* counts natively, plus one `log` for the last-commit date) rather than
18+
* three (a separate `rev-parse` for the upstream name and a `rev-list` to
19+
* count ahead/behind) — this runs once per worktree per sidebar refresh, so
20+
* fewer subprocess spawns measurably reduces contention with other git
21+
* commands firing at the same time, particularly on Windows where spawning
22+
* `git` is markedly slower than on macOS/Linux (see remote.test.ts's timeout
23+
* comments for the same observation elsewhere in this codebase). The
24+
* `rev-list` fallback is only used for the less common no-upstream+baseRef
25+
* case, where git's own status can't compute ahead/behind for us.
1426
*/
1527
export async function getWorktreeHealth(
1628
worktreePath: string,
1729
baseRef?: string | null
1830
): Promise<WorktreeHealth> {
1931
const git = gitForPath(worktreePath);
2032

21-
const [lastCommitRaw, upstream] = await Promise.all([
33+
const [lastCommitRaw, branchStatusRaw] = await Promise.all([
2234
git.raw(['log', '-1', '--format=%aI']).catch(() => ''),
23-
git
24-
.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])
25-
.then(r => r.trim())
26-
.catch(() => null),
35+
git.raw(['status', '--porcelain=v2', '--branch', '--untracked-files=no']).catch(() => ''),
2736
]);
2837

29-
const compareRef = upstream ?? (baseRef || null);
38+
const { upstream, ahead: statusAhead, behind: statusBehind } = parseBranchStatus(branchStatusRaw);
3039

31-
let ahead = 0;
32-
let behind = 0;
33-
if (compareRef) {
40+
let compareRef = upstream;
41+
let ahead = statusAhead;
42+
let behind = statusBehind;
43+
44+
if (!upstream && baseRef) {
45+
compareRef = baseRef;
3446
try {
3547
// `--left-right --count A...B` prints "<left-only> <right-only>" —
36-
// left (compareRef) is what we're behind on, right (HEAD) is ahead.
37-
const raw = await git.raw(['rev-list', '--left-right', '--count', `${compareRef}...HEAD`]);
48+
// left (baseRef) is what we're behind on, right (HEAD) is ahead. Only
49+
// needed here because there's no upstream for `git status` to compare
50+
// HEAD against on its own.
51+
const raw = await git.raw(['rev-list', '--left-right', '--count', `${baseRef}...HEAD`]);
3852
const [behindStr, aheadStr] = raw.trim().split(/\s+/);
3953
behind = parseInt(behindStr ?? '0', 10) || 0;
4054
ahead = parseInt(aheadStr ?? '0', 10) || 0;
4155
} catch {
42-
// compareRef doesn't resolve (deleted branch, unrelated history, etc.)
56+
// baseRef doesn't resolve (deleted branch, unrelated history, etc.)
4357
// — leave ahead/behind at 0 rather than failing the whole snapshot.
4458
}
4559
}
@@ -54,12 +68,36 @@ export async function getWorktreeHealth(
5468
};
5569
}
5670

71+
/**
72+
* Parses the `# branch.*` header lines from `git status --porcelain=v2
73+
* --branch` — specifically `# branch.upstream <name>` and
74+
* `# branch.ab +<ahead> -<behind>`, both omitted entirely when the branch
75+
* has no upstream.
76+
*/
77+
function parseBranchStatus(raw: string): { upstream: string | null; ahead: number; behind: number } {
78+
let upstream: string | null = null;
79+
let ahead = 0;
80+
let behind = 0;
81+
for (const line of raw.split('\n')) {
82+
if (line.startsWith('# branch.upstream ')) {
83+
upstream = line.slice('# branch.upstream '.length).trim();
84+
} else if (line.startsWith('# branch.ab ')) {
85+
const match = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
86+
if (match) {
87+
ahead = parseInt(match[1]!, 10);
88+
behind = parseInt(match[2]!, 10);
89+
}
90+
}
91+
}
92+
return { upstream, ahead, behind };
93+
}
94+
5795
/**
5896
* Computes health for every worktree in `worktreePaths`, capping how many
59-
* run at once. Each worktree's health already fans out to a handful of git
60-
* subprocesses (log, rev-parse, rev-list) — running all worktrees fully in
61-
* parallel would spawn far more `git` processes at once than is useful,
62-
* especially in workspaces with a dozen+ worktrees.
97+
* run at once. Each worktree's health already fans out to a couple of git
98+
* subprocesses (status, log) — running all worktrees fully in parallel
99+
* would spawn far more `git` processes at once than is useful, especially
100+
* in workspaces with a dozen+ worktrees.
63101
*
64102
* Entries are only present for worktrees whose health was computed
65103
* successfully — a worktree removed mid-refresh is simply absent from the

0 commit comments

Comments
 (0)