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
+137 -14
View File
@@ -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
View File
@@ -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