fix(init): surface and offer to opt in gitignored child repos on an empty index (#1156) (#1208)

A Git super-repo whose `.gitignore` excludes its child repositories indexed
~nothing at the parent: CodeGraph respects `.gitignore` by default (#970,
#1065), so the excluded children were skipped and `codegraph init` printed
"Done" with 0 nodes — even though `init` inside each child worked fine. The
empty index was silent and unexplained.

`init`/`index` now detect the gitignored child repos they skipped when an
index comes up empty of symbols, name them, and — in an interactive terminal
— offer to index them (writing an `includeIgnored` entry to codegraph.json and
re-indexing on the spot); non-interactive runs print the exact codegraph.json
snippet to add. Gated on nodesCreated === 0, so a project that deliberately
keeps gitignored reference clones out of a working index is never nagged.

- extraction: findUnindexedIgnoredRepos — the inverse of discoverEmbeddedRepoRoots
  (bounded, skips default-ignored dirs, respects existing includeIgnored)
- project-config: addIncludeIgnoredPatterns — create/merge codegraph.json,
  idempotent, refuses to clobber malformed JSON
- cli: wire the detect-name-offer flow into both `init` and `index`
- tests: +13 covering detection, config writing, and the no-nag gate

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-07 13:01:38 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a9e8fa48a1
commit e65a39746c
6 changed files with 345 additions and 30 deletions
+42
View File
@@ -798,6 +798,48 @@ export function discoverEmbeddedRepoRoots(rootDir: string): string[] {
return out;
}
/**
* Cap on how many skipped gitignored repos the CLI hint enumerates — a huge
* gitignored data dir full of clones must never turn the hint scan into a long
* walk. Enough to make the point; the caller says "+N more" past this.
*/
const UNINDEXED_IGNORED_REPO_HINT_CAP = 100;
/**
* The INVERSE of the gitignored side of {@link discoverEmbeddedRepoRoots}:
* nested git repositories under a gitignored directory that the project has NOT
* opted into via `codegraph.json` `includeIgnored`. These are real repos the
* default `init`/`index` deliberately skips because `.gitignore` excludes them
* (#970, #976) — most visibly the "super-repo `.gitignore`s its child repos"
* layout (#1156), where `init` at the parent correctly indexes ~nothing while
* `init` inside each child works. The CLI uses this to turn that silent empty
* index into an actionable hint: it names the skipped repos and offers to opt
* them in. Paths are `rootDir`-relative and trailing-slashed (valid
* `includeIgnored` patterns as-is). Returns `[]` for a non-git root (a
* filesystem walk already descends into nested repos there), skips built-in
* default-ignored dirs (`node_modules`, …), and is bounded so it never stalls
* on a giant ignored tree.
*/
export function findUnindexedIgnoredRepos(rootDir: string): string[] {
try {
execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootDir, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
} catch {
return [];
}
const defaults = defaultsOnlyIgnore();
const includeIgnored = loadIncludeIgnoredMatcher(rootDir);
const repos: string[] = [];
for (const dir of listIgnoredDirs(rootDir)) {
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)) {
repos.push(repo);
if (repos.length >= UNINDEXED_IGNORED_REPO_HINT_CAP) return repos;
}
}
return repos;
}
/**
* Discover embedded repos hidden by `repoDir`'s OWN gitignore rules: for each
* gitignored directory, search for nested `.git` roots. Returns repo paths