mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 04:19:33 +08:00
fix(scope): a folder git ignores matches only itself, not every folder with its name (#2858)
The scope matcher that the indexer, sync and the file watcher share also takes in the folders `git ls-files -o -i --exclude-standard --directory` reports as ignored (PR #1754, for issue #1728). They were added to it as gitignore patterns. git reports them relative to the root, but a pattern of one segment, like `src/`, has no anchor in gitignore syntax and matches at any depth. So an ignored root `src/`, holding only a gitignored jib agent, hid every module's `src/` in a Maven monorepo, and the index came up empty. As patterns, folder names were also read as syntax: `[draft]` as a character class, `a[b` as a regex that throws on the first match (the git listing then fell back to a filesystem walk, and the watcher threw on every event), `#tmp` as a comment and `!keep` as a negation. The reported folders now stay root-relative paths in a set, and a path is out of scope when it is one of them or lies inside one. Nothing is parsed, so there is nothing to anchor or escape. Escaping was not an option: in ignore 7.0.5 an escaped `?` never matches a literal `?`, an escaped backslash before a `.` turns the dot into a wildcard, and every match is case-insensitive. git prints these paths relative to its cwd, so for a project in a subfolder of a repo they are relative to that subfolder, the root the matcher is asked about. The CLI hint for gitignored nested repos wrote their paths into codegraph.json `includeIgnored` the same unanchored way, so an opt-in for `child/` also took in any later `archive/child/`. It now writes a root-anchored pattern with `*` and `[` escaped. Validation: - New git-ignored-dirs-scope tests (9): all fail on main and pass with the fix. They cover the issue's layout (scan, index, scoped sync), the lib/, build/ and dist/ variants, a `[draft]` folder, the watcher with a `scratch[1` folder, a project in a subfolder of its repo with `#tmp` and `!keep` folders, and the CLI hint end to end. - A/B with the kernel in both arms, on real repos after writing the root folders a local build writes, against git's own view (ls-files --cached --others --exclude-standard, less codegraph's defaults). Files indexed of those in scope, main then fix: stencil 1015 and 1707 of 1707, Sentinel with the reporter's jib layout 104 and 1423 of 1423, ant-design 2851 and 3138 of 3138, wger 767 and 770 of 770. The fix misses no file in scope and adds none outside it. - For Sentinel, ant-design and wger, the fix's graph on the built tree is byte-identical to main's on a clean checkout. ant-design's 53 changed call sites move `useLocale` from the docs site's hook to components/locale/useLocale.ts. In stencil, every edge the fix adds on the built tree is also in main's graph on a clean checkout, and the 62 name matches it no longer makes are unresolved there as well. - Controls gin and flask: byte-identical graphs. - Index time, median of 3 interleaved: gin 2572 and 2545 ms, flask 2400 and 2378 ms; the repos above take longer only by their restored files. The scope step with 486 and 599 reported entries (a `.DS_Store` in every folder): main 206 and 416 ms, fix 105 and 155 ms. Fixes #2849 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
44adef821b
commit
a278c2ee12
@@ -550,9 +550,11 @@ async function offerIndexIgnoredRepos(
|
||||
opts: { interactive: boolean },
|
||||
): Promise<IndexResult | undefined> {
|
||||
let repos: string[];
|
||||
let patterns: string[];
|
||||
try {
|
||||
const { findUnindexedIgnoredRepos } = await import('../extraction');
|
||||
const { findUnindexedIgnoredRepos, includeIgnoredPatternFor } = await import('../extraction');
|
||||
repos = findUnindexedIgnoredRepos(projectPath);
|
||||
patterns = repos.map(includeIgnoredPatternFor);
|
||||
} catch {
|
||||
return; // detection is advisory — never let it break the command
|
||||
}
|
||||
@@ -563,7 +565,7 @@ async function offerIndexIgnoredRepos(
|
||||
const SHOWN = 6;
|
||||
const names = repos.slice(0, SHOWN).map((r) => r.replace(/\/$/, ''));
|
||||
const extra = repos.length > SHOWN ? ` (+${formatNumber(repos.length - SHOWN)} more)` : '';
|
||||
const snippet = `{ "includeIgnored": [${repos.map((p) => JSON.stringify(p)).join(', ')}] }`;
|
||||
const snippet = `{ "includeIgnored": [${patterns.map((p) => JSON.stringify(p)).join(', ')}] }`;
|
||||
|
||||
clack.log.warn(
|
||||
`Your .gitignore excludes ${isOne ? 'a nested git repository' : `${formatNumber(repos.length)} nested git repositories`} here, ` +
|
||||
@@ -594,7 +596,7 @@ async function offerIndexIgnoredRepos(
|
||||
let added: number;
|
||||
try {
|
||||
const { addIncludeIgnoredPatterns } = await import('../project-config');
|
||||
added = addIncludeIgnoredPatterns(projectPath, repos);
|
||||
added = addIncludeIgnoredPatterns(projectPath, patterns);
|
||||
} catch (err) {
|
||||
clack.log.error(`Could not update ${PROJECT_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
manualHint();
|
||||
|
||||
+48
-14
@@ -517,9 +517,12 @@ function readGitExcludeExtraPatterns(rootDir: string): string {
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* ignored-untracked, relative to `rootDir` (git prints paths relative to its
|
||||
* cwd, also when `rootDir` is a subfolder of the repo) and trailing-slashed.
|
||||
* 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). Each one is a path, not a pattern:
|
||||
* see {@link ScopeIgnore}.
|
||||
*/
|
||||
function listGitIgnoredDirectories(rootDir: string): string[] {
|
||||
try {
|
||||
@@ -535,8 +538,11 @@ function listGitIgnoredDirectories(rootDir: string): string[] {
|
||||
},
|
||||
);
|
||||
const dirs: string[] = [];
|
||||
for (const entry of out.split('\0')) {
|
||||
if (!entry) continue;
|
||||
for (const raw of out.split('\0')) {
|
||||
// `./` is git's "this whole directory" for a root that is itself ignored
|
||||
// (#936); the root's own files stay in scope.
|
||||
if (isWholeCwdEntry(raw)) continue;
|
||||
const entry = normalizePath(raw);
|
||||
dirs.push(entry.endsWith('/') ? entry : `${entry}/`);
|
||||
}
|
||||
return dirs;
|
||||
@@ -958,11 +964,29 @@ export class ScopeIgnore {
|
||||
*/
|
||||
private include: Ignore | null = null,
|
||||
private includeRoots: string[] = [],
|
||||
/**
|
||||
* The directories `git ls-files --exclude-standard` reports as ignored
|
||||
* (see listGitIgnoredDirectories): root-relative, trailing-slashed paths.
|
||||
* Each names one directory and is matched as a literal path prefix, never
|
||||
* as a gitignore pattern. As a pattern, `src/` matched every `src/` below
|
||||
* the root too, hiding a whole Maven monorepo's sources (#2849), and a
|
||||
* folder named `[draft]` or `a[b` read as a glob (or did not compile).
|
||||
*/
|
||||
private gitIgnoredDirs: ReadonlySet<string> = new Set(),
|
||||
) {
|
||||
// Longest root first so paths in nested embedded repos hit the innermost matcher.
|
||||
this.embedded = [...embedded].sort((a, b) => b.root.length - a.root.length);
|
||||
}
|
||||
|
||||
/** True when `rel` is one of {@link gitIgnoredDirs} or lies inside one. */
|
||||
private inGitIgnoredDir(rel: string): boolean {
|
||||
if (this.gitIgnoredDirs.size === 0) return false;
|
||||
for (let slash = rel.indexOf('/'); slash !== -1; slash = rel.indexOf('/', slash + 1)) {
|
||||
if (this.gitIgnoredDirs.has(rel.slice(0, slash + 1))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ignores(rel: string): boolean {
|
||||
// User `exclude` (#999) is checked first and against the full root-relative
|
||||
// path: it must drop git-TRACKED paths (which `.gitignore` can't) and apply
|
||||
@@ -994,7 +1018,7 @@ export class ScopeIgnore {
|
||||
if (rel.endsWith('/') && this.embedded.some(({ root }) => root.startsWith(rel))) {
|
||||
return false;
|
||||
}
|
||||
return this.rootMatcher.ignores(rel);
|
||||
return this.rootMatcher.ignores(rel) || this.inGitIgnoredDir(rel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,19 +1030,17 @@ 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);
|
||||
}
|
||||
// The root matcher has defaults + root `.gitignore` + info/exclude +
|
||||
// core.excludesFile. The ignored-untracked directories git reports join
|
||||
// them, so nested `.gitignore` effects prune the watcher identically to the
|
||||
// indexer (#1728) — as literal paths, not patterns (#2849).
|
||||
return new ScopeIgnore(
|
||||
rootMatcher,
|
||||
buildDefaultIgnore(rootDir),
|
||||
roots.map((root) => ({ root, matcher: buildDefaultIgnore(path.join(rootDir, root)) })),
|
||||
loadExcludeMatcher(rootDir),
|
||||
include,
|
||||
include ? includeStaticRoots(loadIncludePatterns(rootDir)) : [],
|
||||
new Set(listGitIgnoredDirectories(rootDir)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1167,6 +1189,18 @@ export function findUnindexedIgnoredRepos(rootDir: string): string[] {
|
||||
return repos;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `codegraph.json` `includeIgnored` pattern that opts in exactly the repo
|
||||
* at `repoDir`, a root-relative, trailing-slashed path from
|
||||
* {@link findUnindexedIgnoredRepos}. Patterns are gitignore syntax, so the
|
||||
* path is anchored to the root (a bare `child/` also matches every `child/`
|
||||
* below it) and its `*` and `[` are escaped (#2849). The `ignore` matcher has
|
||||
* no working escape for `?`, so a `?` stays a one-character wildcard.
|
||||
*/
|
||||
export function includeIgnoredPatternFor(repoDir: string): string {
|
||||
return '/' + repoDir.replace(/[*[]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover embedded repos hidden by `repoDir`'s OWN gitignore rules: for each
|
||||
* gitignored directory, search for nested `.git` roots. Returns repo paths
|
||||
|
||||
Reference in New Issue
Block a user