Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ cd your-project
codegraph init
```

<sub>`codegraph init` creates the local `.codegraph/` directory and builds the full graph in the same step — one command, done.</sub>
<sub>`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`.</sub>

<div align="center">

Expand Down Expand Up @@ -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.

Expand Down
186 changes: 181 additions & 5 deletions __tests__/foundation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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'));
});
});
51 changes: 36 additions & 15 deletions src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down