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
+116 -28
View File
@@ -412,6 +412,82 @@ function printIndexResult(clack: typeof import('@clack/prompts'), result: IndexR
}
}
/**
* When an `init`/`index` produced an EMPTY graph and the reason is that the
* project's own `.gitignore` excludes nested git repositories — the "super-repo
* gitignores its child repos" layout (#1156), where `init` at the parent
* correctly indexes ~nothing while `init` inside each child works — name those
* repos and offer to index them. An interactive terminal gets a yes/no prompt
* that writes `includeIgnored` to codegraph.json and re-indexes; a
* non-interactive run just prints the one-line opt-in snippet. The caller gates
* this on `nodesCreated === 0`, so a project that DID index real content is
* never nagged about the gitignored reference clones it deliberately keeps out
* (#970, #1065). Best-effort throughout: detection never breaks the command.
*/
async function offerIndexIgnoredRepos(
clack: typeof import('@clack/prompts'),
projectPath: string,
reindex: () => Promise<IndexResult>,
opts: { interactive: boolean },
): Promise<IndexResult | undefined> {
let repos: string[];
try {
const { findUnindexedIgnoredRepos } = await import('../extraction');
repos = findUnindexedIgnoredRepos(projectPath);
} catch {
return; // detection is advisory — never let it break the command
}
if (repos.length === 0) return;
const { PROJECT_CONFIG_FILENAME } = await import('../project-config');
const isOne = repos.length === 1;
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(', ')}] }`;
clack.log.warn(
`Your .gitignore excludes ${isOne ? 'a nested git repository' : `${formatNumber(repos.length)} nested git repositories`} here, ` +
`so ${isOne ? 'it was' : 'they were'} not indexed: ${names.join(', ')}${extra}.`,
);
const manualHint = () => {
clack.log.info(
`If ${isOne ? "it's" : "they're"} your code, add ${isOne ? 'it' : 'them'} to ${PROJECT_CONFIG_FILENAME} and re-index:`,
);
clack.log.info(` ${snippet}`);
};
if (!opts.interactive || !process.stdin.isTTY) {
manualHint();
return;
}
const yes = await clack.confirm({
message: `Index ${isOne ? 'it' : `these ${formatNumber(repos.length)}`} now? Adds ${isOne ? 'it' : 'them'} to ${PROJECT_CONFIG_FILENAME}.`,
initialValue: true,
});
if (clack.isCancel(yes) || !yes) {
manualHint();
return;
}
let added: number;
try {
const { addIncludeIgnoredPatterns } = await import('../project-config');
added = addIncludeIgnoredPatterns(projectPath, repos);
} catch (err) {
clack.log.error(`Could not update ${PROJECT_CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`);
manualHint();
return;
}
clack.log.success(`Added ${formatNumber(added)} ${added === 1 ? 'entry' : 'entries'} to ${PROJECT_CONFIG_FILENAME} ${getGlyphs().dash} re-indexing…`);
const result = await reindex();
printIndexResult(clack, result, projectPath);
return result;
}
/**
* Write detailed error log to .codegraph/errors.log
*/
@@ -522,28 +598,34 @@ program
// accepted (so existing muscle memory and scripts don't break) but is a
// no-op — initializing always builds the initial index.
// Supervise the index: self-terminate if orphaned or wedged (#999).
const supervision = installCommandSupervision('init');
let result: IndexResult;
try {
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
// A closure so we can re-run the exact same supervised, progress-rendered
// index if the user opts gitignored child repos in below (#1156).
const runIndex = async (): Promise<IndexResult> => {
const supervision = installCommandSupervision('init');
try {
if (options.verbose) {
return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true });
}
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
const r = await cg.indexAll({ onProgress: progress.onProgress });
await progress.stop();
return r;
} finally {
supervision.stop();
}
} finally {
supervision.stop();
}
};
const result = await runIndex();
printIndexResult(clack, result, projectPath);
await recordIndexTelemetry(cg, result);
// An empty graph at a git super-repo usually means `.gitignore` excludes
// the child repos that hold the code — surface them and offer to opt in
// rather than leaving the user with a silent 0-node "Done". (#1156)
if (result.nodesCreated === 0) {
await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true });
}
try {
const { offerWatchFallback } = await import('../installer');
await offerWatchFallback(clack, projectPath);
@@ -672,26 +754,32 @@ program
const clack = await importESM('@clack/prompts');
clack.intro('Indexing project');
let result: IndexResult;
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
// A closure so a re-index (after opting gitignored child repos in, #1156)
// renders identically. Supervision already wraps the whole command.
const renderIndex = async (): Promise<IndexResult> => {
if (options.verbose) {
return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true });
}
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
const r = await cg.indexAll({ onProgress: progress.onProgress });
await progress.stop();
}
return r;
};
const result = await renderIndex();
printIndexResult(clack, result, projectPath);
await recordIndexTelemetry(cg, result);
if (!result.success) {
// Empty graph at a git super-repo → likely `.gitignore`d child repos;
// name them and offer to opt in instead of a silent 0-node result (#1156).
let finalResult = result;
if (result.nodesCreated === 0) {
finalResult = (await offerIndexIgnoredRepos(clack, projectPath, renderIndex, { interactive: true })) ?? result;
}
if (!finalResult.success) {
process.exit(1);
}