|
@@ -301,17 +301,117 @@ function readGitignorePatterns(giPath: string): string {
|
|
|
return kept.join('\n');
|
|
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
|
|
* 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 {
|
|
export function buildDefaultIgnore(rootDir: string): Ignore {
|
|
|
const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
|
|
const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
|
|
|
const rootGitignore = path.join(rootDir, '.gitignore');
|
|
const rootGitignore = path.join(rootDir, '.gitignore');
|
|
|
if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore));
|
|
if (fs.existsSync(rootGitignore)) ig.add(readGitignorePatterns(rootGitignore));
|
|
|
|
|
+ const extra = readGitExcludeExtraPatterns(rootDir);
|
|
|
|
|
+ if (extra) ig.add(extra);
|
|
|
return ig;
|
|
return ig;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -630,14 +730,17 @@ function findNestedGitRepos(absDir: string, relPrefix: string): string[] {
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Workspace-scope ignore matcher. Ordinary paths get the root's matcher
|
|
* 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 {
|
|
export class ScopeIgnore {
|
|
|
private embedded: Array<{ root: string; matcher: Ignore }>;
|
|
private embedded: Array<{ root: string; matcher: Ignore }>;
|
|
@@ -710,8 +813,15 @@ export class ScopeIgnore {
|
|
|
export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable<string>): ScopeIgnore {
|
|
export function buildScopeIgnore(rootDir: string, embeddedRoots?: Iterable<string>): ScopeIgnore {
|
|
|
const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir);
|
|
const roots = embeddedRoots ? [...embeddedRoots] : discoverEmbeddedRepoRoots(rootDir);
|
|
|
const include = loadIncludeMatcher(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(
|
|
return new ScopeIgnore(
|
|
|
- buildDefaultIgnore(rootDir),
|
|
|
|
|
|
|
+ rootMatcher,
|
|
|
roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
|
|
roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
|
|
|
loadExcludeMatcher(rootDir),
|
|
loadExcludeMatcher(rootDir),
|
|
|
include,
|
|
include,
|
|
@@ -1492,10 +1602,19 @@ export class ExtractionOrchestrator {
|
|
|
* same lifecycle the watcher's own matcher already has.
|
|
* same lifecycle the watcher's own matcher already has.
|
|
|
*/
|
|
*/
|
|
|
private scopedSyncMatcher(): ScopeIgnore {
|
|
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) => {
|
|
.map((name) => {
|
|
|
|
|
+ if (!name) return '-';
|
|
|
try {
|
|
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 {
|
|
} catch {
|
|
|
return '-';
|
|
return '-';
|
|
|
}
|
|
}
|
|
@@ -2784,6 +2903,10 @@ export class ExtractionOrchestrator {
|
|
|
filesChecked = unique.length;
|
|
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)`);
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scoped: ${Date.now() - tSyncScan}ms (${unique.length} paths, ${trackedFiles.length} tracked)`);
|
|
|
} else {
|
|
} 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);
|
|
currentFiles = await scanDirectoryAsync(this.rootDir);
|
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
|
|
if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] sync-scan: ${Date.now() - tSyncScan}ms (${currentFiles.length} files)`);
|
|
|
filesChecked = currentFiles.length;
|
|
filesChecked = currentFiles.length;
|