From 97d8a22f8d795f9245c084af0de81176b79a34fb Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:06:43 -0500 Subject: [PATCH 01/10] Add design spec: indexing coverage & freshness Batch init (--all), a generalized --git-hooks flag, and an opt-in global auto-init setting for MCP-triggered indexing. --- ...-indexing-coverage-and-freshness-design.md | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md diff --git a/docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md b/docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md new file mode 100644 index 000000000..4e86ca7e0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md @@ -0,0 +1,73 @@ +# Indexing coverage & freshness + +## Problem + +Indexing is fully opt-in and per-project (`codegraph init` run manually, once, per repo). For a user running many small repos this creates two gaps: + +1. **Coverage** — no way to bring several existing repos up to date in one shot; each needs its own manual `codegraph init`. +2. **Freshness** — the live file watcher only runs while an MCP session is open. `offerWatchFallback()` already offers a git-hooks freshness fallback (`src/sync/git-hooks.ts`), but only when `watchDisabledReason()` detects the watcher is disabled outright (WSL2 `/mnt` drives, `CODEGRAPH_NO_WATCH`). On a normal filesystem the watcher is "enabled" but simply isn't running between sessions, and the git-hooks fallback is never offered for that case. + +There's also no way to have a new repo get indexed automatically the first time it's opened — the MCP server currently just tells the calling agent "the user can run `codegraph init` there" (`src/mcp/tools.ts`) and stops. + +## Non-goals + +- No change to the extraction/graph/indexing logic itself — this is glue around existing, already-tested code paths. +- No change to default behavior for existing users — auto-init defaults to **off**. +- Not a general-purpose config system — one boolean setting, following the existing `~/.codegraph/telemetry.json` precedent. + +## Design + +Three independent, additive components. Each reuses an existing internal function rather than duplicating logic. + +### 1. Batch init (`--all `) + +Add an `--all ` option to the existing `init` command in `src/bin/codegraph.ts`. When present, loop the same per-directory logic the single-path `init` already runs (including the existing home-dir/filesystem-root safety refusal) over each directory in sequence. + +Output changes from the current one-shot `clack.intro`/`outro` block to a single summary table at the end: `repo → indexed | skipped (already initialized) | error`. A failure in one directory does not stop the batch — see Error handling. + +Existing single-path behavior (`codegraph init [path]`) is unchanged; `--all` is strictly additive. + +### 2. Generalized git-hooks flag (`--git-hooks`) + +`offerWatchFallback()` in `src/installer/index.ts` currently short-circuits unless `watchDisabledReason(projectPath)` returns non-null. Add a `--git-hooks` flag to `init` that calls the existing `installSyncHook()` path (from `src/sync/git-hooks.ts`) unconditionally, bypassing the "only if the watcher is disabled" gate — i.e., a repo with a perfectly normal watcher can still opt into the belt-and-suspenders git hooks for staleness that accrues between sessions. + +No change to the existing WSL2/`CODEGRAPH_NO_WATCH` auto-offer behavior — that keeps firing exactly as today. `--git-hooks` is a second, explicit way to reach the same `installSyncHook()` call. + +### 3. Opt-in global auto-init + +New file `src/installer/user-config.ts`, modeled on the existing `~/.codegraph/telemetry.json` storage pattern (same home directory, same JSON-file-with-safe-parse approach already used for telemetry). Stores one field: + +```json +{ "autoInit": false } +``` + +New CLI surface, mirroring the shape of the existing `telemetry [action]` command: + +``` +codegraph config set auto-init on +codegraph config set auto-init off +codegraph config get auto-init +``` + +In `src/mcp/tools.ts`, at the point where an uninitialized project currently produces the "not initialized... user can run `codegraph init`" response (~line 821 in the compiled dist; corresponding source location in `src/mcp/tools.ts`): if `autoInit` is `true`, run the same init path `codegraph init` uses (including its existing safety refusal for home dirs/filesystem roots) before answering the query that triggered it. If `false` (the default — nothing installed today changes), keep the current behavior unchanged. + +This does not touch the safety refusal logic itself — auto-init is just an additional caller of the existing, already-guarded init path. + +## Error handling + +- **Batch init**: a failure indexing one directory is logged and the batch continues to the next directory; the final summary table shows per-directory status so nothing fails silently. +- **`--git-hooks`**: no new failure modes — reuses `installSyncHook()`'s existing error handling (e.g. "not a git repo" already handled there). +- **Auto-init**: inherits the existing safety refusal unchanged. If the project path looks like a home directory or filesystem root, auto-init declines exactly as manual `init` does today and falls back to the current "user can run `codegraph init`" message rather than indexing something unintended. + +## Testing + +Follow existing patterns in `__tests__/`: + +- Flag parsing for `--all` and `--git-hooks`, similar in shape to `__tests__/cli-*.test.ts`. +- Batch init: one test with a mix of a valid dir, an already-initialized dir, and a dir that trips the safety refusal — asserts the batch continues and the summary reflects all three outcomes. +- `--git-hooks` explicit flag: asserts `installSyncHook()` is called even when `watchDisabledReason()` returns null. +- Auto-init config: read/write round-trip for `~/.codegraph/config.json` (mirrors telemetry's existing storage tests), plus one test that `autoInit: true` triggers the init path from `mcp/tools.ts` and one confirming the home-dir refusal still applies when auto-init fires. + +## Scope check + +This is sized for a single implementation plan — three additive components sharing one theme, no cross-cutting architecture change, no decomposition needed. From b0290b14c7337b7ebb6a5f6715a115b3d8012487 Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:16:34 -0500 Subject: [PATCH 02/10] Add implementation plan: indexing coverage & freshness Five tasks: batch init (--all), generalized --git-hooks flag, global autoInit user-config, codegraph config CLI command, and MCP wiring. --- ...6-08-18-indexing-coverage-and-freshness.md | 1018 +++++++++++++++++ 1 file changed, 1018 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-indexing-coverage-and-freshness.md diff --git a/docs/superpowers/plans/2026-08-18-indexing-coverage-and-freshness.md b/docs/superpowers/plans/2026-08-18-indexing-coverage-and-freshness.md new file mode 100644 index 000000000..f57f3597d --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-indexing-coverage-and-freshness.md @@ -0,0 +1,1018 @@ +# Indexing Coverage & Freshness Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the "many small repos" indexing gap in codegraph with three additive features: batch `init --all`, a generalized `--git-hooks` freshness flag, and an opt-in global `autoInit` setting the MCP server honors. + +**Architecture:** Three independent, additive changes layered onto existing, already-tested code paths (`CodeGraph.init`/`indexAll`, `offerWatchFallback`/`installGitSyncHook`, `getCodeGraph`'s not-indexed branch) — no new subsystems, no change to default behavior for existing users. + +**Tech Stack:** TypeScript, Commander v14 (CLI), Vitest (tests), `@clack/prompts` (interactive CLI UI). + +## Global Constraints + +- Default behavior for every existing command is unchanged unless a new flag/setting is explicitly used. `autoInit` defaults to `false`. +- No new dependencies. +- All new persisted state follows the existing `~/.codegraph/.json` pattern (see `src/installer/beta-signup.ts`), including dependency-injectable `dir` for tests — no test ever touches the real `~/.codegraph`. +- Auto-init (Task 5) must reuse the existing `unsafeIndexRootReason` safety refusal unchanged — it must never index a home directory or filesystem root. +- Run `npx tsc --noEmit` and `npm test` after every task before committing. + +--- + +### Task 1: Batch init (`--all `) + +**Files:** +- Modify: `src/bin/codegraph.ts:596-681` (the `init` command) +- Test: Create `__tests__/cli-init-batch.test.ts` + +**Interfaces:** +- Produces: `initOneProject(projectPath: string, options: { force?: boolean; verbose?: boolean }, clack: typeof import('@clack/prompts'), mode: 'single' | 'batch'): Promise`, where `InitOutcome = { projectPath: string; status: 'indexed' | 'already-initialized' | 'refused' | 'error'; detail: string }`. Task 2 will extend `options` with `gitHooks?: boolean` and thread it into the two `offerWatchFallback` calls inside this function. +- Consumes: existing `unsafeIndexRootReason`, `isInitialized` (from `../directory`), `loadCodeGraph()`, `installCommandSupervision`, `createVerboseProgress`, `createShimmerProgress`, `printIndexResult`, `recordIndexTelemetry`, `offerIndexIgnoredRepos`, `colors`, `getGlyphs` — all already imported/defined in `src/bin/codegraph.ts`. `offerWatchFallback` from `../installer` (dynamic import, as today). + +- [ ] **Step 1: Write the failing test for batch summary output** + +Create `__tests__/cli-init-batch.test.ts`: + +```typescript +/** + * `codegraph init --all ` (batch indexing across many repos). + * + * Exercised end-to-end against the built binary, matching the convention in + * cli-query-command.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function initAll(dirs: string[]): { stdout: string; status: number } { + try { + const stdout = execFileSync(process.execPath, [BIN, 'init', '--all', ...dirs], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return { stdout, status: 0 }; + } catch (err) { + const e = err as { stdout?: Buffer; status?: number }; + return { stdout: e.stdout?.toString('utf-8') ?? '', status: e.status ?? 1 }; + } +} + +function makeRepo(root: string, name: string): string { + const dir = path.join(root, name); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/main.ts'), 'export function main(){ return 1; }\n'); + return dir; +} + +describe('codegraph init --all', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-init-batch-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('indexes every directory listed and reports a summary line per repo', () => { + const repoA = makeRepo(root, 'repo-a'); + const repoB = makeRepo(root, 'repo-b'); + + const { stdout, status } = initAll([repoA, repoB]); + + expect(status).toBe(0); + expect(stdout).toContain(repoA); + expect(stdout).toContain(repoB); + expect(fs.existsSync(path.join(repoA, '.codegraph'))).toBe(true); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); + }); + + it('continues past an already-initialized directory instead of stopping the batch', () => { + const repoA = makeRepo(root, 'repo-a'); + const repoB = makeRepo(root, 'repo-b'); + execFileSync(process.execPath, [BIN, 'init', repoA], { + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: 'ignore', + }); + + const { stdout, status } = initAll([repoA, repoB]); + + expect(status).toBe(0); + expect(stdout).toContain('already'); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); + }); + + it('reports a refusal for an unsafe directory without aborting the rest of the batch', () => { + const repoB = makeRepo(root, 'repo-b'); + + const { stdout, status } = initAll([os.homedir(), repoB]); + + expect(status).toBe(1); // batch exit code reflects the refusal + expect(stdout).toContain('refused'); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); // but repo-b still got indexed + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm run build && npx vitest run __tests__/cli-init-batch.test.ts` +Expected: FAIL — `--all` is not a recognized option, or `.codegraph` never appears for `repoB` in the third test since the whole command doesn't understand `--all` yet. + +- [ ] **Step 3: Extract `initOneProject` and wire `--all`** + +In `src/bin/codegraph.ts`, replace the existing `init` command block (lines 596-681) with: + +```typescript +interface InitOutcome { + projectPath: string; + status: 'indexed' | 'already-initialized' | 'refused' | 'error'; + detail: string; +} + +async function initOneProject( + projectPath: string, + options: { force?: boolean; verbose?: boolean }, + clack: Awaited>, + mode: 'single' | 'batch', +): Promise { + // Refuse to index your home directory / a filesystem root — it pulls in + // caches, other projects, and your whole tree (a multi-GB index + watcher + // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). + const unsafe = unsafeIndexRootReason(projectPath); + if (unsafe && !options.force) { + if (mode === 'single') { + clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); + clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); + } + return { projectPath, status: 'refused', detail: `looks like ${unsafe}` }; + } + + if (isInitialized(projectPath)) { + if (mode === 'single') { + clack.log.warn(`Already initialized in ${projectPath}`); + clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); + } + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + } catch { /* non-fatal */ } + return { projectPath, status: 'already-initialized', detail: 'already initialized' }; + } + + try { + const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); + const cg = await CodeGraph.init(projectPath, { index: false }); + if (mode === 'single') clack.log.success(`Initialized in ${projectPath}`); + + const dbPath = getDatabasePath(projectPath); + const runIndex = async (): Promise => { + const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); + try { + if (mode === 'single' && options.verbose) { + return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); + } + if (mode === 'single') { + process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); + const progress = createShimmerProgress(); + const r = await cg.indexAll({ onProgress: progress.onProgress }); + await progress.stop(); + return r; + } + // Batch mode: no per-file progress UI — N repos would mean N progress + // renders. A one-line summary per repo prints after the loop instead. + return await cg.indexAll(); + } finally { + supervision.stop(); + } + }; + const result = await runIndex(); + if (mode === 'single') printIndexResult(clack, result, projectPath); + await recordIndexTelemetry(cg, result); + + if (result.nodesCreated === 0) { + if (mode === 'single') { + await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true }); + } else { + clack.log.warn(`${projectPath}: indexed 0 nodes — .gitignore may be excluding the code (run "codegraph init" there directly for the interactive fix).`); + } + } + + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + } catch { /* non-fatal */ } + + cg.destroy(); + return { projectPath, status: 'indexed', detail: `${result.nodesCreated} nodes` }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + if (mode === 'single') clack.log.error(`Failed: ${detail}`); + return { projectPath, status: 'error', detail }; + } +} + +program + .command('init [path]') + .description('Initialize CodeGraph in a project directory and build the initial index') + .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') + .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') + .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') + .option('--all ', 'Initialize every directory listed, one after another, and print a summary table') + .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; all?: string[] }) => { + const clack = await importESM('@clack/prompts'); + + if (options.all && options.all.length > 0) { + clack.intro(`Initializing CodeGraph in ${options.all.length} project${options.all.length > 1 ? 's' : ''}`); + const outcomes: InitOutcome[] = []; + for (const dir of options.all) { + outcomes.push(await initOneProject(path.resolve(dir), options, clack, 'batch')); + } + for (const o of outcomes) { + const line = `${o.projectPath} — ${o.status} (${o.detail})`; + if (o.status === 'error' || o.status === 'refused') clack.log.warn(line); + else clack.log.success(line); + } + clack.outro('Done'); + if (outcomes.some((o) => o.status === 'error' || o.status === 'refused')) { + process.exitCode = 1; + } + return; + } + + const projectPath = path.resolve(pathArg || process.cwd()); + clack.intro('Initializing CodeGraph'); + try { + const result = await initOneProject(projectPath, options, clack, 'single'); + if (result.status === 'error') { + clack.outro(''); + process.exit(1); + } + clack.outro('Done'); + } catch (err) { + clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); +``` + +This preserves single-path behavior byte-for-byte (same log calls, same order) while adding `--all`. Batch mode skips per-file progress rendering and the interactive "ignored repos" offer (replaced with a one-line warning), since prompting per-repo in a loop over many repos would block the batch on human input. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm run build && npx vitest run __tests__/cli-init-batch.test.ts` +Expected: PASS (all 3 tests) + +- [ ] **Step 5: Run the existing single-path init tests to confirm no regression** + +Run: `npm run build && npx vitest run -t init` +Expected: PASS — every existing test that exercises `codegraph init` (single-path) still passes unchanged. + +- [ ] **Step 6: Commit** + +```bash +git add src/bin/codegraph.ts __tests__/cli-init-batch.test.ts +git commit -m "feat(init): add --all for batch multi-repo indexing" +``` + +--- + +### Task 2: Generalized git-hooks flag (`--git-hooks`) + +**Files:** +- Modify: `src/installer/index.ts:661-721` (`offerWatchFallback`) +- Modify: `src/bin/codegraph.ts` (the `init` command from Task 1 — add the flag, thread it into `initOneProject`'s two `offerWatchFallback` calls) +- Test: Create `__tests__/watch-fallback.test.ts` + +**Interfaces:** +- Consumes: `initOneProject` and the `init` command from Task 1. +- Produces: `offerWatchFallback(clack, projectPath, opts: { yes?: boolean; force?: boolean })` — `force: true` installs the git hooks even when the live watcher is not disabled (bypasses the `watchDisabledReason` gate). Existing callers (Task 1's `initOneProject`) pass `force: options.gitHooks`. + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/watch-fallback.test.ts`: + +```typescript +/** + * offerWatchFallback's `force` option (generalizes git-hooks freshness + * beyond the WSL2/CODEGRAPH_NO_WATCH-only case — see `--git-hooks` on + * `codegraph init`). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { offerWatchFallback } from '../src/installer'; +import { isSyncHookInstalled } from '../src/sync/git-hooks'; + +function gitInit(dir: string): void { + execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); +} + +function fakeClack() { + return { + log: { warn: () => {}, info: () => {}, success: () => {}, error: () => {} }, + select: async () => 'hook' as const, + isCancel: () => false, + } as unknown as typeof import('@clack/prompts'); +} + +describe('offerWatchFallback force option', () => { + let repo: string; + + beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watchfallback-')); + gitInit(repo); + }); + + afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); + }); + + it('does nothing when the watcher is enabled and force is not set', async () => { + await offerWatchFallback(fakeClack(), repo, { yes: true }); + expect(isSyncHookInstalled(repo)).toBe(false); + }); + + it('installs git sync hooks when force is set, even though the watcher is enabled', async () => { + await offerWatchFallback(fakeClack(), repo, { yes: true, force: true }); + expect(isSyncHookInstalled(repo)).toBe(true); + }); + + it('is a no-op on a non-git directory even when forced', async () => { + const nonGitRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watchfallback-nogit-')); + try { + await offerWatchFallback(fakeClack(), nonGitRepo, { yes: true, force: true }); + expect(isSyncHookInstalled(nonGitRepo)).toBe(false); + } finally { + fs.rmSync(nonGitRepo, { recursive: true, force: true }); + } + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run __tests__/watch-fallback.test.ts` +Expected: FAIL on the second test — `force` is not yet a recognized option, so hooks are never installed when the watcher is enabled. + +- [ ] **Step 3: Add `force` to `offerWatchFallback`** + +In `src/installer/index.ts`, replace the function at lines 661-721 with: + +```typescript +export async function offerWatchFallback( + clack: typeof import('@clack/prompts'), + projectPath: string, + opts: { yes?: boolean; force?: boolean } = {}, +): Promise { + const reason = watchDisabledReason(projectPath); + if (!reason && !opts.force) return; // Watcher runs normally and hooks weren't explicitly requested. + + if (reason) { + clack.log.warn(`Live file watching is disabled here — ${reason}.`); + clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.'); + } else { + clack.log.info('Setting up git sync hooks as a freshness backstop for when no CodeGraph session is open.'); + } + + // No git repo → the commit-hook path doesn't apply; point at manual sync. + if (!isGitRepo(projectPath)) { + clack.log.info('Run `codegraph sync` after changing files to refresh the index.'); + return; + } + + // Already wired up on a previous run — confirm and move on without nagging. + if (isSyncHookInstalled(projectPath)) { + clack.log.info('Git sync hooks are already installed — the index refreshes after commit / pull / checkout.'); + return; + } + + let choice: 'hook' | 'manual'; + if (opts.yes) { + choice = 'hook'; + } else { + const sel = await clack.select({ + message: 'How should CodeGraph keep its index fresh?', + options: [ + { value: 'hook' as const, label: 'Sync on git commit / pull / checkout', hint: 'installs git hooks (recommended)' }, + { value: 'manual' as const, label: 'I\'ll run `codegraph sync` myself', hint: 'fully manual' }, + ], + initialValue: 'hook' as const, + }); + if (clack.isCancel(sel)) { + clack.log.info('Skipped — run `codegraph sync` after changes to refresh the index.'); + return; + } + choice = sel; + } + + if (choice === 'manual') { + clack.log.info('Run `codegraph sync` after changing files to refresh the index.'); + return; + } + + const result = installGitSyncHook(projectPath); + if (result.installed.length > 0) { + clack.log.success( + `Installed git ${result.installed.join(', ')} hook${result.installed.length > 1 ? 's' : ''} — ` + + 'the index refreshes in the background after each.', + ); + clack.log.info('Run `codegraph sync` anytime to refresh immediately.'); + } else { + clack.log.warn( + `Could not install git hooks${result.skipped ? ` (${result.skipped})` : ''}. ` + + 'Run `codegraph sync` after changes instead.', + ); + } +} +``` + +Only the guard clause and the `reason`-dependent log message changed; everything from `isGitRepo` down is identical to the original. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run __tests__/watch-fallback.test.ts` +Expected: PASS (all 3 tests) + +- [ ] **Step 5: Wire `--git-hooks` into the init command** + +In `src/bin/codegraph.ts`, update the `initOneProject` signature and both `offerWatchFallback` call sites from Task 1 to pass `force`: + +```typescript +async function initOneProject( + projectPath: string, + options: { force?: boolean; verbose?: boolean; gitHooks?: boolean }, + clack: Awaited>, + mode: 'single' | 'batch', +): Promise { +``` + +Change both occurrences of: + +```typescript + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); +``` + +to: + +```typescript + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch', force: options.gitHooks }); +``` + +And add the flag to the command definition: + +```typescript + .option('--all ', 'Initialize every directory listed, one after another, and print a summary table') + .option('--git-hooks', 'Install git sync hooks (commit/pull/checkout) to keep the index fresh even when no CodeGraph session is open') +``` + +(placed alongside the existing `.option('--all ...')` from Task 1), and widen the `options` parameter type on the `.action()` callback to include `gitHooks?: boolean`. + +- [ ] **Step 6: Run the full init test suite to confirm no regression** + +Run: `npm run build && npx vitest run -t init && npx vitest run __tests__/watch-fallback.test.ts` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/installer/index.ts src/bin/codegraph.ts __tests__/watch-fallback.test.ts +git commit -m "feat(init): add --git-hooks to force-enable freshness hooks" +``` + +--- + +### Task 3: Global auto-init config (`~/.codegraph/config.json`) + +**Files:** +- Create: `src/installer/user-config.ts` +- Test: Create `__tests__/user-config.test.ts` + +**Interfaces:** +- Produces: `getAutoInit(deps?: { dir?: string }): boolean`, `setAutoInit(value: boolean, deps?: { dir?: string }): void`. Task 4 (CLI command) and Task 5 (MCP wiring) both consume `getAutoInit`; Task 4 also consumes `setAutoInit`. + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/user-config.test.ts`: + +```typescript +/** + * Global user-level config (`~/.codegraph/config.json`) — currently one + * field, `autoInit`. Modeled directly on the beta-signup choice file + * (src/installer/beta-signup.ts): same state dir, same fail-silent / + * corrupted-file-means-default behavior. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { getAutoInit, setAutoInit } from '../src/installer/user-config'; + +describe('global auto-init config', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-user-config-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('defaults to false on a fresh machine', () => { + expect(getAutoInit({ dir })).toBe(false); + }); + + it('persists true after setAutoInit(true)', () => { + setAutoInit(true, { dir }); + expect(getAutoInit({ dir })).toBe(true); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + expect(raw.autoInit).toBe(true); + }); + + it('persists false after setAutoInit(false)', () => { + setAutoInit(true, { dir }); + setAutoInit(false, { dir }); + expect(getAutoInit({ dir })).toBe(false); + }); + + it('creates the state dir when missing', () => { + const nested = path.join(dir, 'not', 'yet', 'there'); + setAutoInit(true, { dir: nested }); + expect(getAutoInit({ dir: nested })).toBe(true); + }); + + it('treats a corrupted config file as the default (false), never throws', () => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), 'not json'); + expect(getAutoInit({ dir })).toBe(false); + }); + + it('preserves unrelated fields already in config.json when writing', () => { + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ somethingElse: 'keep-me' })); + setAutoInit(true, { dir }); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + expect(raw.somethingElse).toBe('keep-me'); + expect(raw.autoInit).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run __tests__/user-config.test.ts` +Expected: FAIL — `../src/installer/user-config` does not exist yet. + +- [ ] **Step 3: Write the implementation** + +Create `src/installer/user-config.ts`: + +```typescript +/** + * Global user-level CodeGraph config: a small JSON file in the user-level + * state dir (~/.codegraph), same home as telemetry.json and beta-signup.json. + * + * Currently one field: + * - `autoInit`: when true, the MCP server initializes (and indexes) any + * project it's asked to query that isn't indexed yet, instead of just + * telling the calling agent to run `codegraph init` (see + * src/mcp/tools.ts's getCodeGraph). Defaults to false — indexing stays + * the user's explicit decision unless they opt in once, here. + * + * A corrupted or unreadable file is treated as "no config yet" (all + * defaults) rather than an error — a bad file must never break a tool call + * or CLI command that merely wants to read this setting. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +interface UserConfigFile { + autoInit?: boolean; + [key: string]: unknown; // preserve fields this module doesn't know about +} + +export interface UserConfigDeps { + /** Global state dir; defaults to ~/.codegraph. Tests inject a temp dir. */ + dir?: string; +} + +function configPath(deps: UserConfigDeps = {}): string { + return path.join(deps.dir ?? path.join(os.homedir(), '.codegraph'), 'config.json'); +} + +function readConfig(deps: UserConfigDeps = {}): UserConfigFile { + try { + return JSON.parse(fs.readFileSync(configPath(deps), 'utf8')) as UserConfigFile; + } catch { + return {}; + } +} + +/** Whether the MCP server should auto-init an unindexed project. Default: false. */ +export function getAutoInit(deps: UserConfigDeps = {}): boolean { + return readConfig(deps).autoInit === true; +} + +/** Persist the auto-init choice. Fail silent — a full disk must not break the CLI. */ +export function setAutoInit(value: boolean, deps: UserConfigDeps = {}): void { + try { + const file = configPath(deps); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const current = readConfig(deps); + const next: UserConfigFile = { ...current, autoInit: value }; + fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n'); + } catch { + /* a full disk must not break the CLI */ + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx vitest run __tests__/user-config.test.ts` +Expected: PASS (all 6 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/installer/user-config.ts __tests__/user-config.test.ts +git commit -m "feat(config): add global autoInit user setting" +``` + +--- + +### Task 4: `codegraph config` CLI command + +**Files:** +- Modify: `src/bin/codegraph.ts` (add new command, near the existing `telemetry` command around line 2383) +- Test: Create `__tests__/cli-config-command.test.ts` + +**Interfaces:** +- Consumes: `getAutoInit`, `setAutoInit` from `../installer/user-config` (Task 3). +- Produces: CLI surface `codegraph config get auto-init` / `codegraph config set auto-init on|off`, mirroring the shape of the existing `codegraph telemetry [action]` command. + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/cli-config-command.test.ts`: + +```typescript +/** + * `codegraph config get|set auto-init` — mirrors the existing + * `codegraph telemetry` command's on/off/status shape (see cli-query-command + * .test.ts for the same execFileSync-against-dist convention). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function run(args: string[], home: string): string { + return execFileSync(process.execPath, [BIN, ...args], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', CODEGRAPH_HOME: home }, + stdio: ['ignore', 'pipe', 'ignore'], + }); +} + +describe('codegraph config auto-init', () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-config-cmd-')); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('defaults to off', () => { + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/off|false/i); + }); + + it('turns on and reports on', () => { + run(['config', 'set', 'auto-init', 'on'], home); + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/on|true/i); + }); + + it('turns back off', () => { + run(['config', 'set', 'auto-init', 'on'], home); + run(['config', 'set', 'auto-init', 'off'], home); + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/off|false/i); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm run build && npx vitest run __tests__/cli-config-command.test.ts` +Expected: FAIL — `config` is not a recognized command, and there's no `CODEGRAPH_HOME` env var honored yet. + +- [ ] **Step 3: Add `CODEGRAPH_HOME` support to `user-config.ts`** + +The test above needs a way to point the CLI's config storage at a temp dir without touching the real `~/.codegraph` (same isolation requirement Task 3's unit tests get via `deps.dir`, but here we're driving the built CLI as a subprocess, so an env var is the only channel in). Update `configPath` in `src/installer/user-config.ts`: + +```typescript +function configPath(deps: UserConfigDeps = {}): string { + const home = deps.dir ?? process.env.CODEGRAPH_HOME ?? path.join(os.homedir(), '.codegraph'); + return path.join(home, 'config.json'); +} +``` + +(Explicit `deps.dir` still wins, preserving Task 3's unit tests unchanged; `CODEGRAPH_HOME` is the new subprocess-testable override.) + +- [ ] **Step 4: Add the `config` command** + +In `src/bin/codegraph.ts`, add near the `telemetry` command (after its closing `});` around line 2421): + +```typescript +/** + * codegraph config get|set auto-init + */ +program + .command('config [key] [value]') + .description('Get or set CodeGraph settings (currently: auto-init)') + .action(async (action: string, key?: string, value?: string) => { + const { getAutoInit, setAutoInit } = await import('../installer/user-config'); + + if (key !== 'auto-init') { + error(`Unknown setting: ${key ?? '(none)'} (expected auto-init)`); + process.exit(1); + } + + if (action === 'get') { + console.log(getAutoInit() ? 'on' : 'off'); + return; + } + + if (action === 'set') { + if (value !== 'on' && value !== 'off') { + error(`Expected "on" or "off", got: ${value ?? '(none)'}`); + process.exit(1); + } + setAutoInit(value === 'on'); + success( + value === 'on' + ? 'Auto-init enabled — the MCP server will index a new project the first time it\'s opened, instead of asking you to run `codegraph init`.' + : 'Auto-init disabled — the MCP server will go back to asking you to run `codegraph init` for a new project.', + ); + return; + } + + error(`Unknown action: ${action} (expected get or set)`); + process.exit(1); + }); +``` + +This uses the same `error`/`success` console helpers the `telemetry` command already uses elsewhere in this file — no new imports needed. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `npm run build && npx vitest run __tests__/cli-config-command.test.ts` +Expected: PASS (all 3 tests) + +- [ ] **Step 6: Re-run Task 3's unit tests to confirm the `CODEGRAPH_HOME` change didn't break explicit `dir` injection** + +Run: `npx vitest run __tests__/user-config.test.ts` +Expected: PASS — unchanged, since `deps.dir` is checked first. + +- [ ] **Step 7: Commit** + +```bash +git add src/bin/codegraph.ts src/installer/user-config.ts __tests__/cli-config-command.test.ts +git commit -m "feat(cli): add codegraph config get/set auto-init" +``` + +--- + +### Task 5: Wire auto-init into the MCP server + +**Files:** +- Modify: `src/mcp/tools.ts:9` (import), `src/mcp/tools.ts:1512-1587` (`getCodeGraph`), and all 10 call sites: lines `1693, 1808, 2112, 2192, 2265, 2335, 3210, 5861, 6249, 6373` +- Test: Create `__tests__/mcp-auto-init.test.ts` + +**Interfaces:** +- Consumes: `getAutoInit` from `../installer/user-config` (Task 3). +- Produces: `getCodeGraph` becomes `private async getCodeGraph(projectPath?: string): Promise` (was synchronous). A new test seam `__setAutoInitDirForTests(dir: string | null): void`, matching the file's existing `__setLoadCodeGraphForTests` pattern (lines 22-24), lets tests point `getAutoInit` at a temp dir instead of the real `~/.codegraph`. + +- [ ] **Step 1: Write the failing test** + +Create `__tests__/mcp-auto-init.test.ts`: + +```typescript +/** + * Opt-in global auto-init (issue: codegraph indexing coverage — see + * docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md). + * + * When `autoInit` is on (src/installer/user-config.ts), the MCP server + * indexes an unindexed project on first query instead of just telling the + * agent to run `codegraph init`. Default (off) behavior is unchanged. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ToolHandler, __setAutoInitDirForTests } from '../src/mcp/tools'; +import { setAutoInit } from '../src/installer/user-config'; + +function makeUnindexedRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-auto-init-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src/main.ts'), 'export function main(){ return 1; }\n'); + return dir; +} + +describe('MCP auto-init (opt-in)', () => { + let repo: string; + let configDir: string; + + beforeEach(() => { + repo = makeUnindexedRepo(); + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-auto-init-config-')); + __setAutoInitDirForTests(configDir); + }); + + afterEach(() => { + __setAutoInitDirForTests(null); + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + it('leaves default (off) behavior unchanged: still asks the user to run codegraph init', async () => { + const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'main', projectPath: repo }); + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/codegraph init/); + }); + + it('indexes the project automatically when autoInit is on', async () => { + setAutoInit(true, { dir: configDir }); + + const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'main', projectPath: repo }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).not.toMatch(/codegraph init/); + expect(fs.existsSync(path.join(repo, '.codegraph'))).toBe(true); + }); + + it('still refuses to auto-init an unsafe path (home directory) even when autoInit is on', async () => { + setAutoInit(true, { dir: configDir }); + + const res = await new ToolHandler(null).execute('codegraph_explore', { query: 'main', projectPath: os.homedir() }); + + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(fs.existsSync(path.join(os.homedir(), '.codegraph'))).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run __tests__/mcp-auto-init.test.ts` +Expected: FAIL — `__setAutoInitDirForTests` doesn't exist yet, and auto-init isn't wired in, so the second test's assertion that `.codegraph` gets created fails. + +- [ ] **Step 3: Add the import and test seam** + +In `src/mcp/tools.ts`, update the import at line 9: + +```typescript +import { findNearestCodeGraphRoot, unsafeIndexRootReason } from '../directory'; +``` + +and add, directly after the existing `__setLoadCodeGraphForTests` block (after line 25): + +```typescript +import { getAutoInit } from '../installer/user-config'; + +// Test seam (same pattern as __setLoadCodeGraphForTests above): points +// getAutoInit at a temp config dir instead of the real ~/.codegraph. +// Never set outside tests. +let autoInitDirForTests: string | null = null; +export function __setAutoInitDirForTests(dir: string | null): void { + autoInitDirForTests = dir; +} +``` + +- [ ] **Step 4: Make `getCodeGraph` async and add the auto-init branch** + +Replace the method at lines 1512-1587. The signature changes from `private getCodeGraph(projectPath?: string): CodeGraph` to `private async getCodeGraph(projectPath?: string): Promise`, and the `!resolvedRoot` branch (originally just `throw new NotIndexedError(...)`) gains an auto-init attempt first: + +```typescript + private async getCodeGraph(projectPath?: string): Promise { + if (!projectPath) { + if (!this.cg) { + const searched = this.defaultProjectHint ?? process.cwd(); + throw new NotIndexedError( + 'No CodeGraph project is loaded for this session.\n' + + `Searched for a .codegraph/ directory starting from: ${searched}\n` + + 'Either the server root has no index of its own (e.g. a monorepo where only ' + + "sub-projects are indexed), or the MCP client launched the server outside your " + + 'project without reporting the workspace root. Either way, target the project ' + + 'explicitly:\n' + + ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' + + '(any project that has a .codegraph/ — including a sub-project of a monorepo)\n' + + ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' + + 'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' + + "project (the user can run 'codegraph init' there to enable it) — you can still query " + + 'other indexed projects by projectPath in the same session.' + ); + } + return this.freshen(this.cg); + } + + if (existsSync(projectPath)) { + const pathError = validateProjectPath(projectPath); + if (pathError) { + throw new PathRefusalError(pathError); + } + } + + const resolvedRoot = findNearestCodeGraphRoot(projectPath); + + if (!resolvedRoot) { + if (getAutoInit(autoInitDirForTests ? { dir: autoInitDirForTests } : {})) { + const unsafe = unsafeIndexRootReason(projectPath); + if (!unsafe) { + try { + const CodeGraphClass = loadCodeGraph(); + const cg = await CodeGraphClass.init(projectPath, { index: false }); + await cg.indexAll(); + this.projectCache.set(cg.getProjectRoot(), cg); + return this.freshen(cg); + } catch { + // Auto-init must never turn a query failure into a worse, unexplained + // one — fall through to the standard NotIndexedError below. + } + } + } + throw new NotIndexedError( + `The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` + + 'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' + + "for that codebase instead, and don't call codegraph for it again this session. " + + "Indexing is the user's decision — they can run 'codegraph init' in that project to enable it." + ); + } + + if (this.cg && this.cg.getProjectRoot() === resolvedRoot) { + return this.freshen(this.cg); + } + + const cached = this.projectCache.get(resolvedRoot); + if (cached) return this.freshen(cached); + + const cg = loadCodeGraph().openSync(resolvedRoot); + this.projectCache.set(resolvedRoot, cg); + return cg; + } +``` + +- [ ] **Step 5: Add `await` at all 10 call sites** + +For each of these lines in `src/mcp/tools.ts`, change `this.getCodeGraph(` to `await this.getCodeGraph(`: `1693, 1808, 2112, 2192, 2265, 2335, 3210, 5861, 6249, 6373`. (Line numbers shift slightly after Step 4's edit since the method body is unchanged in line count — insertions are inside the method, not before it — but confirm each call site with `grep -n "this.getCodeGraph(" src/mcp/tools.ts` before editing, since Step 3 added ~8 lines above this method.) + +- [ ] **Step 6: Typecheck to confirm no missed call site** + +Run: `npx tsc --noEmit` +Expected: PASS. If any call site was missed, TypeScript reports "Property 'getProjectRoot' does not exist on type 'Promise'" (or similar) at that exact line — fix by adding `await` there too, then re-run. + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `npx vitest run __tests__/mcp-auto-init.test.ts` +Expected: PASS (all 3 tests) + +- [ ] **Step 8: Run the full MCP test suite to confirm no regression** + +Run: `npx vitest run -t mcp` +Expected: PASS — every existing `mcp-*.test.ts` file (catch-up gate, roots, staleness banner, require-project-path, etc.) still passes with `getCodeGraph` now async. + +- [ ] **Step 9: Run the complete test suite** + +Run: `npm run build && npm test` +Expected: PASS — full suite green, including Tasks 1-4's tests. + +- [ ] **Step 10: Commit** + +```bash +git add src/mcp/tools.ts __tests__/mcp-auto-init.test.ts +git commit -m "feat(mcp): auto-init unindexed projects when autoInit is on" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Component 1 (batch init) → Task 1. Component 2 (git-hooks flag) → Task 2. Component 3 (auto-init config + MCP wiring) → Tasks 3-5. Error handling requirements (batch continues past failures, auto-init inherits safety refusal) are covered in Task 1 Step 3 and Task 5 Step 4 respectively. Testing requirements from the spec are covered by the dedicated test file in each task. +- **Type consistency:** `InitOutcome`/`initOneProject` (Task 1) is reused unchanged by Task 2. `UserConfigDeps`/`getAutoInit`/`setAutoInit` (Task 3) signatures are reused identically by Task 4 (CLI) and Task 5 (MCP), including the `{ dir?: string }` shape throughout. +- **No placeholders:** every step above contains complete, real code — no TBDs. From 5f28465f78106dce26ff3ebb04a0dd7249bf4f45 Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:22:30 -0500 Subject: [PATCH 03/10] feat(init): add --all for batch multi-repo indexing --- __tests__/cli-init-batch.test.ts | 84 +++++++++++++++ src/bin/codegraph.ts | 175 +++++++++++++++++++------------ 2 files changed, 193 insertions(+), 66 deletions(-) create mode 100644 __tests__/cli-init-batch.test.ts diff --git a/__tests__/cli-init-batch.test.ts b/__tests__/cli-init-batch.test.ts new file mode 100644 index 000000000..28b16b529 --- /dev/null +++ b/__tests__/cli-init-batch.test.ts @@ -0,0 +1,84 @@ +/** + * `codegraph init --all ` (batch indexing across many repos). + * + * Exercised end-to-end against the built binary, matching the convention in + * cli-query-command.test.ts. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function initAll(dirs: string[]): { stdout: string; status: number } { + try { + const stdout = execFileSync(process.execPath, [BIN, 'init', '--all', ...dirs], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return { stdout, status: 0 }; + } catch (err) { + const e = err as { stdout?: Buffer; status?: number }; + return { stdout: e.stdout?.toString('utf-8') ?? '', status: e.status ?? 1 }; + } +} + +function makeRepo(root: string, name: string): string { + const dir = path.join(root, name); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'src/main.ts'), 'export function main(){ return 1; }\n'); + return dir; +} + +describe('codegraph init --all', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-init-batch-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('indexes every directory listed and reports a summary line per repo', () => { + const repoA = makeRepo(root, 'repo-a'); + const repoB = makeRepo(root, 'repo-b'); + + const { stdout, status } = initAll([repoA, repoB]); + + expect(status).toBe(0); + expect(stdout).toContain(repoA); + expect(stdout).toContain(repoB); + expect(fs.existsSync(path.join(repoA, '.codegraph'))).toBe(true); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); + }); + + it('continues past an already-initialized directory instead of stopping the batch', () => { + const repoA = makeRepo(root, 'repo-a'); + const repoB = makeRepo(root, 'repo-b'); + execFileSync(process.execPath, [BIN, 'init', repoA], { + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: 'ignore', + }); + + const { stdout, status } = initAll([repoA, repoB]); + + expect(status).toBe(0); + expect(stdout).toContain('already'); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); + }); + + it('reports a refusal for an unsafe directory without aborting the rest of the batch', () => { + const repoB = makeRepo(root, 'repo-b'); + + const { stdout, status } = initAll([os.homedir(), repoB]); + + expect(status).toBe(1); // batch exit code reflects the refusal + expect(stdout).toContain('refused'); + expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); // but repo-b still got indexed + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 067cdd3e0..70adfa222 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -592,88 +592,131 @@ async function recordIndexTelemetry( /** * codegraph init [path] */ -program - .command('init [path]') - .description('Initialize CodeGraph in a project directory and build the initial index') - .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') - .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') - .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') - .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean }) => { - const projectPath = path.resolve(pathArg || process.cwd()); - const clack = await importESM('@clack/prompts'); +interface InitOutcome { + projectPath: string; + status: 'indexed' | 'already-initialized' | 'refused' | 'error'; + detail: string; +} - clack.intro('Initializing CodeGraph'); +async function initOneProject( + projectPath: string, + options: { force?: boolean; verbose?: boolean }, + clack: Awaited>, + mode: 'single' | 'batch', +): Promise { + // Refuse to index your home directory / a filesystem root — it pulls in + // caches, other projects, and your whole tree (a multi-GB index + watcher + // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). + const unsafe = unsafeIndexRootReason(projectPath); + if (unsafe && !options.force) { + if (mode === 'single') { + clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); + clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); + } + return { projectPath, status: 'refused', detail: `looks like ${unsafe}` }; + } + if (isInitialized(projectPath)) { + if (mode === 'single') { + clack.log.warn(`Already initialized in ${projectPath}`); + clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); + } try { - // Refuse to index your home directory / a filesystem root — it pulls in - // caches, other projects, and your whole tree (a multi-GB index + watcher - // churn, and on pre-1.0 macOS a machine-crashing fd blowup, #845). - const unsafe = unsafeIndexRootReason(projectPath); - if (unsafe && !options.force) { - clack.log.error(`Refusing to initialize in ${projectPath} — it looks like ${unsafe}.`); - clack.log.info('Run this inside a specific project directory, or pass --force if you really mean to index everything under it.'); - clack.outro(''); - process.exitCode = 1; - return; - } - - if (isInitialized(projectPath)) { - clack.log.warn(`Already initialized in ${projectPath}`); - clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update'); - try { - const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath); - } catch { /* non-fatal */ } - clack.outro(''); - return; - } + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + } catch { /* non-fatal */ } + return { projectPath, status: 'already-initialized', detail: 'already initialized' }; + } - const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); - const cg = await CodeGraph.init(projectPath, { index: false }); - clack.log.success(`Initialized in ${projectPath}`); + try { + const { default: CodeGraph, getDatabasePath } = await loadCodeGraph(); + const cg = await CodeGraph.init(projectPath, { index: false }); + if (mode === 'single') clack.log.success(`Initialized in ${projectPath}`); - // Indexing runs by default now. The legacy -i/--index flag is still - // 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). - // The DB + WAL paths let the liveness watchdog tell a slow store on - // degraded storage from a true wedge (#1231). - // 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 dbPath = getDatabasePath(projectPath); - const runIndex = async (): Promise => { - const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); - try { - if (options.verbose) { - return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); - } + const dbPath = getDatabasePath(projectPath); + const runIndex = async (): Promise => { + const supervision = installCommandSupervision('init', { progressPaths: [dbPath, `${dbPath}-wal`] }); + try { + if (mode === 'single' && options.verbose) { + return await cg.indexAll({ onProgress: createVerboseProgress(), verbose: true }); + } + if (mode === 'single') { process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`); const progress = createShimmerProgress(); const r = await cg.indexAll({ onProgress: progress.onProgress }); await progress.stop(); return r; - } 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) { + // Batch mode: no per-file progress UI — N repos would mean N progress + // renders. A one-line summary per repo prints after the loop instead. + return await cg.indexAll(); + } finally { + supervision.stop(); + } + }; + const result = await runIndex(); + if (mode === 'single') printIndexResult(clack, result, projectPath); + await recordIndexTelemetry(cg, result); + + if (result.nodesCreated === 0) { + if (mode === 'single') { await offerIndexIgnoredRepos(clack, projectPath, runIndex, { interactive: true }); + } else { + clack.log.warn(`${projectPath}: indexed 0 nodes — .gitignore may be excluding the code (run "codegraph init" there directly for the interactive fix).`); } + } - try { - const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath); - } catch { /* non-fatal */ } + try { + const { offerWatchFallback } = await import('../installer'); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + } catch { /* non-fatal */ } + + cg.destroy(); + return { projectPath, status: 'indexed', detail: `${result.nodesCreated} nodes` }; + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + if (mode === 'single') clack.log.error(`Failed: ${detail}`); + return { projectPath, status: 'error', detail }; + } +} + +program + .command('init [path]') + .description('Initialize CodeGraph in a project directory and build the initial index') + .option('-i, --index', 'Deprecated: indexing now runs by default; flag accepted for backward compatibility') + .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') + .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') + .option('--all ', 'Initialize every directory listed, one after another, and print a summary table') + .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; all?: string[] }) => { + const clack = await importESM('@clack/prompts'); + if (options.all && options.all.length > 0) { + clack.intro(`Initializing CodeGraph in ${options.all.length} project${options.all.length > 1 ? 's' : ''}`); + const outcomes: InitOutcome[] = []; + for (const dir of options.all) { + outcomes.push(await initOneProject(path.resolve(dir), options, clack, 'batch')); + } + for (const o of outcomes) { + const line = `${o.projectPath} — ${o.status} (${o.detail})`; + if (o.status === 'error' || o.status === 'refused') clack.log.warn(line); + else clack.log.success(line); + } + clack.outro('Done'); + if (outcomes.some((o) => o.status === 'error' || o.status === 'refused')) { + process.exitCode = 1; + } + return; + } + + const projectPath = path.resolve(pathArg || process.cwd()); + clack.intro('Initializing CodeGraph'); + try { + const result = await initOneProject(projectPath, options, clack, 'single'); + if (result.status === 'error') { + clack.outro(''); + process.exit(1); + } clack.outro('Done'); - cg.destroy(); } catch (err) { clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); From 38af4df16be6555afd452c25098c0038b86d811b Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:38:40 -0500 Subject: [PATCH 04/10] fix(init): honor refused/already-initialized status in single-path mode, add regression test --- __tests__/cli-init-batch.test.ts | 23 +++++++++++++++++++++++ src/bin/codegraph.ts | 8 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/__tests__/cli-init-batch.test.ts b/__tests__/cli-init-batch.test.ts index 28b16b529..46cf8eb64 100644 --- a/__tests__/cli-init-batch.test.ts +++ b/__tests__/cli-init-batch.test.ts @@ -26,6 +26,20 @@ function initAll(dirs: string[]): { stdout: string; status: number } { } } +function initSingle(dir: string): { stdout: string; status: number } { + try { + const stdout = execFileSync(process.execPath, [BIN, 'init', dir], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'ignore'], + }); + return { stdout, status: 0 }; + } catch (err) { + const e = err as { stdout?: Buffer; status?: number }; + return { stdout: e.stdout?.toString('utf-8') ?? '', status: e.status ?? 1 }; + } +} + function makeRepo(root: string, name: string): string { const dir = path.join(root, name); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); @@ -82,3 +96,12 @@ describe('codegraph init --all', () => { expect(fs.existsSync(path.join(repoB, '.codegraph'))).toBe(true); // but repo-b still got indexed }); }); + +describe('codegraph init (single-path regression)', () => { + it('refuses to index home directory and exits with code 1', () => { + const { stdout, status } = initSingle(os.homedir()); + + expect(status).toBe(1); + expect(stdout).toContain('Refusing'); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 70adfa222..c8112102b 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -712,11 +712,15 @@ program clack.intro('Initializing CodeGraph'); try { const result = await initOneProject(projectPath, options, clack, 'single'); - if (result.status === 'error') { + if (result.status === 'error' || result.status === 'refused') { clack.outro(''); process.exit(1); } - clack.outro('Done'); + if (result.status === 'already-initialized') { + clack.outro(''); + } else { + clack.outro('Done'); + } } catch (err) { clack.log.error(`Failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); From a373d69d288ddc7ffcfd0625df7cd3779f565a97 Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:43:25 -0500 Subject: [PATCH 05/10] feat(init): add --git-hooks to force-enable freshness hooks Co-Authored-By: Claude Sonnet 5 --- __tests__/watch-fallback.test.ts | 57 ++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 9 ++--- src/installer/index.ts | 12 ++++--- 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 __tests__/watch-fallback.test.ts diff --git a/__tests__/watch-fallback.test.ts b/__tests__/watch-fallback.test.ts new file mode 100644 index 000000000..1ed8df383 --- /dev/null +++ b/__tests__/watch-fallback.test.ts @@ -0,0 +1,57 @@ +/** + * offerWatchFallback's `force` option (generalizes git-hooks freshness + * beyond the WSL2/CODEGRAPH_NO_WATCH-only case — see `--git-hooks` on + * `codegraph init`). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { offerWatchFallback } from '../src/installer'; +import { isSyncHookInstalled } from '../src/sync/git-hooks'; + +function gitInit(dir: string): void { + execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' }); +} + +function fakeClack() { + return { + log: { warn: () => {}, info: () => {}, success: () => {}, error: () => {} }, + select: async () => 'hook' as const, + isCancel: () => false, + } as unknown as typeof import('@clack/prompts'); +} + +describe('offerWatchFallback force option', () => { + let repo: string; + + beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watchfallback-')); + gitInit(repo); + }); + + afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); + }); + + it('does nothing when the watcher is enabled and force is not set', async () => { + await offerWatchFallback(fakeClack(), repo, { yes: true }); + expect(isSyncHookInstalled(repo)).toBe(false); + }); + + it('installs git sync hooks when force is set, even though the watcher is enabled', async () => { + await offerWatchFallback(fakeClack(), repo, { yes: true, force: true }); + expect(isSyncHookInstalled(repo)).toBe(true); + }); + + it('is a no-op on a non-git directory even when forced', async () => { + const nonGitRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-watchfallback-nogit-')); + try { + await offerWatchFallback(fakeClack(), nonGitRepo, { yes: true, force: true }); + expect(isSyncHookInstalled(nonGitRepo)).toBe(false); + } finally { + fs.rmSync(nonGitRepo, { recursive: true, force: true }); + } + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index c8112102b..ef82b65ef 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -600,7 +600,7 @@ interface InitOutcome { async function initOneProject( projectPath: string, - options: { force?: boolean; verbose?: boolean }, + options: { force?: boolean; verbose?: boolean; gitHooks?: boolean }, clack: Awaited>, mode: 'single' | 'batch', ): Promise { @@ -623,7 +623,7 @@ async function initOneProject( } try { const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch', force: options.gitHooks }); } catch { /* non-fatal */ } return { projectPath, status: 'already-initialized', detail: 'already initialized' }; } @@ -668,7 +668,7 @@ async function initOneProject( try { const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' }); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch', force: options.gitHooks }); } catch { /* non-fatal */ } cg.destroy(); @@ -687,7 +687,8 @@ program .option('-f, --force', 'Initialize even if the path looks like your home directory or a filesystem root') .option('-v, --verbose', 'Show detailed worker lifecycle and memory info') .option('--all ', 'Initialize every directory listed, one after another, and print a summary table') - .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; all?: string[] }) => { + .option('--git-hooks', 'Install git sync hooks (commit/pull/checkout) to keep the index fresh even when no CodeGraph session is open') + .action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; all?: string[]; gitHooks?: boolean }) => { const clack = await importESM('@clack/prompts'); if (options.all && options.all.length > 0) { diff --git a/src/installer/index.ts b/src/installer/index.ts index edeb4ac94..86dfa0227 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -661,13 +661,17 @@ async function resolveTargets( export async function offerWatchFallback( clack: typeof import('@clack/prompts'), projectPath: string, - opts: { yes?: boolean } = {}, + opts: { yes?: boolean; force?: boolean } = {}, ): Promise { const reason = watchDisabledReason(projectPath); - if (!reason) return; // Watcher runs normally — nothing to set up. + if (!reason && !opts.force) return; // Watcher runs normally and hooks weren't explicitly requested. - clack.log.warn(`Live file watching is disabled here — ${reason}.`); - clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.'); + if (reason) { + clack.log.warn(`Live file watching is disabled here — ${reason}.`); + clack.log.info('Until you re-sync, the CodeGraph index stays frozen — it will not pick up edits on its own.'); + } else { + clack.log.info('Setting up git sync hooks as a freshness backstop for when no CodeGraph session is open.'); + } // No git repo → the commit-hook path doesn't apply; point at manual sync. if (!isGitRepo(projectPath)) { From 7fd5025faa798c4358c210b8e5a9febc61ca9e8c Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:46:56 -0500 Subject: [PATCH 06/10] feat(config): add global autoInit user setting --- __tests__/user-config.test.ts | 60 +++++++++++++++++++++++++++++++++++ src/installer/user-config.ts | 58 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 __tests__/user-config.test.ts create mode 100644 src/installer/user-config.ts diff --git a/__tests__/user-config.test.ts b/__tests__/user-config.test.ts new file mode 100644 index 000000000..c8e1d81e7 --- /dev/null +++ b/__tests__/user-config.test.ts @@ -0,0 +1,60 @@ +/** + * Global user-level config (`~/.codegraph/config.json`) — currently one + * field, `autoInit`. Modeled directly on the beta-signup choice file + * (src/installer/beta-signup.ts): same state dir, same fail-silent / + * corrupted-file-means-default behavior. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { getAutoInit, setAutoInit } from '../src/installer/user-config'; + +describe('global auto-init config', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-user-config-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('defaults to false on a fresh machine', () => { + expect(getAutoInit({ dir })).toBe(false); + }); + + it('persists true after setAutoInit(true)', () => { + setAutoInit(true, { dir }); + expect(getAutoInit({ dir })).toBe(true); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + expect(raw.autoInit).toBe(true); + }); + + it('persists false after setAutoInit(false)', () => { + setAutoInit(true, { dir }); + setAutoInit(false, { dir }); + expect(getAutoInit({ dir })).toBe(false); + }); + + it('creates the state dir when missing', () => { + const nested = path.join(dir, 'not', 'yet', 'there'); + setAutoInit(true, { dir: nested }); + expect(getAutoInit({ dir: nested })).toBe(true); + }); + + it('treats a corrupted config file as the default (false), never throws', () => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), 'not json'); + expect(getAutoInit({ dir })).toBe(false); + }); + + it('preserves unrelated fields already in config.json when writing', () => { + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ somethingElse: 'keep-me' })); + setAutoInit(true, { dir }); + const raw = JSON.parse(fs.readFileSync(path.join(dir, 'config.json'), 'utf8')); + expect(raw.somethingElse).toBe('keep-me'); + expect(raw.autoInit).toBe(true); + }); +}); diff --git a/src/installer/user-config.ts b/src/installer/user-config.ts new file mode 100644 index 000000000..50e68c9ca --- /dev/null +++ b/src/installer/user-config.ts @@ -0,0 +1,58 @@ +/** + * Global user-level CodeGraph config: a small JSON file in the user-level + * state dir (~/.codegraph), same home as telemetry.json and beta-signup.json. + * + * Currently one field: + * - `autoInit`: when true, the MCP server initializes (and indexes) any + * project it's asked to query that isn't indexed yet, instead of just + * telling the calling agent to run `codegraph init` (see + * src/mcp/tools.ts's getCodeGraph). Defaults to false — indexing stays + * the user's explicit decision unless they opt in once, here. + * + * A corrupted or unreadable file is treated as "no config yet" (all + * defaults) rather than an error — a bad file must never break a tool call + * or CLI command that merely wants to read this setting. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +interface UserConfigFile { + autoInit?: boolean; + [key: string]: unknown; // preserve fields this module doesn't know about +} + +export interface UserConfigDeps { + /** Global state dir; defaults to ~/.codegraph. Tests inject a temp dir. */ + dir?: string; +} + +function configPath(deps: UserConfigDeps = {}): string { + return path.join(deps.dir ?? path.join(os.homedir(), '.codegraph'), 'config.json'); +} + +function readConfig(deps: UserConfigDeps = {}): UserConfigFile { + try { + return JSON.parse(fs.readFileSync(configPath(deps), 'utf8')) as UserConfigFile; + } catch { + return {}; + } +} + +/** Whether the MCP server should auto-init an unindexed project. Default: false. */ +export function getAutoInit(deps: UserConfigDeps = {}): boolean { + return readConfig(deps).autoInit === true; +} + +/** Persist the auto-init choice. Fail silent — a full disk must not break the CLI. */ +export function setAutoInit(value: boolean, deps: UserConfigDeps = {}): void { + try { + const file = configPath(deps); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const current = readConfig(deps); + const next: UserConfigFile = { ...current, autoInit: value }; + fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n'); + } catch { + /* a full disk must not break the CLI */ + } +} From 47b0df0e2f34369782b7408dfab74a40fb934cba Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 18:50:25 -0500 Subject: [PATCH 07/10] feat(cli): add codegraph config get/set auto-init --- __tests__/cli-config-command.test.ts | 50 ++++++++++++++++++++++++++++ src/bin/codegraph.ts | 37 ++++++++++++++++++++ src/installer/user-config.ts | 3 +- 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 __tests__/cli-config-command.test.ts diff --git a/__tests__/cli-config-command.test.ts b/__tests__/cli-config-command.test.ts new file mode 100644 index 000000000..ae07c7790 --- /dev/null +++ b/__tests__/cli-config-command.test.ts @@ -0,0 +1,50 @@ +/** + * `codegraph config get|set auto-init` — mirrors the existing + * `codegraph telemetry` command's on/off/status shape (see cli-query-command + * .test.ts for the same execFileSync-against-dist convention). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +function run(args: string[], home: string): string { + return execFileSync(process.execPath, [BIN, ...args], { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', CODEGRAPH_HOME: home }, + stdio: ['ignore', 'pipe', 'ignore'], + }); +} + +describe('codegraph config auto-init', () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-config-cmd-')); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it('defaults to off', () => { + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/off|false/i); + }); + + it('turns on and reports on', () => { + run(['config', 'set', 'auto-init', 'on'], home); + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/on|true/i); + }); + + it('turns back off', () => { + run(['config', 'set', 'auto-init', 'on'], home); + run(['config', 'set', 'auto-init', 'off'], home); + const out = run(['config', 'get', 'auto-init'], home); + expect(out).toMatch(/off|false/i); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index ef82b65ef..044924dcd 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2468,6 +2468,43 @@ program console.log(chalk.dim(`\nExactly what is collected (and never collected): ${TELEMETRY_DOCS}\n`)); }); +/** + * codegraph config get|set auto-init + */ +program + .command('config [key] [value]') + .description('Get or set CodeGraph settings (currently: auto-init)') + .action(async (action: string, key?: string, value?: string) => { + const { getAutoInit, setAutoInit } = await import('../installer/user-config'); + + if (key !== 'auto-init') { + error(`Unknown setting: ${key ?? '(none)'} (expected auto-init)`); + process.exit(1); + } + + if (action === 'get') { + console.log(getAutoInit() ? 'on' : 'off'); + return; + } + + if (action === 'set') { + if (value !== 'on' && value !== 'off') { + error(`Expected "on" or "off", got: ${value ?? '(none)'}`); + process.exit(1); + } + setAutoInit(value === 'on'); + success( + value === 'on' + ? 'Auto-init enabled — the MCP server will index a new project the first time it\'s opened, instead of asking you to run `codegraph init`.' + : 'Auto-init disabled — the MCP server will go back to asking you to run `codegraph init` for a new project.', + ); + return; + } + + error(`Unknown action: ${action} (expected get or set)`); + process.exit(1); + }); + /** * codegraph upgrade [version] * diff --git a/src/installer/user-config.ts b/src/installer/user-config.ts index 50e68c9ca..4f51c4c46 100644 --- a/src/installer/user-config.ts +++ b/src/installer/user-config.ts @@ -28,7 +28,8 @@ export interface UserConfigDeps { } function configPath(deps: UserConfigDeps = {}): string { - return path.join(deps.dir ?? path.join(os.homedir(), '.codegraph'), 'config.json'); + const home = deps.dir ?? process.env.CODEGRAPH_HOME ?? path.join(os.homedir(), '.codegraph'); + return path.join(home, 'config.json'); } function readConfig(deps: UserConfigDeps = {}): UserConfigFile { From c2870670906906de60934071a8c2afcc60b53840 Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 19:08:55 -0500 Subject: [PATCH 08/10] feat(mcp): auto-init unindexed projects when autoInit is on Wire the opt-in global autoInit setting (Task 3's getAutoInit) into ToolHandler.getCodeGraph: when a project has no .codegraph/ and autoInit is on, index it automatically instead of just telling the agent to run `codegraph init`. Unsafe roots (home directory etc.) are still refused. getCodeGraph becomes async, which ripples to its private callers worktreeMismatchFor/withWorktreeNotice/withStalenessNotice (also made async) and all call sites (10 direct + 3 propagated). Fixes a test in concurrent-locking.test.ts that called the now-async getCodeGraph synchronously via an `as any` cast (missed by tsc since the cast opts out of type checking there). --- __tests__/concurrent-locking.test.ts | 6 +- __tests__/mcp-auto-init.test.ts | 82 ++++++++++++++++++++++++++++ src/mcp/tools.ts | 62 ++++++++++++++------- 3 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 __tests__/mcp-auto-init.test.ts diff --git a/__tests__/concurrent-locking.test.ts b/__tests__/concurrent-locking.test.ts index 5c8ab518d..abde38a04 100644 --- a/__tests__/concurrent-locking.test.ts +++ b/__tests__/concurrent-locking.test.ts @@ -112,13 +112,13 @@ describe('issue #238 — ToolHandler reuses the default instance (#2)', () => { fs.rmSync(dir, { recursive: true, force: true }); }); - it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', () => { + it('getCodeGraph(defaultRoot) returns the default instance, not a new connection', async () => { const openSpy = vi.spyOn(CodeGraph, 'openSync'); try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resolved = (handler as any).getCodeGraph(root); + const resolved = await (handler as any).getCodeGraph(root); // eslint-disable-next-line @typescript-eslint/no-explicit-any - const nested = (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); + const nested = await (handler as any).getCodeGraph(path.join(root, 'does', 'not', 'exist')); expect(resolved).toBe(cg); expect(nested).toBe(cg); // a sub-path resolves up to the same default project expect(openSpy).not.toHaveBeenCalled(); // no second connection opened diff --git a/__tests__/mcp-auto-init.test.ts b/__tests__/mcp-auto-init.test.ts new file mode 100644 index 000000000..4cc58d1d0 --- /dev/null +++ b/__tests__/mcp-auto-init.test.ts @@ -0,0 +1,82 @@ +/** + * Opt-in global auto-init (issue: codegraph indexing coverage — see + * docs/superpowers/specs/2026-08-18-indexing-coverage-and-freshness-design.md). + * + * When `autoInit` is on (src/installer/user-config.ts), the MCP server + * indexes an unindexed project on first query instead of just telling the + * agent to run `codegraph init`. Default (off) behavior is unchanged. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler, __setAutoInitDirForTests, __setLoadCodeGraphForTests } from '../src/mcp/tools'; +import { setAutoInit } from '../src/installer/user-config'; + +function makeUnindexedRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-auto-init-')); + fs.mkdirSync(path.join(dir, 'src')); + fs.writeFileSync(path.join(dir, 'src/main.ts'), 'export function main(){ return 1; }\n'); + return dir; +} + +describe('MCP auto-init (opt-in)', () => { + let repo: string; + let configDir: string; + let handler: ToolHandler; + + beforeEach(() => { + repo = makeUnindexedRepo(); + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-auto-init-config-')); + __setAutoInitDirForTests(configDir); + // Services ToolHandler's lazy cross-project require('../index'), which + // vitest's module transform can't resolve in-process — same seam used by + // mcp-stale-slice.test.ts for the same reason. + __setLoadCodeGraphForTests(CodeGraph); + handler = new ToolHandler(null); + }); + + afterEach(() => { + __setAutoInitDirForTests(null); + __setLoadCodeGraphForTests(null); + // Close the auto-inited project's DB connection before removing its + // directory — an open SQLite handle holds the directory locked on + // Windows and rmSync fails EPERM otherwise (same class of issue the + // mcp-unindexed/mcp-stale-slice suites document). + try { handler.closeAll(); } catch { /* ignore */ } + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + it('leaves default (off) behavior unchanged: still asks the user to run codegraph init', async () => { + const res = await handler.execute('codegraph_explore', { query: 'main', projectPath: repo }); + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).toMatch(/codegraph init/); + }); + + it('indexes the project automatically when autoInit is on', async () => { + setAutoInit(true, { dir: configDir }); + + const res = await handler.execute('codegraph_explore', { query: 'main', projectPath: repo }); + + expect(res.isError).toBeUndefined(); + expect(res.content[0]!.text).not.toMatch(/codegraph init/); + expect(fs.existsSync(path.join(repo, '.codegraph'))).toBe(true); + }); + + it('still refuses to auto-init an unsafe path (home directory) even when autoInit is on', async () => { + setAutoInit(true, { dir: configDir }); + + const res = await handler.execute('codegraph_explore', { query: 'main', projectPath: os.homedir() }); + + expect(res.content[0]!.text).toMatch(/codegraph init/); + // NOT existsSync(homedir/.codegraph) alone: that directory already exists + // on any machine that has used the codegraph CLI (it also holds + // telemetry.json / config.json — global state unrelated to project + // indexing). The precise signal that a PROJECT index was created there + // is codegraph.db, the same marker `isInitialized` (src/directory.ts) + // checks. + expect(fs.existsSync(path.join(os.homedir(), '.codegraph', 'codegraph.db'))).toBe(false); + }); +}); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index d1d013514..a178e558d 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -6,7 +6,7 @@ import type CodeGraph from '../index'; import type { QueryPool } from './query-pool'; -import { findNearestCodeGraphRoot } from '../directory'; +import { findNearestCodeGraphRoot, unsafeIndexRootReason } from '../directory'; // Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same // helper in engine.ts. ToolHandler must load to answer tools/list (static // schemas), but it must NOT drag in sqlite/query layers before the daemon binds; @@ -23,6 +23,15 @@ let loadCodeGraphForTests: typeof import('../index').default | null = null; export function __setLoadCodeGraphForTests(cls: typeof import('../index').default | null): void { loadCodeGraphForTests = cls; } +import { getAutoInit } from '../installer/user-config'; + +// Test seam (same pattern as __setLoadCodeGraphForTests above): points +// getAutoInit at a temp config dir instead of the real ~/.codegraph. +// Never set outside tests. +let autoInitDirForTests: string | null = null; +export function __setAutoInitDirForTests(dir: string | null): void { + autoInitDirForTests = dir; +} import { detectWorktreeIndexMismatch, worktreeMismatchWarning, @@ -1509,7 +1518,7 @@ export class ToolHandler { * Walks up parent directories to find the nearest .codegraph/ folder, * similar to how git finds .git/ directories. */ - private getCodeGraph(projectPath?: string): CodeGraph { + private async getCodeGraph(projectPath?: string): Promise { if (!projectPath) { if (!this.cg) { const searched = this.defaultProjectHint ?? process.cwd(); @@ -1556,6 +1565,21 @@ export class ToolHandler { const resolvedRoot = findNearestCodeGraphRoot(projectPath); if (!resolvedRoot) { + if (getAutoInit(autoInitDirForTests ? { dir: autoInitDirForTests } : {})) { + const unsafe = unsafeIndexRootReason(projectPath); + if (!unsafe) { + try { + const CodeGraphClass = loadCodeGraph(); + const cg = await CodeGraphClass.init(projectPath, { index: false }); + await cg.indexAll(); + this.projectCache.set(cg.getProjectRoot(), cg); + return this.freshen(cg); + } catch { + // Auto-init must never turn a query failure into a worse, unexplained + // one — fall through to the standard NotIndexedError below. + } + } + } throw new NotIndexedError( `The project at ${projectPath} isn't indexed with codegraph (no .codegraph/ directory found ` + 'walking up from it), so codegraph cannot query it. Use your built-in tools (Read/Grep/Glob) ' + @@ -1676,7 +1700,7 @@ export class ToolHandler { * (e.g. nothing initialized yet), it reports "no mismatch" so a tool is never * broken by this check. */ - private worktreeMismatchFor(projectPath?: string): WorktreeIndexMismatch | null { + private async worktreeMismatchFor(projectPath?: string): Promise { const startPath = projectPath ?? this.defaultProjectHint ?? process.cwd(); // The verdict depends on BOTH the start path AND the index root it resolves @@ -1690,7 +1714,7 @@ export class ToolHandler { // that first verdict until restart (#926). let indexRoot: string; try { - indexRoot = this.getCodeGraph(projectPath).getProjectRoot(); + indexRoot = (await this.getCodeGraph(projectPath)).getProjectRoot(); } catch { // No resolvable project (or any other resolution error) → nothing to warn. return null; @@ -1713,9 +1737,9 @@ export class ToolHandler { * is no mismatch. `codegraph_status` is excluded — it embeds its own verbose * warning — so it stays out of this path. */ - private withWorktreeNotice(result: ToolResult, projectPath?: string): ToolResult { + private async withWorktreeNotice(result: ToolResult, projectPath?: string): Promise { if (result.isError) return result; - const mismatch = this.worktreeMismatchFor(projectPath); + const mismatch = await this.worktreeMismatchFor(projectPath); if (!mismatch) return result; const notice = worktreeMismatchNotice(mismatch); @@ -1800,12 +1824,12 @@ export class ToolHandler { return stale; } - private withStalenessNotice(result: ToolResult, projectPath?: string): ToolResult { + private async withStalenessNotice(result: ToolResult, projectPath?: string): Promise { if (result.isError) return result; let cg: CodeGraph; try { - cg = this.getCodeGraph(projectPath); + cg = await this.getCodeGraph(projectPath); } catch { return result; // no default project — leave as is } @@ -1980,8 +2004,8 @@ export class ToolHandler { // internal bookkeeping and must never reach the client, whether or not a // caller passed session state. const result = this.takeExploreEmission(raw, sessionState); - const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined); - return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); + const withWorktree = await this.withWorktreeNotice(result, args.projectPath as string | undefined); + return await this.withStalenessNotice(withWorktree, args.projectPath as string | undefined); } catch (err) { // Expected condition, not a malfunction: answer as a SUCCESS so the // agent keeps trusting the toolset for projects that ARE indexed. @@ -2109,7 +2133,7 @@ export class ToolHandler { const query = this.validateString(args.query, 'query'); if (typeof query !== 'string') return query; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const rawKind = args.kind as string | undefined; // The schema enum says 'type' (what agents naturally reach for); the // NodeKind is 'type_alias'. Without the mapping, kind: "type" silently @@ -2189,7 +2213,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -2262,7 +2286,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const limit = clamp((args.limit as number) || 20, 1, 100); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -2332,7 +2356,7 @@ export class ToolHandler { const symbol = this.validateString(args.symbol, 'symbol'); if (typeof symbol !== 'string') return symbol; - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const depth = clamp((args.depth as number) || 2, 1, 10); const fileFilter = typeof args.file === 'string' ? args.file : undefined; @@ -3207,7 +3231,7 @@ export class ToolHandler { // ranking all see the same canonical spelling (Erlang `mod:fn/arity`). const query = normalizeQuerySpelling(rawQuery); - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const projectRoot = cg.getProjectRoot(); // Resolve adaptive output budget from project size. Falls back to the @@ -5858,7 +5882,7 @@ export class ToolHandler { * Handle codegraph_node */ private async handleNode(args: Record): Promise { - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); // Default to false to minimize context usage const includeCode = args.includeCode === true; const fileHint = typeof args.file === 'string' && args.file.trim() ? args.file.trim() : undefined; @@ -6246,7 +6270,7 @@ export class ToolHandler { * Handle codegraph_status */ private async handleStatus(args: Record): Promise { - let cg = this.getCodeGraph(args.projectPath as string | undefined); + let cg = await this.getCodeGraph(args.projectPath as string | undefined); // Same trick as withStalenessNotice — when an explicit projectPath // resolves to the same project as the default session cg, prefer the // default so getPendingFiles() (only populated by the default's watcher) @@ -6265,7 +6289,7 @@ export class ToolHandler { // Queries then reflect that tree's branch, not the worktree being edited. // status shows the verbose, multi-line form; the read tools get the compact // one-liner via withWorktreeNotice. Both share the cached detection. - const mismatch = this.worktreeMismatchFor(args.projectPath as string | undefined); + const mismatch = await this.worktreeMismatchFor(args.projectPath as string | undefined); const lines: string[] = [ '**CodeGraph Status**', @@ -6370,7 +6394,7 @@ export class ToolHandler { * Handle codegraph_files - get project file structure from the index */ private async handleFiles(args: Record): Promise { - const cg = this.getCodeGraph(args.projectPath as string | undefined); + const cg = await this.getCodeGraph(args.projectPath as string | undefined); const pathFilter = args.path as string | undefined; const pattern = args.pattern as string | undefined; const format = (args.format as 'tree' | 'flat' | 'grouped') || 'tree'; From f17049de889d3d6baabad9f689ef5bcebfe7eba0 Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 19:19:32 -0500 Subject: [PATCH 09/10] fix(mcp): auto-init safety, indexAll-failure, and concurrency bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer-found fixes to the auto-init branch added in c287067: - Critical: require the projectPath to already exist AND pass validateProjectPath before attempting auto-init, not just unsafeIndexRootReason (which doesn't cover ~/.ssh, ~/.aws, ~/.gnupg, ~/.config). Auto-init must only ever index a directory the user already has, never create one via CodeGraph.init's mkdirSync. - Critical: check indexAll()'s IndexResult.success instead of discarding it — a failed indexAll (e.g. contended file lock) was being cached and returned as if it succeeded, permanently leaving an empty "indexed but broken" .codegraph/ on disk. - Important: on any auto-init failure, close the opened DB connection and remove the partially-created .codegraph/ directory so the next call retries cleanly instead of leaking a handle or hitting "already initialized". - Important: de-dupe concurrent auto-init attempts for the same path onto one in-flight Promise (new autoInitInFlight map), so two simultaneous tool calls against the same unindexed project can't race two separate init/indexAll runs. Extracted the auto-init mechanics into a new private autoInitProject method. Added a test for the sensitive-nonexistent-path case and strengthened the "indexes automatically" test to assert real query content, not just that a .codegraph/ dir appeared. --- __tests__/mcp-auto-init.test.ts | 33 ++++++++++++ src/mcp/tools.ts | 90 ++++++++++++++++++++++++++++++--- 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/__tests__/mcp-auto-init.test.ts b/__tests__/mcp-auto-init.test.ts index 4cc58d1d0..071089e0f 100644 --- a/__tests__/mcp-auto-init.test.ts +++ b/__tests__/mcp-auto-init.test.ts @@ -63,6 +63,11 @@ describe('MCP auto-init (opt-in)', () => { expect(res.isError).toBeUndefined(); expect(res.content[0]!.text).not.toMatch(/codegraph init/); expect(fs.existsSync(path.join(repo, '.codegraph'))).toBe(true); + // Not just "a .codegraph/ dir exists" — the index must actually have + // real content. A failed/empty indexAll would still create the + // directory (and, pre-fix, was being cached and returned as if it had + // succeeded), so assert the query resolved the real symbol. + expect(res.content[0]!.text).toMatch(/main/); }); it('still refuses to auto-init an unsafe path (home directory) even when autoInit is on', async () => { @@ -79,4 +84,32 @@ describe('MCP auto-init (opt-in)', () => { // checks. expect(fs.existsSync(path.join(os.homedir(), '.codegraph', 'codegraph.db'))).toBe(false); }); + + it('does not create anything under a sensitive, NONEXISTENT path even when autoInit is on', async () => { + setAutoInit(true, { dir: configDir }); + + // Pre-fix, getCodeGraph's auto-init branch only ran unsafeIndexRootReason + // (home dir / parent-of-home / filesystem root) and — critically — skipped + // validateProjectPath entirely for a path that doesn't exist yet (that skip + // was safe before auto-init existed, when this branch was read-only: it let + // a not-yet-real sub-path of a REAL project still walk up to a real + // ancestor's .codegraph/, #238). unsafeIndexRootReason does NOT cover + // ~/.ssh, ~/.aws, ~/.gnupg, ~/.config — only validateProjectPath does. So a + // hallucinated projectPath under one of those (agents supply these + // routinely) would have sailed past both checks and CodeGraph.init would + // mkdirSync the whole missing chain into existence. + const sensitiveTarget = path.join(os.homedir(), '.ssh', `codegraph-auto-init-attack-${Date.now()}`); + expect(fs.existsSync(sensitiveTarget)).toBe(false); + + try { + const res = await handler.execute('codegraph_explore', { query: 'main', projectPath: sensitiveTarget }); + + expect(res.content[0]!.text).toMatch(/codegraph init/); + expect(fs.existsSync(sensitiveTarget)).toBe(false); + } finally { + // Defensive cleanup in case this guard ever regresses — never leave + // anything behind under the real ~/.ssh. + fs.rmSync(sensitiveTarget, { recursive: true, force: true }); + } + }); }); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index a178e558d..c23416067 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -6,7 +6,7 @@ import type CodeGraph from '../index'; import type { QueryPool } from './query-pool'; -import { findNearestCodeGraphRoot, unsafeIndexRootReason } from '../directory'; +import { findNearestCodeGraphRoot, unsafeIndexRootReason, getCodeGraphDir } from '../directory'; // Lazy-load the heavy CodeGraph chain off the MCP startup path — see the same // helper in engine.ts. ToolHandler must load to answer tools/list (static // schemas), but it must NOT drag in sqlite/query layers before the daemon binds; @@ -45,6 +45,7 @@ import { existsSync, readFileSync, statSync, + rmSync, } from 'fs'; import { createHash } from 'crypto'; import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils'; @@ -1303,6 +1304,12 @@ const DEFAULT_MCP_TOOLS = new Set(['explore']); export class ToolHandler { // Cache of opened CodeGraph instances for cross-project queries private projectCache: Map = new Map(); + // In-flight auto-init attempts keyed by resolved projectPath, so two + // concurrent tool calls against the same unindexed path await the SAME + // init/indexAll run instead of racing two independent ones (which would + // both open a DatabaseConnection and contend for the file lock — feeding + // straight into the indexAll-failure and leaked-handle cases below). + private autoInitInFlight: Map> = new Map(); // The directory the server last searched for a default project. Surfaced in // the "not initialized" error so users can see why detection missed. private defaultProjectHint: string | null = null; @@ -1566,14 +1573,24 @@ export class ToolHandler { if (!resolvedRoot) { if (getAutoInit(autoInitDirForTests ? { dir: autoInitDirForTests } : {})) { - const unsafe = unsafeIndexRootReason(projectPath); - if (!unsafe) { + // Auto-init only ever INDEXES a directory the user already has — + // never CREATES one. Before this feature, this branch was read-only + // (no .codegraph/ found ⇒ throw), so skipping validateProjectPath on + // a nonexistent path was safe: it existed only to let a not-yet-real + // sub-path still walk UP to a real ancestor's .codegraph/ (#238). + // CodeGraph.init on a nonexistent path calls mkdirSync on the whole + // missing chain, which could land under a sensitive path (~/.ssh, + // ~/.aws, ~/.config, ...) that validateProjectPath exists to refuse — + // so require the path to already exist AND pass validation (in + // addition to unsafeIndexRootReason) before ever attempting to + // create anything. + const canAutoInit = + existsSync(projectPath) && + !validateProjectPath(projectPath) && + !unsafeIndexRootReason(projectPath); + if (canAutoInit) { try { - const CodeGraphClass = loadCodeGraph(); - const cg = await CodeGraphClass.init(projectPath, { index: false }); - await cg.indexAll(); - this.projectCache.set(cg.getProjectRoot(), cg); - return this.freshen(cg); + return await this.autoInitProject(projectPath); } catch { // Auto-init must never turn a query failure into a worse, unexplained // one — fall through to the standard NotIndexedError below. @@ -1610,6 +1627,63 @@ export class ToolHandler { return cg; } + /** + * Auto-init a project's `.codegraph/` and index it, for `getCodeGraph`'s + * opt-in `autoInit` path. Callers must already have verified the path is + * existing/safe (`validateProjectPath` + `unsafeIndexRootReason`) — this + * method only handles the init/index/failure-cleanup mechanics: + * + * - De-dupes concurrent callers on the same path onto one in-flight + * attempt, so two simultaneous tool calls against the same unindexed + * project never race two separate `CodeGraph.init` + `indexAll` runs + * (which would contend for the same file lock). + * - Treats `indexAll`'s `{ success: false }` result as a failure (it + * resolves rather than throws on a contended lock) — an unsuccessful + * index is never cached or returned as if it were real, which would + * otherwise leave a permanently "indexed but empty" project on disk. + * - On ANY failure, closes the opened DB connection (so the write handle + * never leaks for the process lifetime) and removes the partially + * created `.codegraph/` directory, so the next call gets a clean + * retry instead of tripping "already initialized" or a stuck lock. + */ + private async autoInitProject(projectPath: string): Promise { + const key = resolvePath(projectPath); + const inFlight = this.autoInitInFlight.get(key); + if (inFlight) return inFlight; + + const attempt = (async (): Promise => { + let cg: CodeGraph | undefined; + try { + const CodeGraphClass = loadCodeGraph(); + cg = await CodeGraphClass.init(projectPath, { index: false }); + const result = await cg.indexAll(); + if (!result.success) { + throw new Error( + `auto-init indexAll failed for ${projectPath}: ` + + (result.errors[0]?.message ?? 'unknown error') + ); + } + this.projectCache.set(cg.getProjectRoot(), cg); + return this.freshen(cg); + } catch (err) { + if (cg) { + try { cg.close(); } catch { /* best-effort — already failing */ } + try { + rmSync(getCodeGraphDir(projectPath), { recursive: true, force: true }); + } catch { /* best-effort cleanup so the next call can retry cleanly */ } + } + throw err; + } + })(); + + this.autoInitInFlight.set(key, attempt); + try { + return await attempt; + } finally { + this.autoInitInFlight.delete(key); + } + } + /** * Heal a long-lived connection whose `.codegraph/` was removed and recreated * at the same path (a worktree recreated, or `rm -rf .codegraph` + re-init) From aa7bfa6dd8402ec7871edb26ee8fd5fc99601cdd Mon Sep 17 00:00:00 2001 From: Rajesh Guntupalli Date: Tue, 18 Aug 2026 19:56:19 -0500 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20final-review=20wave=20=E2=80=94=20?= =?UTF-8?q?auto-init=20cleanup=20safety,=20--git-hooks=20prompt,=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the whole-branch review. 1. (Critical) auto-init's failure cleanup could delete another query-pool worker's live index. Each worker builds its own ToolHandler (src/mcp/query-worker.ts), so autoInitProject's in-flight de-dup map — a per-instance field — does not de-dupe across workers. Two workers can both pass isInitialized on the same fresh path; the loser's indexAll RESOLVES `{ success: false, errors: ['Could not acquire file lock ...'] }` rather than throwing, and the catch block then rmSync'd the whole .codegraph/ — the WINNER's in-progress index, out from under its open SQLite handle. Contended failures are now tagged `skipCleanup` and the rmSync is gated on it: the loser closes its handle and walks away, falling through to NotIndexedError so the next call re-resolves and finds the finished index. Genuine sole-owner failures still clean up as before. 2. (Important) `codegraph init --git-hooks` still opened an interactive prompt in single-path mode: offerWatchFallback was passed `yes: mode === 'batch'`, always false outside --all, so the user could answer "manual" and get no hooks despite the flag — and a setup script or CI run blocked on a prompt nobody could answer. An explicit --git-hooks now implies yes in both modes. 3. (Important) Document `codegraph config get|set auto-init` and the new `init --all` / `--git-hooks` flags in the README CLI Reference. Tests: both new tests were verified to FAIL with their fix reverted. - mcp-auto-init: a losing worker hitting the real FileLock (real pid-held lock file, real contention result, real cleanup branch) leaves the winner's directory byte-identical and still queryable from a fresh handler. Asserts the whole directory listing, not just codegraph.db — on Windows rmSync cannot unlink the open .db but does take out every unopened sibling first (pre-fix it stripped .gitignore). - cli-init-batch: single-path `init --git-hooks` against the built binary with stdin at /dev/null and no `select` stub, so the hooks can only be installed via the non-interactive path. Co-Authored-By: Claude Sonnet 5 --- README.md | 3 +- __tests__/cli-init-batch.test.ts | 40 +++++++++++++++++ __tests__/mcp-auto-init.test.ts | 76 ++++++++++++++++++++++++++++++++ src/bin/codegraph.ts | 4 +- src/mcp/tools.ts | 41 +++++++++++++---- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f8bb60bbc..5c32ec550 100644 --- a/README.md +++ b/README.md @@ -509,7 +509,7 @@ The exact text is `src/mcp/server-instructions.ts` — the single source of trut codegraph # Run interactive installer codegraph install # Run installer (explicit) codegraph uninstall # Remove CodeGraph from your agents AND the CLI (--keep-cli for configs only) -codegraph init [path] # Initialize a project + build its graph (one step) +codegraph init [path] # Initialize a project + build its graph (--all for many repos, --git-hooks for git sync hooks) codegraph uninit [path] # Remove CodeGraph from a project (--force to skip prompt) codegraph index [path] # Full index (--force to re-index, --quiet for less output) codegraph sync [path] # Incremental update @@ -524,6 +524,7 @@ codegraph callees # Find what a function/method calls (--limit, codegraph impact # Analyze what code is affected by changing a symbol (--depth, --json) codegraph affected [files...] # Find test files affected by changes (see below) codegraph daemon # Manage background daemons — pick one to stop (alias: daemons) +codegraph config get|set # Show or change a global setting (auto-init on|off) codegraph telemetry [on|off] # Show or change anonymous usage telemetry codegraph upgrade [version] # Update to the latest release (--check, --force) codegraph version # Print the installed version (also -v, --version) diff --git a/__tests__/cli-init-batch.test.ts b/__tests__/cli-init-batch.test.ts index 46cf8eb64..dd49cb3b0 100644 --- a/__tests__/cli-init-batch.test.ts +++ b/__tests__/cli-init-batch.test.ts @@ -105,3 +105,43 @@ describe('codegraph init (single-path regression)', () => { expect(stdout).toContain('Refusing'); }); }); + +describe('codegraph init --git-hooks (single path)', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-init-githooks-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('installs the hooks without prompting when --git-hooks is passed to a single-path init', () => { + const repo = makeRepo(root, 'repo-a'); + execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'ignore' }); + + // The flag's whole point is "yes, install them" — but single mode passed + // `yes: mode === 'batch'`, i.e. always false, so offerWatchFallback still + // ran clack.select() and the user could answer "manual" and get no hooks + // at all. In a non-interactive context (setup script, CI) that prompt has + // nobody to answer it. + // + // stdin is /dev/null here and nothing stubs `select`, so a surviving + // prompt cannot resolve to 'hook' by accident: the only way the hooks get + // installed is the non-interactive `yes` path. + const stdout = execFileSync( + process.execPath, + [BIN, 'init', repo, '--git-hooks'], + { + encoding: 'utf-8', + env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' }, + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 120_000, + }, + ); + + expect(stdout).not.toContain('How should CodeGraph keep its index fresh?'); + expect(fs.existsSync(path.join(repo, '.git', 'hooks', 'post-commit'))).toBe(true); + }); +}); diff --git a/__tests__/mcp-auto-init.test.ts b/__tests__/mcp-auto-init.test.ts index 071089e0f..0ef2ce291 100644 --- a/__tests__/mcp-auto-init.test.ts +++ b/__tests__/mcp-auto-init.test.ts @@ -70,6 +70,82 @@ describe('MCP auto-init (opt-in)', () => { expect(res.content[0]!.text).toMatch(/main/); }); + it("a losing pool worker never deletes the winning worker's in-progress index", async () => { + setAutoInit(true, { dir: configDir }); + + // Every query-pool worker (src/mcp/query-worker.ts) builds its OWN + // ToolHandler, so autoInitProject's in-flight de-dup map -- a per-instance + // field -- does NOT de-dupe across workers. Two workers really can both + // observe "not indexed" and both run CodeGraph.init + indexAll on the same + // path. One wins the index file lock; the loser's indexAll RESOLVES + // `{ success: false, errors: ['Could not acquire file lock ...'] }` + // (src/index.ts) rather than throwing. Pre-fix, the loser's catch block + // then rmSync'd the entire .codegraph/ -- the WINNER's live index, out + // from under the winner's open SQLite handle. + + // --- The winner: a real, complete, queryable index. --- + const won = await handler.execute('codegraph_explore', { query: 'main', projectPath: repo }); + expect(won.content[0]!.text).toMatch(/main/); + const cgDir = path.join(repo, '.codegraph'); + + // ...and it is still indexing. FileLock (src/utils.ts) is a lock FILE + // holding the owner's pid; it treats a lock whose pid is alive as held. + // Writing our own live pid is byte-for-byte what the winner's FileLock + // writes while indexAll runs, so the loser below hits the real lock via + // the real acquire() path -- nothing about the contention is faked. + fs.writeFileSync(path.join(cgDir, 'codegraph.lock'), String(process.pid)); + + const beforeLoser = fs.readdirSync(cgDir).sort(); + expect(beforeLoser).toContain('codegraph.db'); + + // --- The loser: a second ToolHandler, i.e. a second pool worker. --- + const handlerB = new ToolHandler(null); + + // One JS thread cannot produce the single interleave that matters: both + // workers passing `isInitialized` before either created the directory + // (CodeGraph.init throws "already initialized" for whoever checks second, + // and a real Promise.all of two execute() calls just serializes). So stub + // ONLY that step -- the loser's init hands back a real CodeGraph opened on + // the same on-disk .codegraph/, exactly what its racing init would have + // produced on a second thread. Everything downstream is unstubbed: the + // real indexAll, the real FileLock contention, the real failure shape, and + // the real cleanup branch under review. + class RacingCodeGraph extends CodeGraph { + static async init(root: string): Promise { + return CodeGraph.openSync(root); + } + } + __setLoadCodeGraphForTests(RacingCodeGraph as unknown as typeof CodeGraph); + + try { + await expect( + (handlerB as unknown as { autoInitProject(p: string): Promise }).autoInitProject(repo), + ).rejects.toThrow(/file lock|another process/i); + + // The winner's index is untouched -- not one file removed. Asserting the + // whole directory listing rather than just codegraph.db matters on + // Windows, where rmSync cannot unlink the open .db but WOULD still have + // taken out every unopened sibling before failing. + expect(fs.readdirSync(cgDir).sort()).toEqual(beforeLoser); + } finally { + __setLoadCodeGraphForTests(CodeGraph); + try { handlerB.closeAll(); } catch { /* ignore */ } + try { fs.unlinkSync(path.join(cgDir, 'codegraph.lock')); } catch { /* ignore */ } + } + + // And it is still a REAL index on disk, not an unlinked inode the winner's + // handle merely still points at: a fresh handler opens it from scratch. + const verifier = new ToolHandler(null); + try { + const after = await verifier.execute('codegraph_explore', { query: 'main', projectPath: repo }); + expect(after.isError).toBeUndefined(); + expect(after.content[0]!.text).not.toMatch(/codegraph init/); + expect(after.content[0]!.text).toMatch(/main/); + } finally { + try { verifier.closeAll(); } catch { /* ignore */ } + } + }); + it('still refuses to auto-init an unsafe path (home directory) even when autoInit is on', async () => { setAutoInit(true, { dir: configDir }); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 044924dcd..b9f9cde52 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -623,7 +623,7 @@ async function initOneProject( } try { const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath, { yes: mode === 'batch', force: options.gitHooks }); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' || options.gitHooks === true, force: options.gitHooks }); } catch { /* non-fatal */ } return { projectPath, status: 'already-initialized', detail: 'already initialized' }; } @@ -668,7 +668,7 @@ async function initOneProject( try { const { offerWatchFallback } = await import('../installer'); - await offerWatchFallback(clack, projectPath, { yes: mode === 'batch', force: options.gitHooks }); + await offerWatchFallback(clack, projectPath, { yes: mode === 'batch' || options.gitHooks === true, force: options.gitHooks }); } catch { /* non-fatal */ } cg.destroy(); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index c23416067..dc0057239 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1641,10 +1641,21 @@ export class ToolHandler { * resolves rather than throws on a contended lock) — an unsuccessful * index is never cached or returned as if it were real, which would * otherwise leave a permanently "indexed but empty" project on disk. - * - On ANY failure, closes the opened DB connection (so the write handle - * never leaks for the process lifetime) and removes the partially - * created `.codegraph/` directory, so the next call gets a clean - * retry instead of tripping "already initialized" or a stuck lock. + * - On a failure this call OWNS, closes the opened DB connection (so the + * write handle never leaks for the process lifetime) and removes the + * partially created `.codegraph/` directory, so the next call gets a + * clean retry instead of tripping "already initialized" or a stuck lock. + * - On a CONTENDED failure — `indexAll` reporting that another process + * holds the index file lock — closes the connection but deliberately + * does NOT delete `.codegraph/`. The de-dup map above is per-instance, + * and every query-pool worker (src/mcp/query-worker.ts) builds its own + * `ToolHandler`, so two workers CAN reach `CodeGraph.init` on the same + * unindexed path before either created the directory. The loser must + * walk away quietly: the directory it would delete is the WINNER's + * live, in-progress index, and unlinking it out from under the winner's + * open SQLite handle corrupts a healthy index to "fix" a failure that + * isn't ours. Falling through to NotIndexedError is correct and + * self-healing — the next call re-resolves and finds the finished index. */ private async autoInitProject(projectPath: string): Promise { const key = resolvePath(projectPath); @@ -1658,19 +1669,31 @@ export class ToolHandler { cg = await CodeGraphClass.init(projectPath, { index: false }); const result = await cg.indexAll(); if (!result.success) { - throw new Error( + // Lock contention means SOMEONE ELSE owns this .codegraph/ and is + // actively indexing it — this call is not the sole owner, so the + // cleanup below must not touch the directory. See src/index.ts's + // indexAll: a failed `fileLock.acquire()` resolves with exactly this + // message rather than throwing. + const contended = (result.errors ?? []).some((e) => + /file lock|another process/i.test(e.message) + ); + const failure = new Error( `auto-init indexAll failed for ${projectPath}: ` + (result.errors[0]?.message ?? 'unknown error') - ); + ) as Error & { skipCleanup?: boolean }; + failure.skipCleanup = contended; + throw failure; } this.projectCache.set(cg.getProjectRoot(), cg); return this.freshen(cg); } catch (err) { if (cg) { try { cg.close(); } catch { /* best-effort — already failing */ } - try { - rmSync(getCodeGraphDir(projectPath), { recursive: true, force: true }); - } catch { /* best-effort cleanup so the next call can retry cleanly */ } + if (!(err as { skipCleanup?: boolean } | null)?.skipCleanup) { + try { + rmSync(getCodeGraphDir(projectPath), { recursive: true, force: true }); + } catch { /* best-effort cleanup so the next call can retry cleanly */ } + } } throw err; }