Skip to content

Commit 67770e3

Browse files
authored
fix(watcher): stop cross-worktree fan-out via shared commondir (#2971)
## Problem Starting a new worktree task gets slower as the number of existing worktrees grows. Reported from a fresh machine (few worktrees) feeling dramatically faster than an older one with many worktrees. Root cause: linked worktrees share the main repo's `.git` as their `commondir`, and each worktree's `watchRepo` recursively watches that commondir. So mutating *any* worktree's admin dir under `.git/worktrees/` — notably **creating a new worktree** (which writes `.git/worktrees/<name>/HEAD`) — woke *every* other worktree's watcher. Each wake fired a server-side branch re-check (a `git rev-parse` subprocess) and a renderer branch-query invalidation. With N worktrees, one git operation triggered ~N subprocesses + N invalidations, so the per-event cost grew linearly with worktree count — and creating a task is exactly the moment that writes to the shared `.git`. ## Changes Exclude the `worktrees/` admin subtree (`**/worktrees/**`) from the git-dir watches in `WatcherService.watchRepo`. - A worktree's own admin dir is still watched directly as its `gitDir` (rooted inside `worktrees/<name>`, where the pattern matches nothing), so its own HEAD changes are still observed. - Shared refs (`refs/heads`, `packed-refs`) live outside `worktrees/`, so commits/branch changes still propagate to sibling worktrees as before. - Only cross-worktree admin noise is dropped. Note: the `ignore`-option mechanism is load-bearing here — it's relative to each watch root, so it suppresses the subtree only for the shared commondir watch and not for a worktree's own gitDir watch. Filtering by an absolute-path substring instead would wrongly drop a worktree's own HEAD events. ## How did you test this? - Added `service.test.ts` covering the ignore wiring for both a linked worktree (commondir + own gitDir get the worktrees ignore; working tree keeps node_modules/.git ignores) and a non-worktree repo. Both pass via `vitest run src/services/watcher/service.test.ts`. - `biome lint` clean on the watcher dir; `tsc` clean for the changed files (remaining repo typecheck errors are unbuilt sibling-package `dist/`, unrelated to this change). ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? --- *Created with [PostHog](https://posthog.com?ref=pr) from a [Slack thread](https://posthog.slack.com/archives/C09G8Q32R6F/p1782718496987319?thread_ts=1782718496.987319&cid=C09G8Q32R6F)*
1 parent a289096 commit 67770e3

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { WatcherEvent } from "./schemas";
3+
import { WatcherService } from "./service";
4+
5+
/**
6+
* Records every `watch()` call so we can assert what each watched directory is
7+
* told to ignore, then drains the generator so the loops shut down cleanly.
8+
*/
9+
async function collectWatchCalls(
10+
repoPath: string,
11+
gitDirs: {
12+
gitDir: string | null;
13+
commonDir: string | null;
14+
},
15+
): Promise<Array<{ dir: string; ignore?: string[] }>> {
16+
const service = new WatcherService();
17+
const calls: Array<{ dir: string; ignore?: string[] }> = [];
18+
19+
vi.spyOn(service, "resolveGitDirs").mockResolvedValue(gitDirs);
20+
// Empty generators end the file/git loops immediately, so watchRepo settles
21+
// after recording the subscribe targets.
22+
vi.spyOn(service, "watch").mockImplementation(
23+
// biome-ignore lint/correctness/useYield: intentionally empty generator
24+
async function* (
25+
dir: string,
26+
options: { ignore?: string[] },
27+
): AsyncGenerator<WatcherEvent[]> {
28+
calls.push({ dir, ignore: options.ignore });
29+
},
30+
);
31+
32+
const controller = new AbortController();
33+
const gen = service.watchRepo(repoPath, controller.signal);
34+
await gen.next();
35+
controller.abort();
36+
await gen.return?.(undefined);
37+
38+
return calls;
39+
}
40+
41+
describe("WatcherService.watchRepo ignore patterns", () => {
42+
it("excludes the cross-worktree admin subtree from the linked worktree's git watches", async () => {
43+
const repoPath = "/repo/.worktrees/feature/myrepo";
44+
const calls = await collectWatchCalls(repoPath, {
45+
gitDir: "/main/.git/worktrees/feature",
46+
commonDir: "/main/.git",
47+
});
48+
49+
// The shared commondir is watched but must skip `.git/worktrees/**`, so a
50+
// sibling worktree's HEAD/index churn (e.g. creating a new worktree) no
51+
// longer wakes this worktree's watcher.
52+
const commonDirCall = calls.find((c) => c.dir === "/main/.git");
53+
expect(commonDirCall?.ignore).toEqual(["**/worktrees/**"]);
54+
55+
// The worktree's own gitDir is rooted inside `worktrees/<name>`, where the
56+
// pattern matches nothing, so its own HEAD changes are still observed.
57+
const gitDirCall = calls.find(
58+
(c) => c.dir === "/main/.git/worktrees/feature",
59+
);
60+
expect(gitDirCall?.ignore).toEqual(["**/worktrees/**"]);
61+
62+
// The working tree keeps its own ignores (node_modules/.git/.jj).
63+
const workingTreeCall = calls.find((c) => c.dir === repoPath);
64+
expect(workingTreeCall?.ignore).toContain("**/node_modules/**");
65+
expect(workingTreeCall?.ignore).not.toContain("**/worktrees/**");
66+
});
67+
68+
it("watches a non-worktree repo's git dir once with the worktrees ignore", async () => {
69+
const repoPath = "/main";
70+
const calls = await collectWatchCalls(repoPath, {
71+
gitDir: "/main/.git",
72+
commonDir: null,
73+
});
74+
75+
const gitDirCalls = calls.filter((c) => c.dir === "/main/.git");
76+
expect(gitDirCalls).toHaveLength(1);
77+
expect(gitDirCalls[0]?.ignore).toEqual(["**/worktrees/**"]);
78+
});
79+
});

packages/workspace-server/src/services/watcher/service.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ export type WatchOptions = {
1010
};
1111

1212
const IGNORE_PATTERNS = ["**/node_modules/**", "**/.git/**", "**/.jj/**"];
13+
14+
// Ignore patterns for the git-dir watches. Linked worktrees share the main
15+
// repo's `.git` as their `commondir`, so every worktree's commondir watch sees
16+
// the whole `.git/worktrees/` admin subtree — including sibling worktrees'
17+
// HEAD/index files. Without this, creating or mutating one worktree wakes every
18+
// other worktree's watcher (each firing a branch re-check + renderer
19+
// invalidation), so the per-event cost grows linearly with the number of
20+
// worktrees. A worktree's own admin dir is watched directly as its `gitDir`
21+
// (rooted inside `worktrees/<name>`, where this pattern matches nothing), so
22+
// excluding the subtree from the commondir watch drops only cross-worktree
23+
// noise; shared refs (`refs/heads`, `packed-refs`) live outside `worktrees/`
24+
// and are still observed.
25+
const GIT_IGNORE_PATTERNS = ["**/worktrees/**"];
1326
const DEBOUNCE_MS = 500;
1427
const BULK_THRESHOLD = 100;
1528

@@ -202,7 +215,11 @@ export class WatcherService {
202215
for (const dir of gitDirs) {
203216
gitLoops.push(
204217
(async () => {
205-
for await (const batch of this.watch(dir, {}, signal)) {
218+
for await (const batch of this.watch(
219+
dir,
220+
{ ignore: GIT_IGNORE_PATTERNS },
221+
signal,
222+
)) {
206223
if (batch.some((e) => isRelevantGitEvent(e.path))) {
207224
pushOut([{ kind: "git-state-changed", repoPath }]);
208225
}

0 commit comments

Comments
 (0)