diff --git a/packages/workspace-server/src/services/watcher/service.test.ts b/packages/workspace-server/src/services/watcher/service.test.ts new file mode 100644 index 0000000000..05e189c2fc --- /dev/null +++ b/packages/workspace-server/src/services/watcher/service.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WatcherEvent } from "./schemas"; +import { WatcherService } from "./service"; + +/** + * Records every `watch()` call so we can assert what each watched directory is + * told to ignore, then drains the generator so the loops shut down cleanly. + */ +async function collectWatchCalls( + repoPath: string, + gitDirs: { + gitDir: string | null; + commonDir: string | null; + }, +): Promise> { + const service = new WatcherService(); + const calls: Array<{ dir: string; ignore?: string[] }> = []; + + vi.spyOn(service, "resolveGitDirs").mockResolvedValue(gitDirs); + // Empty generators end the file/git loops immediately, so watchRepo settles + // after recording the subscribe targets. + vi.spyOn(service, "watch").mockImplementation( + // biome-ignore lint/correctness/useYield: intentionally empty generator + async function* ( + dir: string, + options: { ignore?: string[] }, + ): AsyncGenerator { + calls.push({ dir, ignore: options.ignore }); + }, + ); + + const controller = new AbortController(); + const gen = service.watchRepo(repoPath, controller.signal); + await gen.next(); + controller.abort(); + await gen.return?.(undefined); + + return calls; +} + +describe("WatcherService.watchRepo ignore patterns", () => { + it("excludes the cross-worktree admin subtree from the linked worktree's git watches", async () => { + const repoPath = "/repo/.worktrees/feature/myrepo"; + const calls = await collectWatchCalls(repoPath, { + gitDir: "/main/.git/worktrees/feature", + commonDir: "/main/.git", + }); + + // The shared commondir is watched but must skip `.git/worktrees/**`, so a + // sibling worktree's HEAD/index churn (e.g. creating a new worktree) no + // longer wakes this worktree's watcher. + const commonDirCall = calls.find((c) => c.dir === "/main/.git"); + expect(commonDirCall?.ignore).toEqual(["**/worktrees/**"]); + + // The worktree's own gitDir is rooted inside `worktrees/`, where the + // pattern matches nothing, so its own HEAD changes are still observed. + const gitDirCall = calls.find( + (c) => c.dir === "/main/.git/worktrees/feature", + ); + expect(gitDirCall?.ignore).toEqual(["**/worktrees/**"]); + + // The working tree keeps its own ignores (node_modules/.git/.jj). + const workingTreeCall = calls.find((c) => c.dir === repoPath); + expect(workingTreeCall?.ignore).toContain("**/node_modules/**"); + expect(workingTreeCall?.ignore).not.toContain("**/worktrees/**"); + }); + + it("watches a non-worktree repo's git dir once with the worktrees ignore", async () => { + const repoPath = "/main"; + const calls = await collectWatchCalls(repoPath, { + gitDir: "/main/.git", + commonDir: null, + }); + + const gitDirCalls = calls.filter((c) => c.dir === "/main/.git"); + expect(gitDirCalls).toHaveLength(1); + expect(gitDirCalls[0]?.ignore).toEqual(["**/worktrees/**"]); + }); +}); diff --git a/packages/workspace-server/src/services/watcher/service.ts b/packages/workspace-server/src/services/watcher/service.ts index 427776f23e..1c27e17b6b 100644 --- a/packages/workspace-server/src/services/watcher/service.ts +++ b/packages/workspace-server/src/services/watcher/service.ts @@ -10,6 +10,19 @@ export type WatchOptions = { }; const IGNORE_PATTERNS = ["**/node_modules/**", "**/.git/**", "**/.jj/**"]; + +// Ignore patterns for the git-dir watches. Linked worktrees share the main +// repo's `.git` as their `commondir`, so every worktree's commondir watch sees +// the whole `.git/worktrees/` admin subtree — including sibling worktrees' +// HEAD/index files. Without this, creating or mutating one worktree wakes every +// other worktree's watcher (each firing a branch re-check + renderer +// invalidation), so the per-event cost grows linearly with the number of +// worktrees. A worktree's own admin dir is watched directly as its `gitDir` +// (rooted inside `worktrees/`, where this pattern matches nothing), so +// excluding the subtree from the commondir watch drops only cross-worktree +// noise; shared refs (`refs/heads`, `packed-refs`) live outside `worktrees/` +// and are still observed. +const GIT_IGNORE_PATTERNS = ["**/worktrees/**"]; const DEBOUNCE_MS = 500; const BULK_THRESHOLD = 100; @@ -202,7 +215,11 @@ export class WatcherService { for (const dir of gitDirs) { gitLoops.push( (async () => { - for await (const batch of this.watch(dir, {}, signal)) { + for await (const batch of this.watch( + dir, + { ignore: GIT_IGNORE_PATTERNS }, + signal, + )) { if (batch.some((e) => isRelevantGitEvent(e.path))) { pushOut([{ kind: "git-state-changed", repoPath }]); }