diff --git a/CHANGELOG.md b/CHANGELOG.md index 536110e..6163387 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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) - 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) diff --git a/__tests__/multi-repo-workspace.test.ts b/__tests__/multi-repo-workspace.test.ts index 2e6b3c6..2828340 100644 --- a/__tests__/multi-repo-workspace.test.ts +++ b/__tests__/multi-repo-workspace.test.ts @@ -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 }); + 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', () => { // `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'); diff --git a/src/extraction/index.ts b/src/extraction/index.ts index f2e7e04..2459478 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -839,6 +839,10 @@ export function findUnindexedIgnoredRepos(rootDir: string): string[] { if (defaults.ignores(dir)) continue; // node_modules etc. — never project code if (includeIgnored?.ignores(normalizePath(dir))) continue; // already opted in — nothing to nag about 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); if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos; } @@ -867,8 +871,21 @@ function findIgnoredEmbeddedRepos(repoDir: string, includeIgnored: Ignore | null const repos: string[] = []; for (const dir of listIgnoredDirs(repoDir)) { if (defaults.ignores(dir)) continue; - if (!includeIgnored.ignores(normalizePath(prefix + dir))) continue; - repos.push(...findNestedGitRepos(path.join(repoDir, dir), dir)); + const nested = 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; }