diff --git a/gitnexus-claude-plugin/hooks/gitnexus-hook.js b/gitnexus-claude-plugin/hooks/gitnexus-hook.js index a53d79f29..238438455 100644 --- a/gitnexus-claude-plugin/hooks/gitnexus-hook.js +++ b/gitnexus-claude-plugin/hooks/gitnexus-hook.js @@ -543,7 +543,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs index 56f5235fb..c74f03f5d 100644 --- a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs +++ b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs @@ -276,7 +276,13 @@ function formatBunxCommand(gitnexusArgs) { } function formatAnalyzeCommand(options = {}, deps = {}) { - const suffix = options.embeddings ? ' --embeddings' : ''; + // `--index-only` is what a routine "your index is stale" nudge wants: it + // reindexes without rewriting AGENTS.md / CLAUDE.md / skills, so an agent + // following the nudge on every commit cannot churn the tracked agent guides + // (#2907). Callers that actually want the docs refreshed omit it. + const suffix = `${options.indexOnly ? ' --index-only' : ''}${ + options.embeddings ? ' --embeddings' : '' + }`; // Keep the stale-index hook budget tight by querying each tool at most once. // The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with // resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess diff --git a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs index 3331ae3fc..630195087 100755 --- a/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs +++ b/gitnexus/hooks/antigravity/gitnexus-antigravity-hook.cjs @@ -503,7 +503,7 @@ function buildStaleIndexHint(gitNexusDir, cwd) { if (currentHead === lastCommit) return ''; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); return ( `[GitNexus] index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + `Run \`${analyzeCmd}\` to refresh the knowledge graph.` diff --git a/gitnexus/hooks/claude/gitnexus-hook.cjs b/gitnexus/hooks/claude/gitnexus-hook.cjs index 18be614f4..1b75ed17d 100755 --- a/gitnexus/hooks/claude/gitnexus-hook.cjs +++ b/gitnexus/hooks/claude/gitnexus-hook.cjs @@ -523,7 +523,7 @@ function handlePostToolUse(input) { // If HEAD matches last indexed commit, no reindex needed if (currentHead && currentHead === lastCommit) return; - const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings }); + const analyzeCmd = formatAnalyzeCommand({ embeddings: hadEmbeddings, indexOnly: true }); sendHookResponse( 'PostToolUse', `GitNexus index is stale (last indexed: ${lastCommit ? lastCommit.slice(0, 7) : 'never'}). ` + diff --git a/gitnexus/hooks/claude/resolve-analyze-cmd.cjs b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs index 56f5235fb..c74f03f5d 100644 --- a/gitnexus/hooks/claude/resolve-analyze-cmd.cjs +++ b/gitnexus/hooks/claude/resolve-analyze-cmd.cjs @@ -276,7 +276,13 @@ function formatBunxCommand(gitnexusArgs) { } function formatAnalyzeCommand(options = {}, deps = {}) { - const suffix = options.embeddings ? ' --embeddings' : ''; + // `--index-only` is what a routine "your index is stale" nudge wants: it + // reindexes without rewriting AGENTS.md / CLAUDE.md / skills, so an agent + // following the nudge on every commit cannot churn the tracked agent guides + // (#2907). Callers that actually want the docs refreshed omit it. + const suffix = `${options.indexOnly ? ' --index-only' : ''}${ + options.embeddings ? ' --embeddings' : '' + }`; // Keep the stale-index hook budget tight by querying each tool at most once. // The memoized `probe` is a spawn-free PATH scan (resolveOnPath) shared with // resolveInvocationMode, so `gitnexus` is scanned only once and no subprocess diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index e78130a14..b861ef939 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -157,7 +157,10 @@ export function generateGitNexusContent( ? generatedSkills .map( (s) => - `| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/${s.name}/SKILL.md\` |`, + // The per-cluster count is as volatile as the header parenthetical, + // so --no-stats drops it too (#2907) — otherwise the flag that + // promises "omit volatile symbol counts" left a churning one behind. + `| Work in the ${s.label} area${noStats ? '' : ` (${s.symbolCount} symbols)`} | \`.claude/skills/${s.name}/SKILL.md\` |`, ) .join('\n') : ''; @@ -200,7 +203,7 @@ ${tableBody}` This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. Use GitNexus graph tools to understand code, assess impact, and navigate safely. -> Index stale? Run \`${runner} analyze\` from the project root — it auto-selects an available runner. ${bootstrapNote} +> Index stale? Run \`${runner} analyze --index-only\` from the project root — it auto-selects an available runner. ${bootstrapNote} ## Always Do @@ -267,11 +270,33 @@ async function fileExists(filePath: string): Promise { } } +/** + * Replace the block's volatile counts — the header parenthetical and the + * per-cluster symbol counts in the skills table — with fixed placeholders, so + * two renderings that differ only in those numbers compare equal. + * + * Placeholders rather than deletions: `--no-stats` REMOVES the parenthetical, + * which must still be written through. Deleting instead of substituting would + * make a with-counts block and a without-counts block compare equal, and the + * flag would silently stop taking effect on an already-injected file. + */ +function stripVolatileCounts(section: string): string { + return section + .replace(/ \(\d+ symbols, \d+ relationships, \d+ execution flows\)/g, ' ()') + .replace(/ \(\d+ symbols\)/g, ' ()'); +} + /** * Create or update GitNexus section in a file * - If file doesn't exist: create with GitNexus content * - If file exists without GitNexus section: append - * - If file exists with GitNexus section: replace that section + * - If file exists with GitNexus section: replace that section, UNLESS the only + * delta is the volatile counts (#2907). AGENTS.md and CLAUDE.md are the agent + * guides teams commit, and the counts move with any code change, so a + * count-only rewrite dirties a tracked file on every reindex for no reader + * benefit. Live counts stay available from `gitnexus status` and + * `gitnexus://repo/{name}/context`; the committed block keeps whichever + * numbers it was last materially updated with. */ async function upsertGitNexusSection( filePath: string, @@ -283,7 +308,10 @@ async function upsertGitNexusSection( const exists = await fileExists(filePath); if (!exists) { - await fs.writeFile(filePath, content, 'utf-8'); + // Same `.trim() + '\n'` shape the update paths write. Creating without the + // trailing newline made the NEXT analyze dirty a freshly committed file + // even at unchanged counts, purely to append it (#2907). + await fs.writeFile(filePath, content.trim() + '\n', 'utf-8'); return 'created'; } @@ -344,6 +372,11 @@ async function upsertGitNexusSection( if (statsPattern.test(existingSection)) { const updatedSection = existingSection.replace(statsPattern, statsLine); + // Count-only delta — leave the committed lean block alone (#2907). A + // project rename, or --no-stats dropping the parenthetical, still writes. + if (stripVolatileCounts(updatedSection) === stripVolatileCounts(existingSection)) { + return 'preserved'; + } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); await fs.writeFile(filePath, (before + updatedSection + after).trim() + '\n', 'utf-8'); @@ -355,7 +388,11 @@ async function upsertGitNexusSection( return 'preserved'; } - // No keep marker — replace existing section with full verbose content + // No keep marker — replace existing section with full verbose content, + // unless the counts are the only thing that moved (#2907). + if (stripVolatileCounts(existingSection) === stripVolatileCounts(content)) { + return 'preserved'; + } const before = existingContent.substring(0, startIdx); const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length); const newContent = before + content + after; diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index 8a6716849..f9b55ce77 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -375,7 +375,10 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro lines.push(' - cypher: Raw graph queries'); lines.push(' - list_repos: Discover all indexed repositories'); lines.push(''); - lines.push('re_index: Run `npx gitnexus analyze` in terminal if data is stale'); + lines.push( + 're_index: Run `npx gitnexus analyze --index-only` in terminal if data is stale ' + + '(drop --index-only to also refresh AGENTS.md/CLAUDE.md and skills)', + ); lines.push(''); lines.push('resources_available:'); lines.push(' - gitnexus://repos: All indexed repositories'); diff --git a/gitnexus/test/integration/antigravity-hook-e2e.test.ts b/gitnexus/test/integration/antigravity-hook-e2e.test.ts index 98e64fbce..c5b4fcdfa 100644 --- a/gitnexus/test/integration/antigravity-hook-e2e.test.ts +++ b/gitnexus/test/integration/antigravity-hook-e2e.test.ts @@ -186,7 +186,7 @@ describe('antigravity hook adapter e2e', () => { const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('Run `gitnexus analyze`'); + expect(output!.additionalContext).toContain('Run `gitnexus analyze --index-only`'); expect(output!.additionalContext).not.toContain('npx gitnexus'); } finally { gn.cleanup(); @@ -240,7 +240,9 @@ describe('antigravity hook adapter e2e', () => { const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings'); + expect(output!.additionalContext).toContain( + 'npx gitnexus@latest analyze --index-only --embeddings', + ); }); it('prefers gitnexus.json over meta.json when both are present (dual-write steady state)', () => { diff --git a/gitnexus/test/integration/hooks-e2e.test.ts b/gitnexus/test/integration/hooks-e2e.test.ts index 3402d23ab..19fc3277a 100644 --- a/gitnexus/test/integration/hooks-e2e.test.ts +++ b/gitnexus/test/integration/hooks-e2e.test.ts @@ -141,7 +141,7 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('Run `gitnexus analyze`'); + expect(output!.additionalContext).toContain('Run `gitnexus analyze --index-only`'); expect(output!.additionalContext).not.toContain('npx gitnexus'); } finally { gn.cleanup(); @@ -173,7 +173,9 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('Run `gitnexus analyze --embeddings`'); + expect(output!.additionalContext).toContain( + 'Run `gitnexus analyze --index-only --embeddings`', + ); expect(output!.additionalContext).not.toContain('npx gitnexus'); } finally { gn.cleanup(); @@ -231,7 +233,9 @@ describe.each(HOOKS)('hooks e2e ($name)', ({ name, path: hookPath }) => { const output = parseHookOutput(result.stdout); expect(output).not.toBeNull(); - expect(output!.additionalContext).toContain('npx gitnexus@latest analyze --embeddings'); + expect(output!.additionalContext).toContain( + 'npx gitnexus@latest analyze --index-only --embeddings', + ); }); it('treats missing meta.json as stale', () => { diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 1fbd558b8..d8cf4b208 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -119,7 +119,7 @@ describe('generateAIContextFiles', () => { for (const f of ['CLAUDE.md', 'AGENTS.md']) { const content = await fs.readFile(path.join(subDir, f), 'utf-8'); // Primary command is the fixed project-local runner, not machine-resolved. - expect(content).toContain('`node .gitnexus/run.cjs analyze`'); + expect(content).toContain('`node .gitnexus/run.cjs analyze --index-only`'); expect(content).not.toContain('run `gitnexus analyze`'); // no machine-resolved leak // Bootstrap path (for a not-yet-analyzed checkout) + npm-11 escape hatch. // Every install-free runner is named, so a machine without npm (bun-only) @@ -272,7 +272,7 @@ describe('generateAIContextFiles', () => { const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); - expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze`'); + expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze --index-only`'); expect(content).toContain('## Always Do'); expect(content).toContain('## Never Do'); expect(content).toContain('## Resources'); @@ -367,7 +367,7 @@ Some project docs here. # GitNexus — Code Knowledge Graph -Indexed as **TestProject** (50 symbols, 100 relationships, 5 execution flows). MCP tools. +Indexed as **OldName** (50 symbols, 100 relationships, 5 execution flows). MCP tools. | Tool | Use for | |------|---------| @@ -378,7 +378,9 @@ Resources: gitnexus://repo/TestProject/context `; await fs.writeFile(claudeMdPath, customContent, 'utf-8'); - // Run analyze with new stats — should only update the stats line + // Run analyze with new stats — should only update the stats line. The seed + // carries a stale project NAME because a counts-only delta is now preserved + // rather than written (#2907); the rename is what makes this a real update. const stats = { nodes: 999, edges: 1234, processes: 42 }; await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); @@ -967,7 +969,7 @@ Project-specific agent guidance. # GitNexus context for AGENTS -Indexed as **AgentsTest** (10 symbols, 20 relationships, 1 execution flows). +Indexed as **AgentsOldName** (10 symbols, 20 relationships, 1 execution flows). Use 'query' for finding flows, 'context' for symbol details. @@ -1032,7 +1034,7 @@ Indexed as **Idem** (1 symbols, 2 relationships, 3 execution flows). Custom. '\r\n' + '\r\n' + '\r\n' + - 'Indexed as **CRLFTest** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' + + 'Indexed as **CRLFOldName** (5 symbols, 6 relationships, 7 execution flows). Custom CRLF.\r\n' + '\r\n'; await fs.writeFile(claudePath, crlfContent, 'utf-8'); @@ -1365,3 +1367,133 @@ Indexed as **P**. Custom. } }); }); + +// AGENTS.md and CLAUDE.md are the agent guides teams commit, so a rewrite whose +// only delta is the volatile counts dirties a tracked file on every reindex +// (#2907). These assert the write is skipped for a count-only delta and still +// happens for every material one. +describe('count-only reindex does not churn the committed block (#2907)', () => { + let dir: string; + let storage: string; + + beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-2907-')); + storage = path.join(dir, '.gitnexus'); + await fs.mkdir(storage, { recursive: true }); + }); + + afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); + }); + + const read = (file: string): Promise => fs.readFile(path.join(dir, file), 'utf-8'); + + it('creates the file with a trailing newline so the next analyze has nothing to append', async () => { + await generateAIContextFiles(dir, storage, 'P', { nodes: 10, edges: 20, processes: 3 }); + expect(await read('CLAUDE.md')).toMatch(/\n$/); + expect(await read('AGENTS.md')).toMatch(/\n$/); + }); + + it('leaves both files byte-identical when only the counts moved', async () => { + const before = { claude: await read('CLAUDE.md'), agents: await read('AGENTS.md') }; + + const result = await generateAIContextFiles(dir, storage, 'P', { + nodes: 999999, + edges: 888888, + processes: 777, + }); + + expect(await read('CLAUDE.md')).toBe(before.claude); + expect(await read('AGENTS.md')).toBe(before.agents); + expect(result.files).toContain('CLAUDE.md (preserved)'); + expect(result.files).toContain('AGENTS.md (preserved)'); + // The counts the block was created with are the ones still on disk. + expect(before.claude).toContain('(10 symbols, 20 relationships, 3 execution flows)'); + }); + + it('still rewrites when something other than the counts changed', async () => { + const result = await generateAIContextFiles(dir, storage, 'RenamedProject', { + nodes: 10, + edges: 20, + processes: 3, + }); + + expect(result.files).toContain('CLAUDE.md (updated)'); + expect(await read('CLAUDE.md')).toContain('**RenamedProject**'); + }); + + it('still applies --no-stats to an already-injected block', async () => { + const result = await generateAIContextFiles( + dir, + storage, + 'RenamedProject', + { nodes: 10, edges: 20, processes: 3 }, + undefined, + { noStats: true }, + ); + + expect(result.files).toContain('CLAUDE.md (updated)'); + const content = await read('CLAUDE.md'); + expect(content).toContain('indexed by GitNexus as **RenamedProject**.'); + expect(content).not.toContain('(10 symbols, 20 relationships, 3 execution flows)'); + }); +}); + +describe('--no-stats drops the per-cluster symbol counts too (#2907)', () => { + const stats = { nodes: 10, edges: 20, processes: 3 }; + const skills = [{ label: 'ingestion', name: 'p-ingestion', symbolCount: 120 }]; + + it('omits the count under --no-stats and keeps it otherwise', () => { + const lean = generateGitNexusContent('P', stats, { generatedSkills: skills, noStats: true }); + const full = generateGitNexusContent('P', stats, { generatedSkills: skills }); + + expect(lean).toContain( + '| Work in the ingestion area | `.claude/skills/p-ingestion/SKILL.md` |', + ); + expect(lean).not.toContain('(120 symbols)'); + expect(full).toContain( + '| Work in the ingestion area (120 symbols) | `.claude/skills/p-ingestion/SKILL.md` |', + ); + }); +}); + +describe('keep-marker blocks follow the same count-only rule (#2907)', () => { + const seed = (name: string, counts: string): string => `# Guide + + + +Indexed as **${name}**${counts}. Lean block. + +`; + + it('preserves on a count-only delta and updates on a rename', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-2907-keep-')); + const storage = path.join(dir, '.gitnexus'); + await fs.mkdir(storage, { recursive: true }); + try { + const original = seed('P', ' (10 symbols, 20 relationships, 3 execution flows)'); + await fs.writeFile(path.join(dir, 'CLAUDE.md'), original, 'utf-8'); + await fs.writeFile(path.join(dir, 'AGENTS.md'), original, 'utf-8'); + + const preserved = await generateAIContextFiles(dir, storage, 'P', { + nodes: 55, + edges: 66, + processes: 7, + }); + expect(preserved.files).toContain('CLAUDE.md (preserved)'); + expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toBe(original); + + const renamed = await generateAIContextFiles(dir, storage, 'Q', { + nodes: 55, + edges: 66, + processes: 7, + }); + expect(renamed.files).toContain('CLAUDE.md (updated)'); + expect(await fs.readFile(path.join(dir, 'CLAUDE.md'), 'utf-8')).toContain( + 'Indexed as **Q** (55 symbols, 66 relationships, 7 execution flows). Lean block.', + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/gitnexus/test/unit/resolve-invocation.test.ts b/gitnexus/test/unit/resolve-invocation.test.ts index 5fc9f19f1..0ea6e9309 100644 --- a/gitnexus/test/unit/resolve-invocation.test.ts +++ b/gitnexus/test/unit/resolve-invocation.test.ts @@ -38,7 +38,7 @@ const PLUGIN_CJS = path.resolve( interface CjsModule { formatAnalyzeCommand: ( - o?: { embeddings?: boolean }, + o?: { embeddings?: boolean; indexOnly?: boolean }, deps?: { npmMajor?: number | null; pnpmMajor?: number | null; pnpmMinor?: number | null }, ) => string; formatBunxCommand: (args: string) => string; @@ -115,6 +115,16 @@ describe('resolve-analyze-cmd.cjs (canonical invocation resolver)', () => { } }); + it('appends --index-only for the routine stale-index nudge (#2907)', () => { + process.env.GITNEXUS_INVOCATION = 'gitnexus'; + expect(cjs.formatAnalyzeCommand({ indexOnly: true })).toBe('gitnexus analyze --index-only'); + expect(cjs.formatAnalyzeCommand({ indexOnly: true, embeddings: true })).toBe( + 'gitnexus analyze --index-only --embeddings', + ); + // Absent/false leaves the doc-refreshing form untouched. + expect(cjs.formatAnalyzeCommand({ indexOnly: false })).toBe('gitnexus analyze'); + }); + it('auto-selects global gitnexus first', () => { expect(cjs.resolveInvocationMode(() => '/usr/local/bin/gitnexus')).toBe('gitnexus'); });