Просмотр исходного кода

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>
Colby Mchenry 7 часов назад
Родитель
Сommit
9a32487491
5 измененных файлов с 272 добавлено и 24 удалено
  1. 2 0
      CHANGELOG.md
  2. 76 0
      __tests__/extraction.test.ts
  3. 36 0
      __tests__/watcher.test.ts
  4. 137 14
      src/extraction/index.ts
  5. 21 10
      src/sync/watcher.ts

+ 2 - 0
CHANGELOG.md

@@ -137,6 +137,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 #### MCP / indexing
 
+- **Watcher scope now matches `git ls-files --exclude-standard` (#1728).** `buildDefaultIgnore` / `buildScopeIgnore` read `.git/info/exclude` and `core.excludesFile` (not only the root `.gitignore`), and seed directories git reports as ignored-untracked so nested `.gitignore` effects prune the live watcher the same way the indexer skips them. Single-file auto-sync was already incremental (`pendingFiles` → scoped `sync({ paths })`); the remaining gap was watching trees git had excluded.
+
 - **Live sync no longer lets the write-ahead log grow without a bound when a reader is holding it open (#1539).** Incremental sync now uses the same writer pause that full indexing already used, and if checkpointing still cannot finish once the log is past its documented size limit — typically because the query pool is reading at the same time — sync stops with a clear error instead of keeping writing until the disk fills. The previous behaviour could leave a multi-tens-of-gigabyte log beside a few-gigabyte index on a large project. Close concurrent readers and retry, or raise `CODEGRAPH_WAL_VALVE_MB` if the limit is too tight for the project.
 
 - **A second `codegraph serve --mcp` on the same project no longer silently kills auto-sync (#1740).** Direct mode (`CODEGRAPH_NO_DAEMON=1` or proxy→in-process fallback) now takes an exclusive `.codegraph/writer.pid` lock; a second writer exits immediately with guidance to stop the other server or unset `CODEGRAPH_NO_DAEMON` so clients share the daemon. The shared daemon already multiplexes N clients onto one watcher — this closes the same-OS dual-direct gap the docs warned about for Windows/WSL but did not guard.

+ 76 - 0
__tests__/extraction.test.ts

@@ -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 - 0
__tests__/watcher.test.ts

@@ -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 });

+ 137 - 14
src/extraction/index.ts

@@ -301,17 +301,117 @@ function readGitignorePatterns(giPath: string): string {
   return kept.join('\n');
 }
 
+/**
+ * Resolve the repository GIT_DIR for `repoRoot` (a `.git` directory, or the
+ * target of a `.git` file pointer). Null when this isn't a git checkout.
+ */
+function resolveGitDir(repoRoot: string): string | null {
+  const gitPath = path.join(repoRoot, '.git');
+  let st: fs.Stats;
+  try {
+    st = fs.statSync(gitPath);
+  } catch {
+    return null;
+  }
+  if (st.isDirectory()) return gitPath;
+  if (!st.isFile()) return null;
+  try {
+    const raw = fs.readFileSync(gitPath, 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
+    if (!raw) return null;
+    return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(repoRoot, raw);
+  } catch {
+    return null;
+  }
+}
+
+/** Expand a leading `~/` the way git does for `core.excludesFile`. */
+function expandUserPath(p: string): string {
+  if (p === '~') return os.homedir();
+  if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
+  return p;
+}
+
+/**
+ * Root-relative exclude patterns from git sources that are NOT the root
+ * `.gitignore`: `.git/info/exclude` and `core.excludesFile`. Same semantics as
+ * the root `.gitignore`, so they merge into {@link buildDefaultIgnore}. Without
+ * these, the watcher / FS-walk scope silently diverged from
+ * `git ls-files --exclude-standard` (#1728).
+ */
+function readGitExcludeExtraPatterns(rootDir: string): string {
+  const chunks: string[] = [];
+  const gitDir = resolveGitDir(rootDir);
+  if (gitDir) {
+    const excludePath = path.join(gitDir, 'info', 'exclude');
+    if (fs.existsSync(excludePath)) {
+      const patterns = readGitignorePatterns(excludePath);
+      if (patterns) chunks.push(patterns);
+    }
+  }
+  try {
+    const configured = execFileSync(
+      'git',
+      ['-C', rootDir, 'config', '--get', 'core.excludesFile'],
+      { encoding: 'utf8', timeout: 5_000, stdio: ['ignore', 'pipe', 'ignore'] },
+    ).trim();
+    if (configured) {
+      const abs = expandUserPath(configured);
+      if (fs.existsSync(abs)) {
+        const patterns = readGitignorePatterns(abs);
+        if (patterns) chunks.push(patterns);
+      }
+    }
+  } catch {
+    // No git, unset, or timeout — leave extras empty.
+  }
+  return chunks.join('\n');
+}
+
+/**
+ * Directories `git ls-files -o -i --exclude-standard --directory` reports as
+ * ignored-untracked. Seeded into {@link ScopeIgnore} so nested `.gitignore`
+ * effects (and any exclude-standard rule the flat matcher might miss) prune the
+ * watcher the same way the indexer skips them (#1728).
+ */
+function listGitIgnoredDirectories(rootDir: string): string[] {
+  try {
+    const out = execFileSync(
+      'git',
+      ['-C', rootDir, 'ls-files', '-z', '-o', '-i', '--exclude-standard', '--directory'],
+      {
+        encoding: 'utf8',
+        timeout: 60_000,
+        maxBuffer: 50 * 1024 * 1024,
+        stdio: ['ignore', 'pipe', 'ignore'],
+      },
+    );
+    const dirs: string[] = [];
+    for (const entry of out.split('\0')) {
+      if (!entry) continue;
+      dirs.push(entry.endsWith('/') ? entry : `${entry}/`);
+    }
+    return dirs;
+  } catch {
+    return [];
+  }
+}
+
 /**
  * An `ignore` matcher seeded with the built-in defaults, merged with the project's
- * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default. Shared
- * by both enumeration paths so behavior is identical with or without git — and so
- * the defaults apply to tracked files too (committing a dependency dir doesn't make
- * it project code; the explicit `.gitignore` negation is the only opt-in).
+ * root .gitignore so a negation there (e.g. `!vendor/`) overrides a default, plus
+ * git's other root-relative exclude files (`.git/info/exclude`, `core.excludesFile`)
+ * so watcher / FS-walk scope matches `git ls-files --exclude-standard` (#1728).
+ * Shared by both enumeration paths so behavior is identical with or without git —
+ * and so the defaults apply to tracked files too (committing a dependency dir
+ * doesn't make it project code; the explicit `.gitignore` negation is the only
+ * opt-in).
  */
 export function buildDefaultIgnore(rootDir: string): Ignore {
   const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
   const rootGitignore = path.join(rootDir, '.gitignore');
   if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore));
+  const extra = readGitExcludeExtraPatterns(rootDir);
+  if (extra) ig.add(extra);
   return ig;
 }
 
