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);
}
/**