Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/workspace-server/src/services/watcher/service.test.ts
Original file line number Diff line number Diff line change
@@ -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<Array<{ dir: string; ignore?: string[] }>> {
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<WatcherEvent[]> {
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/<name>`, 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/**"]);
});
});
19 changes: 18 additions & 1 deletion packages/workspace-server/src/services/watcher/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>`, 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;

Expand Down Expand Up @@ -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 }]);
}
Expand Down
Loading