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:
co-authored by
Claude Opus 4.8
parent
a9e8fa48a1
commit
e65a39746c
+116
-28
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -342,3 +342,55 @@ export function loadIncludePatterns(rootDir: string): string[] {
|
||||
export function clearProjectConfigCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add gitignore-style patterns to a project's `codegraph.json` `includeIgnored`
|
||||
* list, creating the file if absent and preserving every other key. Used by the
|
||||
* CLI to opt a "super-repo of gitignored child repos" (#1156) into the index on
|
||||
* the user's say-so. Returns the count of patterns actually ADDED (ones already
|
||||
* present are skipped, so a re-run is idempotent).
|
||||
*
|
||||
* A plain-JSON round-trip: a `codegraph.json` carrying comments (not valid JSON)
|
||||
* already fails to load with a warning, so rather than silently clobber such a
|
||||
* file this throws when an existing config won't parse — the caller falls back
|
||||
* to printing the manual snippet. Invalidates the config cache so a subsequent
|
||||
* index in the same process sees the new patterns.
|
||||
*/
|
||||
export function addIncludeIgnoredPatterns(rootDir: string, patterns: string[]): number {
|
||||
const file = path.join(rootDir, PROJECT_CONFIG_FILENAME);
|
||||
let config: Record<string, unknown> = {};
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = fs.readFileSync(file, 'utf-8');
|
||||
} catch {
|
||||
raw = null; // missing file — create a fresh one below
|
||||
}
|
||||
if (raw !== null) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`${PROJECT_CONFIG_FILENAME} is not valid JSON — fix it by hand, then re-run.`);
|
||||
}
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
config = parsed as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = Array.isArray(config.includeIgnored)
|
||||
? (config.includeIgnored as unknown[]).filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
const merged = [...existing];
|
||||
const seen = new Set(existing);
|
||||
let added = 0;
|
||||
for (const p of patterns) {
|
||||
if (seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
merged.push(p);
|
||||
added++;
|
||||
}
|
||||
config.includeIgnored = merged;
|
||||
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
|
||||
clearProjectConfigCache();
|
||||
return added;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user