From d7e1af61bd49c1891bc4afd210d3bd6adea14643 Mon Sep 17 00:00:00 2001 From: walterf3 <1686878+walterf3@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:48:59 -0400 Subject: [PATCH] fix(init): make the generated .codegraph index invisible to git everywhere (#492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.codegraph/.gitignore` ended with `!.gitignore`, which un-ignored the generated file itself. Git therefore had one non-ignored path under the data dir, so in any repository whose root `.gitignore` has no rule for `.codegraph/` — i.e. every consumer repo — `git status` reported the whole generated index as untracked work: ?? .codegraph/ # git status ?? .codegraph/.gitignore # git status -uall This is the residual of #492: the issue asked for "the entire `.codegraph/` directory is not tracked by Git" and suggested `*` + `!.gitignore`, but that suggestion cannot deliver it. Git reads and honors an ignore file that ignores itself, so dropping the negation is what actually hides the directory — with no edit to the consumer's own root `.gitignore`, which CodeGraph has no business writing to. - Generated content is now a bare `*` (self-ignoring). - The stale-default predicate gains a second generation: under our header, `*` plus `!.gitignore` is now recognized as stale and upgraded in place, so existing projects self-heal on the next CodeGraph command. User-authored files (no header) are still never rewritten, and neither is a headered file customized with some other negation — only the exact `!.gitignore` line marks a stale default. Regression coverage drives real `git` against real temp repos, since this repo's own root `.gitignore` masks `.codegraph/` and would hide the bug: fresh init, migration from the deployed form, an alternate `CODEGRAPH_DIR`, and a user-authored file keeping its own semantics. All six new/updated assertions fail on the unfixed source. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 + README.md | 4 +- __tests__/foundation.test.ts | 186 ++++++++++++++++++++++++++++++++++- src/directory.ts | 51 +++++++--- 4 files changed, 221 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2d07fb3..a29d08fb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- The index CodeGraph generates no longer shows up as untracked work in `git status`. `.codegraph/` now hides itself completely in every repository, so `codegraph init` leaves your working tree exactly as clean as it was before and you don't need to add anything to your own `.gitignore`. Projects initialized by an earlier version are corrected automatically the next time you run any CodeGraph command, and a `.codegraph/.gitignore` you wrote yourself is still left untouched. (#492) + - Indexing no longer hangs on a Swift Vapor project containing a call with a long argument list. A single `.get(...)`-style call with many labeled arguments and no `use:` handler — the shape generated request builders produce — could stall `codegraph index`, `codegraph sync`, and the MCP server indefinitely. Route detection now handles such files in milliseconds, and every previously-recognized route shape still parses exactly as before. Thanks @maxmilian. (#1544) (Swift) - `codegraph status` now sees new files inside brand-new directories. Git reports an entirely-untracked directory as a single collapsed entry, so source files created there — a freshly scaffolded `frontend/`, for example — were missing from the pending-changes report, which could claim everything was up to date while those files had not yet been indexed. Thanks @maxmilian. (#1213) diff --git a/README.md b/README.md index 4f89a40e6..a38b48aa2 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ cd your-project codegraph init ``` -`codegraph init` creates the local `.codegraph/` directory and builds the full graph in the same step — one command, done. +`codegraph init` creates the local `.codegraph/` directory and builds the full graph in the same step — one command, done. `.codegraph/` is local to your machine and ignores itself, so it never shows up in `git status` and you don't have to add anything to your project's `.gitignore`.
@@ -495,7 +495,7 @@ The exact text is `src/mcp/server-instructions.ts` — the single source of trut 1. **Extraction** — a native **Rust kernel** parses source with [tree-sitter](https://tree-sitter.github.io/) grammars compiled into it, extracting nodes (functions, classes, methods) and edges (calls, imports, extends, implements) for 20 languages; remaining languages and per-file fallbacks use the same extraction logic on the portable engine, producing identical graphs. -2. **Storage** — Everything goes into a local SQLite database (`.codegraph/codegraph.db`) with FTS5 full-text search. +2. **Storage** — Everything goes into a local SQLite database (`.codegraph/codegraph.db`) with FTS5 full-text search. The whole `.codegraph/` directory is generated, machine-local, and safe to delete — it carries a nested ignore file that hides the directory and itself from git, so nothing in it is ever offered up for commit. 3. **Resolution** — After extraction, references are resolved: function calls → definitions, imports → source files, class inheritance, and framework-specific patterns. diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index b7616272a..2734040c9 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -5,6 +5,7 @@ */ 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'; @@ -62,10 +63,11 @@ describe('CodeGraph Foundation', () => { expect(fs.existsSync(gitignorePath)).toBe(true); const content = fs.readFileSync(gitignorePath, 'utf-8'); - // Ignore everything in .codegraph/ except this file itself, so transient - // files (db, daemon.pid, sockets, logs) never show up in git. (#492, #484) - expect(content).toContain('*'); - expect(content).toContain('!.gitignore'); + // Ignore everything in .codegraph/ — db, daemon.pid, sockets, logs, and + // this file itself — so the generated index never shows up in git in a + // consumer repo that has no root rule for it. (#492, #484) + expect(content.split('\n').map((l) => l.trim())).toContain('*'); + expect(content).not.toContain('!.gitignore'); cg.close(); }); @@ -305,10 +307,38 @@ describe('CodeGraph Foundation', () => { const upgraded = fs.readFileSync(gitignorePath, 'utf-8'); expect(upgraded).toContain('\n*\n'); // wildcard ignores everything… - expect(upgraded).toContain('!.gitignore'); // …except this file + expect(upgraded).not.toContain('!.gitignore'); // …including this file expect(upgraded).not.toContain('.dirty'); // old explicit list is gone }); + it('upgrades the wildcard-plus-!.gitignore default in place', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // The default shipped between #788 and this change: it ignored every + // transient file but re-exposed itself, so `.codegraph/` still surfaced + // as untracked work in any repo without a root rule for it. + const staleWildcard = + '# CodeGraph data files — local to each machine, not for committing.\n' + + '# Ignore everything in .codegraph/ except this file itself, so transient\n' + + '# files (the database, daemon.pid, sockets, logs) never show up in git.\n' + + '*\n!.gitignore\n'; + fs.writeFileSync(gitignorePath, staleWildcard, 'utf-8'); + + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + const upgraded = fs.readFileSync(gitignorePath, 'utf-8'); + expect(upgraded).toContain('\n*\n'); + expect(upgraded).not.toContain('!.gitignore'); + + // Idempotent: a second open must not rewrite the now-current default. + const cg3 = CodeGraph.openSync(tempDir); + cg3.close(); + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(upgraded); + }); + it('leaves a user-customized .codegraph/.gitignore untouched', () => { const cg = CodeGraph.initSync(tempDir); cg.close(); @@ -323,6 +353,25 @@ describe('CodeGraph Foundation', () => { expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom); }); + + it('leaves a headered .gitignore with a non-self negation untouched', () => { + const cg = CodeGraph.initSync(tempDir); + cg.close(); + + const gitignorePath = path.join(getCodeGraphDir(tempDir), '.gitignore'); + // Our header + wildcard, but the user un-ignored a file of their own. + // Only the exact `!.gitignore` self-negation marks a stale default, so + // this deliberate customization survives. + const customized = + '# CodeGraph data files — local to each machine, not for committing.\n' + + '*\n!notes.md\n'; + fs.writeFileSync(gitignorePath, customized, 'utf-8'); + + const cg2 = CodeGraph.openSync(tempDir); + cg2.close(); + + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(customized); + }); }); describe('Uninitialize', () => { @@ -562,3 +611,130 @@ describe('CODEGRAPH_DIR override (#636)', () => { } }); }); + +/** + * The generated index must be invisible to git in EVERY consumer repository, + * without CodeGraph editing the repo's own root `.gitignore`. + * + * The nested `.codegraph/.gitignore` used to end with `!.gitignore`, which + * re-exposed itself: in a repo whose root ignore file has no rule for + * `.codegraph/` (i.e. every repo but this one), `git status` reported + * `?? .codegraph/` as untracked work. Ignoring the generated file with the + * same wildcard closes that hole — git still reads and honors an ignore file + * that ignores itself. + * + * These drive real `git` against real temp repos — the only way to prove what + * git actually reports. + */ +describe('generated index is invisible to git', () => { + let repo: string; + const savedDirName = process.env.CODEGRAPH_DIR; + + /** Run git with the developer's global/system config out of the way, so a + * personal `core.excludesFile` can neither mask nor cause a failure. */ + function git(...args: string[]): string { + const none = path.join(repo, 'no-such-gitconfig'); + return execFileSync('git', args, { + cwd: repo, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + env: { ...process.env, GIT_CONFIG_GLOBAL: none, GIT_CONFIG_SYSTEM: none }, + }); + } + + /** Everything `git status` would surface, including files inside untracked dirs. */ + function untracked(): string { + return git('status', '--porcelain', '--untracked-files=all').trim(); + } + + /** Simulate a live index: the runtime files a real session leaves behind. */ + function plantRuntimeFiles(dataDir: string): void { + fs.writeFileSync(path.join(dataDir, 'daemon.pid'), '12345\n'); + fs.writeFileSync(path.join(dataDir, 'codegraph.db-wal'), ''); + fs.mkdirSync(path.join(dataDir, 'cache'), { recursive: true }); + fs.writeFileSync(path.join(dataDir, 'cache', 'entry.json'), '{}'); + } + + beforeEach(() => { + repo = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-gitvis-'))); + execFileSync('git', ['init'], { cwd: repo, stdio: ['ignore', 'ignore', 'ignore'] }); + // A consumer repo with real content and NO rule for .codegraph/ anywhere. + fs.writeFileSync(path.join(repo, 'README.md'), '# consumer\n'); + fs.writeFileSync(path.join(repo, '.gitignore'), 'node_modules/\ndist/\n'); + }); + + afterEach(() => { + if (savedDirName === undefined) delete process.env.CODEGRAPH_DIR; + else process.env.CODEGRAPH_DIR = savedDirName; + fs.rmSync(repo, { recursive: true, force: true }); + }); + + it('fresh init leaves nothing from .codegraph in git status', () => { + const before = untracked(); + expect(before).toContain('README.md'); // the repo's own files still show + + const cg = CodeGraph.initSync(repo); + cg.close(); + plantRuntimeFiles(getCodeGraphDir(repo)); + + // Not one path under the data dir is reported — not even its .gitignore. + expect(untracked()).not.toMatch(/\.codegraph/); + expect(untracked()).toBe(before); // status is byte-identical to pre-init + + // …and git agrees the generated ignore file ignores itself. + expect( + git('check-ignore', '-v', path.join('.codegraph', '.gitignore')) + ).toContain(path.join('.codegraph', '.gitignore')); + }); + + it('migrating a pre-existing !.gitignore index clears it from git status', () => { + const cg = CodeGraph.initSync(repo); + cg.close(); + const gitignorePath = path.join(getCodeGraphDir(repo), '.gitignore'); + // Roll the index back to the previously shipped default. + fs.writeFileSync( + gitignorePath, + '# CodeGraph data files — local to each machine, not for committing.\n' + + '# Ignore everything in .codegraph/ except this file itself, so transient\n' + + '# files (the database, daemon.pid, sockets, logs) never show up in git.\n' + + '*\n!.gitignore\n', + 'utf-8' + ); + plantRuntimeFiles(getCodeGraphDir(repo)); + + // Pre-condition: this is exactly the leak being fixed. + expect(untracked()).toContain(path.join('.codegraph', '.gitignore')); + + // Any CodeGraph command runs validateDirectory, which self-heals. + const cg2 = CodeGraph.openSync(repo); + cg2.close(); + + expect(untracked()).not.toMatch(/\.codegraph/); + }); + + it('an alternate CODEGRAPH_DIR is hidden the same way (#636)', () => { + process.env.CODEGRAPH_DIR = '.codegraph-win'; + const cg = CodeGraph.initSync(repo); + cg.close(); + plantRuntimeFiles(getCodeGraphDir(repo)); + + expect(fs.existsSync(path.join(repo, '.codegraph-win', 'codegraph.db'))).toBe(true); + expect(untracked()).not.toMatch(/\.codegraph-win/); + }); + + it('a user-authored .codegraph/.gitignore keeps its own git semantics', () => { + const cg = CodeGraph.initSync(repo); + cg.close(); + const gitignorePath = path.join(getCodeGraphDir(repo), '.gitignore'); + // No CodeGraph header → user-authored → never rewritten, so whatever the + // user chose to expose stays exposed. CodeGraph does not police this. + const custom = '# my own rules\n*\n!.gitignore\n'; + fs.writeFileSync(gitignorePath, custom, 'utf-8'); + + const cg2 = CodeGraph.openSync(repo); + cg2.close(); + + expect(fs.readFileSync(gitignorePath, 'utf-8')).toBe(custom); + expect(untracked()).toContain(path.join('.codegraph', '.gitignore')); + }); +}); diff --git a/src/directory.ts b/src/directory.ts index fd4a1aa0b..4c03bffb0 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -584,35 +584,56 @@ export function planFrontload(cwd: string, prompt: string): FrontloadPlan { /** * Contents of `.codegraph/.gitignore`. A single wildcard ignore keeps every - * transient file in the index dir — the database, `daemon.pid`, the socket, - * logs, cache, and anything future versions add — out of git, without having - * to enumerate each name (issues #788, #492, #484). Older versions wrote an - * explicit allowlist that never listed `daemon.pid` or the socket, so those - * runtime files were silently committed. + * file in the index dir — the database, `daemon.pid`, the socket, logs, + * cache, this ignore file itself, and anything future versions add — out of + * git, without having to enumerate each name (issues #788, #492, #484). + * + * The wildcard deliberately covers `.gitignore` too. An earlier default added + * `!.gitignore`, which re-exposed the generated file: in any repository whose + * root `.gitignore` doesn't already mask `.codegraph/`, that one un-ignored + * file made the whole generated index dir surface as untracked work + * (`?? .codegraph/`). Git still reads and honors an ignore file that ignores + * itself, so self-ignoring is what makes the index local-only everywhere + * — no edit to the consumer's root `.gitignore` required. */ const GITIGNORE_CONTENT = `# CodeGraph data files — local to each machine, not for committing. -# Ignore everything in .codegraph/ except this file itself, so transient -# files (the database, daemon.pid, sockets, logs) never show up in git. +# Ignore everything in .codegraph/, including this file itself, so the +# generated index (database, daemon.pid, sockets, logs) stays invisible to +# git without touching the repository's own .gitignore. * -!.gitignore `; /** Header line that prefixes every .gitignore CodeGraph has auto-generated. */ const GITIGNORE_MARKER = '# CodeGraph data files'; +/** + * The self-negation line the previous default emitted. Its presence under our + * header marks a generated file that still leaks `.codegraph/.gitignore` into + * `git status` as untracked. + */ +const GITIGNORE_SELF_NEGATION = '!.gitignore'; + /** * Is `content` a stale CodeGraph-generated `.gitignore` that should be - * regenerated in place? True when it carries our header but predates the - * wildcard ignore (it has no bare `*` line) — i.e. one of the old explicit - * allowlists (`*.db`, `cache/`, `.dirty`, …) that never ignored `daemon.pid` - * or the socket (issue #788). A file WITHOUT our header is user-authored and - * is left untouched; one that already has the wildcard is current. Matching + * regenerated in place? Two generations qualify, both gated on our header: + * + * 1. No bare `*` line — one of the old explicit allowlists (`*.db`, + * `cache/`, `.dirty`, …) that never ignored `daemon.pid` or the socket + * (issue #788). + * 2. A bare `*` plus `!.gitignore` — the wildcard default that re-exposed + * the generated ignore file, so `.codegraph/` showed up as untracked in + * any repo without a root rule for it. + * + * A file WITHOUT our header is user-authored and is left untouched; so is a + * headered file that customizes the default with some other negation. Matching * on the header (not a byte-exact list of past defaults) heals every old - * variant — v0.7.x through 0.9.9 — and is idempotent once upgraded. + * variant — v0.7.x onward — and is idempotent once upgraded. */ function isStaleDefaultGitignore(content: string): boolean { if (!content.trimStart().startsWith(GITIGNORE_MARKER)) return false; - return !content.split('\n').some((line) => line.trim() === '*'); + const lines = content.split('\n').map((line) => line.trim()); + if (!lines.includes('*')) return true; + return lines.includes(GITIGNORE_SELF_NEGATION); } /**