fix(scan): includeIgnored child patterns revive repos under a gitignored parent (#1318)

`.gitignore: /repos/` lists `repos/` as ONE ignored entry, while the
CLI hint (#1156) suggests `includeIgnored: ["repos/a/", "repos/b/"]` —
the child spelling. findIgnoredEmbeddedRepos tested the opt-in matcher
against the PARENT path only, which a child pattern never matches, so
the documented opt-in silently indexed nothing and init looped the
byte-identical suggestion back at the user (#1295).

Ignored dirs that don't match as a whole are now descended (the walk
was already bounded: depth 4 / 2000 entries, and only runs when
includeIgnored is configured) and each nested repo root is matched
individually — parent spelling opts in everything under the dir, child
spelling exactly the named repos. findUnindexedIgnoredRepos gets the
same per-repo check so the hint stops nagging about repos that are
already configured while still naming unopted siblings.

Fixes #1295

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-16 16:01:08 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent c472cfb52e
commit a5a8942d1c
3 changed files with 59 additions and 2 deletions
+1
View File
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes ### Fixes
- `codegraph.json`'s `includeIgnored` works again for the "folder of repos" layout: when one `.gitignore` rule covers a parent directory (`/repos/`) holding several embedded git repositories, opting in the individual repos (`"includeIgnored": ["repos/a/"]` — the exact spelling `codegraph init`'s own hint suggests) previously matched nothing and indexed zero files, looping the same suggestion back at you. Both spellings now work — name the parent directory to opt in everything under it, or name individual repos to opt in just those — and the hint no longer re-suggests repos that are already configured. (#1295)
- Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230) - Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230)
- Go method calls through a struct field (`target.conn.Exec(...)`) no longer bind to unrelated same-named local methods when the field's type is external — `conn *sql.DB` calls were being attributed to a local interface that happened to declare `Exec`, fabricating internal dependencies. Chained field calls now resolve by inferring the field's declared type from the struct definition: in-project types (including unexported ones like chi's `tree *node`) gain correct, validated call edges that never existed before, and external types (standard library, third-party modules) are left unlinked instead of guessed. (#1276) - Go method calls through a struct field (`target.conn.Exec(...)`) no longer bind to unrelated same-named local methods when the field's type is external — `conn *sql.DB` calls were being attributed to a local interface that happened to declare `Exec`, fabricating internal dependencies. Chained field calls now resolve by inferring the field's declared type from the struct definition: in-project types (including unexported ones like chi's `tree *node`) gain correct, validated call edges that never existed before, and external types (standard library, third-party modules) are left unlinked instead of guessed. (#1276)
- TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292) - TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292)
+39
View File
@@ -120,6 +120,45 @@ describe('multi-repo workspaces (#514) + .gitignore-respect default (#970, #976)
expect(files).toContain('tools.ts'); // the parent's own tracked code still indexes expect(files).toContain('tools.ts'); // the parent's own tracked code still indexes
}); });
it('child-pattern spelling revives repos whose PARENT dir carries the gitignore rule (#1295)', () => {
// `.gitignore: /repos/` lists `repos/` as ONE ignored entry, while the
// CLI hint suggests `includeIgnored: ["repos/a/", "repos/b/"]` — the
// child spelling. That never matched the parent path, so the documented
// opt-in silently indexed nothing.
write(path.join(ws, 'repos/a/a.ts'), 'export const a = 1;\n');
write(path.join(ws, 'repos/b/b.ts'), 'export const b = 2;\n');
makeRepo(path.join(ws, 'repos/a'));
makeRepo(path.join(ws, 'repos/b'));
write(path.join(ws, '.gitignore'), '/repos/\n');
writeConfig({ includeIgnored: ['repos/a/', 'repos/b/'] });
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('repos/a/a.ts');
expect(files).toContain('repos/b/b.ts');
// Discovery (the watcher path) agrees with the scanner.
expect(discoverEmbeddedRepoRoots(ws).sort()).toEqual(['repos/a/', 'repos/b/']);
// And the CLI hint has nothing left to nag about.
expect(findUnindexedIgnoredRepos(ws)).toEqual([]);
});
it('child-pattern spelling opts in ONLY the named repo; siblings stay out and stay hinted (#1295)', () => {
write(path.join(ws, 'repos/a/a.ts'), 'export const a = 1;\n');
write(path.join(ws, 'repos/b/b.ts'), 'export const b = 2;\n');
makeRepo(path.join(ws, 'repos/a'));
makeRepo(path.join(ws, 'repos/b'));
write(path.join(ws, '.gitignore'), '/repos/\n');
writeConfig({ includeIgnored: ['repos/a/'] });
makeRepo(ws);
const files = scanDirectory(ws);
expect(files).toContain('repos/a/a.ts');
expect(files.some((f) => f.startsWith('repos/b/'))).toBe(false);
expect(discoverEmbeddedRepoRoots(ws)).toEqual(['repos/a/']);
// The unopted sibling is still worth hinting about.
expect(findUnindexedIgnoredRepos(ws)).toEqual(['repos/b/']);
});
it('only re-includes the opted-in dir, not every gitignored dir', () => { it('only re-includes the opted-in dir, not every gitignored dir', () => {
// `packages/` is opted in; `scratch/` (also holding a repo) is NOT. // `packages/` is opted in; `scratch/` (also holding a repo) is NOT.
write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n'); write(path.join(ws, 'packages/proj-a/src/auth.ts'), 'export function login() {}\n');
+19 -2
View File
@@ -839,6 +839,10 @@ export function findUnindexedIgnoredRepos(rootDir: string): string[] {
if (defaults.ignores(dir)) continue; // node_modules etc. — never project code if (defaults.ignores(dir)) continue; // node_modules etc. — never project code
if (includeIgnored?.ignores(normalizePath(dir))) continue; // already opted in — nothing to nag about if (includeIgnored?.ignores(normalizePath(dir))) continue; // already opted in — nothing to nag about
for (const repo of findNestedGitRepos(path.join(rootDir, dir), dir)) { for (const repo of findNestedGitRepos(path.join(rootDir, dir), dir)) {
// Per-repo opt-in check, mirroring findIgnoredEmbeddedRepos: a child
// pattern (`repos/a/`) doesn't match the parent dir above but DOES
// cover this repo — it's indexed, so don't nag about it (#1295).
if (includeIgnored?.ignores(normalizePath(repo))) continue;
repos.push(repo); repos.push(repo);
if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos; if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos;
} }
@@ -867,8 +871,21 @@ function findIgnoredEmbeddedRepos(repoDir: string, includeIgnored: Ignore | null
const repos: string[] = []; const repos: string[] = [];
for (const dir of listIgnoredDirs(repoDir)) { for (const dir of listIgnoredDirs(repoDir)) {
if (defaults.ignores(dir)) continue; if (defaults.ignores(dir)) continue;
if (!includeIgnored.ignores(normalizePath(prefix + dir))) continue; const nested = findNestedGitRepos(path.join(repoDir, dir), dir);
repos.push(...findNestedGitRepos(path.join(repoDir, dir), dir)); if (includeIgnored.ignores(normalizePath(prefix + dir))) {
// The whole ignored dir is opted in — every nested repo under it counts.
repos.push(...nested);
} else {
// A single gitignore rule often covers the PARENT of the opted-in
// repos: `.gitignore: /repos/` lists `repos/` as ONE ignored entry,
// while `includeIgnored: ["repos/a/"]` (the CLI hint's own suggested
// spelling) names the child — which never matches the parent path, so
// the opt-in silently did nothing (#1295). Match each nested repo
// root individually so both spellings work. The walk is bounded
// (depth/entry caps in findNestedGitRepos) and only runs when
// includeIgnored is configured at all.
repos.push(...nested.filter((r) => includeIgnored.ignores(normalizePath(prefix + r))));
}
} }
return repos; return repos;
} }