@@ -630,14 +730,17 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
 
 /**
  * Workspace-scope ignore matcher. Ordinary paths get the root's matcher
- * (built-in defaults + root `.gitignore`); paths inside an EMBEDDED repo get
- * that repo's own matcher (defaults + its root `.gitignore`) — the parent's
- * `.gitignore` hides a child repo from git, not from the index (#514). A
- * directory path (trailing slash) that is an ANCESTOR of an embedded root is
- * never ignored, so directory-pruning callers (the Linux per-directory
- * watcher) still descend to reach the embedded repos.
+ * (built-in defaults + root `.gitignore` + `.git/info/exclude` +
+ * `core.excludesFile`, plus directories `git ls-files --exclude-standard`
+ * reports as ignored); paths inside an EMBEDDED repo get that repo's own
+ * matcher — the parent's `.gitignore` hides a child repo from git, not from
+ * the index (#514). A directory path (trailing slash) that is an ANCESTOR of
+ * an embedded root is never ignored, so directory-pruning callers (the Linux
+ * per-directory watcher) still descend to reach the embedded repos.
  *
- * Single source of truth for indexer and watcher scope — they must not diverge.
+ * Shared by the indexer (scoped sync / skip checks) and the watcher so their
+ * scope cannot diverge from each other or from `git ls-files --exclude-standard`
+ * (#1728).
  */
 export class ScopeIgnore {
   private embedded: Array<{ root: string; matcher: Ignore }>;
@@ -710,8 +813,15 @@ export class ScopeIgnore {
 export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable<string>): ScopeIgnore {
   const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir);
   const include = loadIncludeMatcher(rootDir);
+  // Root matcher already has defaults + root `.gitignore` + info/exclude +
+  // core.excludesFile. Seed ignored-untracked directories from git so nested
+  // `.gitignore` effects prune the watcher identically to the indexer (#1728).
+  const rootMatcher = buildDefaultIgnore(rootDir);
+  for (const dir of listGitIgnoredDirectories(rootDir)) {
+    rootMatcher.add(dir);
+  }
   return new ScopeIgnore(
-    buildDefaultIgnore(rootDir),
+    rootMatcher,
     roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
     loadExcludeMatcher(rootDir),
     include,
@@ -1492,10 +1602,19 @@ export class ExtractionOrchestrator {
    * same lifecycle the watcher's own matcher already has.
    */
   private scopedSyncMatcher(): ScopeIgnore {
-    const key = [PROJECT_CONFIG_FILENAME, '.gitignore']
+    // Bust when any root-level exclude source the matcher reads may have
+    // changed. Nested `.gitignore` edits force a full watcher sync, which
+    // clears this cache (see the full-reconcile branch in sync()).
+    const gitDir = resolveGitDir(this.rootDir);
+    const key = [
+      PROJECT_CONFIG_FILENAME,
+      '.gitignore',
+      gitDir ? path.join(gitDir, 'info', 'exclude') : '',
+    ]
       .map((name) => {
+        if (!name) return '-';
         try {
-          return String(fs.statSync(path.join(this.rootDir, name)).mtimeMs);
+          return String(fs.statSync(path.isAbsolute(name) ? name : path.join(this.rootDir, name)).mtimeMs);
         } catch {
           return '-';
         }
@@ -2784,6 +2903,10 @@ export class ExtractionOrchestrator {
       filesChecked = unique.length;
       if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scoped: ${Date.now() - tSyncScan}ms (${unique.length} paths, ${trackedFiles.length} tracked)`);
     } else {
+      // Full reconcile: drop the memoized scope matcher so a nested
+      // `.gitignore` / exclude-standard change that forced this full sync is
+      // visible to the next scoped sync (#1728).
+      this.scopedMatcher = null;
       currentFiles = await scanDirectoryAsync(this.rootDir);
       if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
       filesChecked = currentFiles.length;

+ 21 - 10
src/sync/watcher.ts

@@ -24,11 +24,12 @@
  *     per-file watches are never needed.
  *
  * Excluded trees (node_modules/, dist/, .git/, …) are filtered via the
- * indexer's `buildScopeIgnore` (built-in default-ignore dirs + the project's
- * .gitignore) — on Linux they're never descended into (so they cost no watch),
- * and on macOS/Windows the single recursive stream still covers them but their
+ * indexer's `buildScopeIgnore` (built-in defaults + root `.gitignore` +
+ * `.git/info/exclude` + `core.excludesFile` + git ignored-untracked dirs) —
+ * on Linux they're never descended into (so they cost no watch), and on
+ * macOS/Windows the single recursive stream still covers them but their
  * events are dropped before any sync is scheduled. Either way the watcher's
- * scope matches the indexer's (#276 / #407).
+ * scope matches `git ls-files --exclude-standard` (#276 / #407 / #1728).
  */
 
 import * as fs from 'fs';
@@ -328,12 +329,14 @@ export class FileWatcher {
    * deterministically gate on watcher readiness.
    */
   private readyWaiters: Array<() => void> = [];
-  // The shared scope matcher (built-in defaults + project .gitignore + the
-  // `codegraph.json` exclude/include rules, with embedded child repos matched
-  // by their OWN rules — #514), built at start() and REBUILT whenever one of
-  // the files it is derived from changes (see `refreshScope`, #1590). Same
-  // source of truth the indexer uses, so watcher scope can never diverge from
-  // index scope. An embedded repo created after start() joins the scope on
+  // The shared scope matcher from `buildScopeIgnore` (built-in defaults +
+  // root `.gitignore` + `.git/info/exclude` + `core.excludesFile` + dirs
+  // `git ls-files --exclude-standard` reports ignored + `codegraph.json`
+  // exclude/include, with embedded child repos matched by their OWN rules —
+  // #514), built at start() and REBUILT whenever one of the files it is
+  // derived from changes (see `refreshScope`, #1590). Same construction the
+  // indexer uses for scoped sync, so watcher scope cannot diverge from index
+  // scope (#1728). An embedded repo created after start() joins the scope on
   // the next scope refresh / watcher restart / re-index.
   private ignoreMatcher: ScopeIgnore | null = null;
 
@@ -574,6 +577,14 @@ export class FileWatcher {
    */
   private handleChange(rel: string): void {
     if (!rel || rel === '.' || rel.startsWith('..')) return;
+    // `.git/info/exclude` is otherwise always-ignored with the rest of `.git/`,
+    // but it feeds `buildScopeIgnore` — allow it through as a scope refresh
+    // when the platform delivers the event (recursive watchers may; Linux
+    // per-directory watching does not descend into `.git/`) (#1728).
+    if (rel === '.git/info/exclude') {
+      this.refreshScope(rel);
+      return;
+    }
     if (this.isAlwaysIgnored(rel)) return;
     // The two root files the scope matcher is derived from are handled BEFORE
     // the matcher is consulted: a user `exclude` pattern that happens to cover