fix(watcher): align ignore scope with git --exclude-standard (#1728) (#1754)

buildDefaultIgnore now reads .git/info/exclude and core.excludesFile; buildScopeIgnore also seeds directories git ls-files reports as ignored-untracked so nested .gitignore effects prune the live watcher. Defect B (full-project sync per event) was already fixed via scoped pendingFiles sync.

Co-authored-by: Colby McHenry <colbymchenry@users.noreply.github.com>
This commit is contained in:
Colby Mchenry
2026-09-08 01:19:05 -05:00
committed by GitHub
co-authored by Colby McHenry
parent 9b8bb4aba0
commit 9a32487491
5 changed files with 272 additions and 24 deletions
+76
View File
@@ -7793,6 +7793,82 @@ describe('Nested non-submodule git repos', () => {
expect(ig.ignores('dist/')).toBe(true); // valid rule survives
expect(ig.ignores('src/app.ts')).toBe(false);
});
it('buildDefaultIgnore honors .git/info/exclude (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'exclude-root');
fs.mkdirSync(root, { recursive: true });
git(root, 'init', '-q');
fs.writeFileSync(path.join(root, 'src.ts'), 'export const x = 1;\n');
fs.mkdirSync(path.join(root, '.claude', 'worktrees', 'agent-1'), { recursive: true });
fs.writeFileSync(
path.join(root, '.claude', 'worktrees', 'agent-1', 'src.ts'),
'export const w = 1;\n',
);
// Not in .gitignore — only in info/exclude (the reporter's exact shape).
fs.writeFileSync(
path.join(root, '.git', 'info', 'exclude'),
'**/.claude/worktrees/\n',
);
const ig = buildDefaultIgnore(root);
expect(ig.ignores('src.ts')).toBe(false);
expect(ig.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
expect(ig.ignores('.claude/worktrees/')).toBe(true);
// ScopeIgnore (watcher path) agrees, including via git ignored-dir seeding.
const scope = buildScopeIgnore(root);
expect(scope.ignores('src.ts')).toBe(false);
expect(scope.ignores('.claude/worktrees/agent-1/')).toBe(true);
expect(scope.ignores('.claude/worktrees/agent-1/src.ts')).toBe(true);
});
it('buildDefaultIgnore honors core.excludesFile (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'excludesfile-root');
fs.mkdirSync(root, { recursive: true });
git(root, 'init', '-q');
const globalExcludes = path.join(tempDir, 'global-excludes');
fs.writeFileSync(globalExcludes, 'scratch/\n');
git(root, 'config', 'core.excludesFile', globalExcludes);
fs.mkdirSync(path.join(root, 'scratch'), { recursive: true });
fs.writeFileSync(path.join(root, 'scratch', 'tmp.ts'), 'export const t = 1;\n');
fs.writeFileSync(path.join(root, 'app.ts'), 'export const a = 1;\n');
const ig = buildDefaultIgnore(root);
expect(ig.ignores('app.ts')).toBe(false);
expect(ig.ignores('scratch/')).toBe(true);
expect(ig.ignores('scratch/tmp.ts')).toBe(true);
});
it('buildScopeIgnore prunes dirs ignored only by a nested .gitignore (#1728)', async () => {
const { execFileSync } = await import('child_process');
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, stdio: 'pipe' });
const root = path.join(tempDir, 'nested-gi-root');
fs.mkdirSync(path.join(root, 'pkg', 'build'), { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.email', 'test@test.com');
git(root, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(root, 'pkg', 'app.ts'), 'export const a = 1;\n');
fs.writeFileSync(path.join(root, 'pkg', 'build', 'out.ts'), 'export const o = 1;\n');
fs.writeFileSync(path.join(root, 'pkg', '.gitignore'), 'build/\n');
// Commit only the non-ignored file so git still reports build/ as ignored-other.
git(root, 'add', 'pkg/app.ts', 'pkg/.gitignore');
git(root, 'commit', '-q', '-m', 'init');
const scope = buildScopeIgnore(root);
expect(scope.ignores('pkg/app.ts')).toBe(false);
expect(scope.ignores('pkg/build/')).toBe(true);
expect(scope.ignores('pkg/build/out.ts')).toBe(true);
});
});
// =============================================================================
+36
View File
@@ -608,6 +608,42 @@ describe('FileWatcher', () => {
watcher.stop();
});
it('info/exclude patterns drop worktree paths from pending (#1728)', async () => {
const { execFileSync } = await import('child_process');
// Re-init the testDir as a real git repo so buildScopeIgnore can read
// .git/info/exclude (createTempDir fixtures are usually plain dirs).
execFileSync('git', ['init', '-q'], { cwd: testDir, stdio: 'pipe' });
fs.mkdirSync(path.join(testDir, '.claude', 'worktrees', 'w1', 'src'), { recursive: true });
fs.writeFileSync(
path.join(testDir, '.claude', 'worktrees', 'w1', 'src', 'x.ts'),
'export const x = 1;\n',
);
fs.writeFileSync(
path.join(testDir, '.git', 'info', 'exclude'),
'**/.claude/worktrees/\n',
);
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });
watcher.start();
await watcher.waitUntilReady();
__emitWatchEventForTests(testDir, '.claude/worktrees/w1/src/x.ts');
expect(watcher.getPendingFiles().map((p) => p.path)).not.toContain(
'.claude/worktrees/w1/src/x.ts',
);
await new Promise((r) => setTimeout(r, 300));
expect(syncFn).not.toHaveBeenCalled();
// In-scope edits still schedule a scoped sync.
fs.writeFileSync(path.join(testDir, 'src', 'ok.ts'), 'export const ok = 1;\n');
__emitWatchEventForTests(testDir, 'src/ok.ts');
await waitFor(() => syncFn.mock.calls.length > 0);
expect(syncFn.mock.calls[0]![0]).toEqual(['src/ok.ts']);
watcher.stop();
});
it('a nested .gitignore inside the scope forces a full sync', async () => {
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = newWatcher(syncFn, { debounceMs: 100 });