feat(init): --local-only keeps .codegraph/ out of git entirely (#3104)

The generated `.codegraph/.gitignore` ignores everything but itself, so the
rule can be committed and shared. For someone using CodeGraph alone, that
leaves `.codegraph/` showing as untracked in `git status`, with a file to
commit that nobody else needs.

`codegraph init --local-only` makes the file the wildcard alone, so it
ignores itself too and nothing of CodeGraph's shows in git. It works on a
new project and on one that's already initialized, where it converts the
file without indexing again. Plain `codegraph init` is unchanged.

Only a file CodeGraph generated is replaced. A file with the user's own
rules is left alone, and so is a default the repository has committed,
because rewriting a tracked file would show up as a change, the opposite of
what the flag is for. Both cases say why they were left. The local-only
file carries no generated header, so every later open leaves it alone.

A flag rather than the proposed CODEGRAPH_LOCAL_GITIGNORE env var: the file
is only written when it is missing, so an env var does nothing for a
project that already has one, which is the reporter's case; an env var in a
shell profile would also silently stop every new project sharing the rule,
team repositories included. The flag is also what the issue proposed.

Validation: 22 new tests, the CLI ones against real git repositories. They
cover `git status --porcelain` staying empty, the default unchanged without
the flag, conversion leaving the database untouched, and the file surviving
sync, status, index and a plain init.

Reimplements PR 1013 by @jiangyq9.
Fixes #910

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-30 20:55:44 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 27c904b011
commit fb71a9f4b3
6 changed files with 365 additions and 5 deletions
+1
View File
@@ -27,6 +27,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- New `codegraph cycles` command lists the groups of files that import each other in a circle, as text or `--json`, and exits 1 when it finds any, so it works as a pre-commit or CI check (#888). Thanks @jiangyq9 and @goutham80808.
- `codegraph init --local-only` keeps `.codegraph/` out of git entirely, so nothing of CodeGraph's shows in `git status`, and it works on a project that's already set up, leaving a `.codegraph/.gitignore` you've edited or committed alone (#910). Thanks @jadchene, @jiangyq9, @Wood-Q and @walterf3.
### Fixes
+1 -1
View File
@@ -529,7 +529,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 (one step; --local-only keeps .codegraph/ out of git)
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
+275
View File
@@ -0,0 +1,275 @@
/**
* `codegraph init --local-only` (#910): a `.codegraph/.gitignore` that ignores
* itself, so a project's CodeGraph data leaves no trace in `git status`.
*
* The default stays as it was, because a committed `.gitignore` is how a team
* shares the rule. So these also pin what must NOT be rewritten: a file with the
* user's own rules, and a file a repository has already committed.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { execFileSync, spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { getCodeGraphDir, makeGitignoreLocalOnly } from '../src/directory';
import CodeGraph from '../src/index';
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
// These index a project and spawn the real CLI, up to several times a test.
vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
const LOCAL_ONLY = '*\n';
const DEFAULT = `# 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.
*
!.gitignore
`;
const STALE_DEFAULT = `# CodeGraph data files
*.db
*.db-wal
*.db-shm
cache/
.dirty
`;
describe('local-only .codegraph/.gitignore', () => {
let dir: string;
let gitignore: string;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-local-only-'));
gitignore = path.join(dir, '.codegraph', '.gitignore');
fs.writeFileSync(path.join(dir, 'a.ts'), 'export const a = 1;\n');
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
function seedGitignore(content: string): void {
fs.mkdirSync(path.dirname(gitignore), { recursive: true });
fs.writeFileSync(gitignore, content);
}
function git(...args: string[]): string {
return execFileSync('git', args, {
cwd: dir,
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_AUTHOR_NAME: 'CodeGraph Test',
GIT_AUTHOR_EMAIL: 'codegraph-test@example.invalid',
GIT_COMMITTER_NAME: 'CodeGraph Test',
GIT_COMMITTER_EMAIL: 'codegraph-test@example.invalid',
},
});
}
function initRepo(): void {
git('init', '-q');
git('add', 'a.ts');
git('commit', '-qm', 'baseline');
}
function codegraph(...args: string[]): { code: number | null; output: string } {
const run = spawnSync(process.execPath, [BIN, ...args], {
cwd: dir,
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1', DO_NOT_TRACK: '1', CODEGRAPH_TELEMETRY: '0' },
});
return { code: run.status, output: run.stdout + run.stderr };
}
describe('makeGitignoreLocalOnly', () => {
it('writes the wildcard alone when there is no file', () => {
fs.mkdirSync(path.dirname(gitignore));
expect(makeGitignoreLocalOnly(dir)).toBe('written');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
});
it('writes into the directory the project really uses', () => {
const custom = process.env.CODEGRAPH_DIR;
process.env.CODEGRAPH_DIR = '.cg-custom';
try {
fs.mkdirSync(getCodeGraphDir(dir));
expect(makeGitignoreLocalOnly(dir)).toBe('written');
expect(fs.readFileSync(path.join(dir, '.cg-custom', '.gitignore'), 'utf-8')).toBe(LOCAL_ONLY);
} finally {
if (custom === undefined) delete process.env.CODEGRAPH_DIR;
else process.env.CODEGRAPH_DIR = custom;
}
});
it('replaces the default CodeGraph generates, as the code generates it', () => {
CodeGraph.initSync(dir).close();
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
expect(makeGitignoreLocalOnly(dir)).toBe('written');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
});
it('replaces a default checked out with Windows line endings', () => {
seedGitignore(DEFAULT.replace(/\n/g, '\r\n'));
expect(makeGitignoreLocalOnly(dir)).toBe('written');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
});
it('replaces a default from before the wildcard', () => {
seedGitignore(STALE_DEFAULT);
expect(makeGitignoreLocalOnly(dir)).toBe('written');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
});
it.each([
['with a newline', '*\n'],
['with a Windows newline', '*\r\n'],
])('leaves a file that is already local-only, %s', (_label, content) => {
seedGitignore(content);
expect(makeGitignoreLocalOnly(dir)).toBe('unchanged');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(content);
});
it.each([
['the user wrote', 'codegraph.db\n'],
['we generated and the user added to', `${DEFAULT}!shared-notes.md\n`],
])('leaves a file %s', (_label, content) => {
seedGitignore(content);
expect(makeGitignoreLocalOnly(dir)).toBe('customized');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(content);
});
it('leaves a default the repository has committed', () => {
seedGitignore(DEFAULT);
initRepo();
git('add', '.codegraph/.gitignore');
git('commit', '-qm', 'share the rule');
expect(makeGitignoreLocalOnly(dir)).toBe('tracked');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
expect(git('status', '--porcelain')).toBe('');
});
it('replaces a default that is only untracked', () => {
seedGitignore(DEFAULT);
initRepo();
expect(makeGitignoreLocalOnly(dir)).toBe('written');
});
it('works outside a git repository', () => {
seedGitignore(DEFAULT);
expect(makeGitignoreLocalOnly(dir)).toBe('written');
});
});
describe('codegraph init', () => {
it('leaves nothing in git status with --local-only', () => {
initRepo();
const { code, output } = codegraph('init', '-y', '--local-only');
expect(code).toBe(0);
expect(output).toContain('.codegraph/.gitignore now ignores itself');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
expect(git('status', '--porcelain')).toBe('');
});
it('still builds the index', () => {
codegraph('init', '-y', '--local-only');
expect(fs.existsSync(path.join(dir, '.codegraph', 'codegraph.db'))).toBe(true);
expect(codegraph('status').output).toContain('typescript');
});
it('keeps its default without the flag', () => {
initRepo();
const { code, output } = codegraph('init', '-y');
expect(code).toBe(0);
expect(output).not.toContain('ignores itself');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(DEFAULT);
expect(git('status', '--porcelain')).toContain('.codegraph/');
});
it('converts an initialized project, without indexing it again', () => {
initRepo();
codegraph('init', '-y');
const db = path.join(dir, '.codegraph', 'codegraph.db');
const indexed = fs.statSync(db).mtimeMs;
const { code, output } = codegraph('init', '-y', '--local-only');
expect(code).toBe(0);
expect(output).toContain('Already initialized');
expect(output).toContain('.codegraph/.gitignore now ignores itself');
expect(fs.statSync(db).mtimeMs).toBe(indexed);
expect(git('status', '--porcelain')).toBe('');
});
it('says so when there is nothing to convert', () => {
codegraph('init', '-y', '--local-only');
expect(codegraph('init', '-y', '--local-only').output).toContain('.codegraph/.gitignore already ignores itself');
});
it('survives everything that opens the index afterwards', () => {
codegraph('init', '-y', '--local-only');
expect(codegraph('sync').code).toBe(0);
expect(codegraph('status').code).toBe(0);
expect(codegraph('index', '--quiet').code).toBe(0);
expect(codegraph('init', '-y').code).toBe(0);
expect(fs.readFileSync(gitignore, 'utf-8')).toBe(LOCAL_ONLY);
});
it('says why it left a file with the user\'s own rules', () => {
seedGitignore('codegraph.db\n');
const { code, output } = codegraph('init', '-y', '--local-only');
expect(code).toBe(0);
expect(output).toContain('Left .codegraph/.gitignore as it is because it has your own rules');
expect(fs.readFileSync(gitignore, 'utf-8')).toBe('codegraph.db\n');
});
it('says why it left a committed file, and shows no change in git', () => {
seedGitignore(DEFAULT);
initRepo();
git('add', '.codegraph/.gitignore');
git('commit', '-qm', 'share the rule');
const { code, output } = codegraph('init', '-y', '--local-only');
expect(code).toBe(0);
expect(output).toContain('Left .codegraph/.gitignore as it is because it is committed');
expect(git('status', '--porcelain')).toBe('');
});
it('refuses an unsafe root before touching anything', () => {
const home = spawnSync(process.execPath, [BIN, 'init', '-y', '--local-only'], {
cwd: os.homedir(),
encoding: 'utf-8',
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', NO_COLOR: '1' },
});
expect(home.status).toBe(1);
expect(fs.existsSync(path.join(os.homedir(), '.codegraph', '.gitignore'))).toBe(false);
});
it('offers the flag in --help', () => {
expect(codegraph('init', '--help').output).toContain('--local-only');
});
});
});
+3 -1
View File
@@ -7,7 +7,7 @@ description: Every CodeGraph command and the flags it accepts.
codegraph # Run interactive installer
codegraph install # Run installer (explicit)
codegraph uninstall # Remove CodeGraph from your agents (inverse of install)
codegraph init [path] # Initialize a project + build its graph (one step)
codegraph init [path] # Initialize a project + build its graph (--local-only)
codegraph uninit [path] # Remove CodeGraph from a project (--force to skip prompt)
codegraph index [path] # Full re-index from scratch (--force, --quiet, --verbose)
codegraph sync [path] # Incremental update (--quiet)
@@ -36,6 +36,8 @@ The MCP server (`codegraph serve --mcp`) is launched automatically by your agent
`codegraph init` creates the local `.codegraph/` directory **and** builds the full graph in one step. (The old `-i`/`--index` flag is now a no-op, accepted only so existing scripts don't break.) After that the file watcher keeps the graph current automatically — `index` (a full rebuild from scratch) and `sync` (an incremental update) are only needed when the watcher is disabled or you're scripting against the index outside an agent session.
`.codegraph/` holds a `.gitignore` that keeps the index out of git and is itself meant to be committed, so the rule is shared with your team. If you use CodeGraph on your own, `codegraph init --local-only` makes that file ignore itself too, so nothing shows in `git status`. It also works on a project that's already initialized, without re-indexing. A `.gitignore` you've edited or committed is left as it is.
## status
`codegraph status` shows what is in the index, including how many files each language has. When only some of a language's files yielded symbols, its row says how many did:
+28 -3
View File
@@ -55,7 +55,7 @@ try {
import { Command } from 'commander';
import * as path from 'path';
import * as fs from 'fs';
import { getCodeGraphDir, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, isAgentMessage, isCrossSessionMessage, stripSystemReminders, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { getCodeGraphDir, makeGitignoreLocalOnly, isInitialized, hasSchemalessDb, hasForeignDbFile, unsafeIndexRootReason, findNearestCodeGraphRoot, planFrontload, isTaskNotification, isAgentMessage, isCrossSessionMessage, stripSystemReminders, hasStructuralKeyword, extractCodeTokens, capPromptHookInjection, codeGraphDirName, DEFAULT_CODEGRAPH_DIR } from '../directory';
import { extractProseCandidates } from '../search/identifier-segments';
import { detectWorktreeIndexMismatch, worktreeMismatchWarning } from '../sync/worktree';
import { createShimmerProgress } from '../ui/shimmer-progress';
@@ -707,6 +707,28 @@ async function recordIndexTelemetry(
// Commands
// =============================================================================
/** `init --local-only`: make the project's `.gitignore` ignore itself, and say what happened. */
function applyLocalOnlyGitignore(
clack: { log: { success(message: string): void; info(message: string): void; warn(message: string): void } },
projectPath: string,
): void {
const file = `${path.basename(getCodeGraphDir(projectPath))}/.gitignore`;
switch (makeGitignoreLocalOnly(projectPath)) {
case 'written':
clack.log.success(`${file} now ignores itself, so nothing of CodeGraph's shows in git.`);
break;
case 'unchanged':
clack.log.info(`${file} already ignores itself.`);
break;
case 'customized':
clack.log.warn(`Left ${file} as it is because it has your own rules. A lone * in it would keep the whole folder out of git.`);
break;
case 'tracked':
clack.log.warn(`Left ${file} as it is because it is committed to this repository, so changing it would show up in git.`);
break;
}
}
/**
* The `init` flow — shared by `codegraph init` and `codegraph install --init`
* (#1578): refuse an unsafe root, create `.codegraph/`, build the initial
@@ -717,7 +739,7 @@ async function recordIndexTelemetry(
*/
async function runInit(
projectPath: string,
options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean },
options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean; localOnly?: boolean },
): Promise<void> {
const clack = await importESM('@clack/prompts');
@@ -739,6 +761,7 @@ async function runInit(
if (isInitialized(projectPath)) {
clack.log.warn(`Already initialized in ${projectPath}`);
clack.log.info('Use "codegraph index" to re-index or "codegraph sync" to update');
if (options.localOnly) applyLocalOnlyGitignore(clack, projectPath);
try {
const { offerWatchFallback } = await import('../installer');
await offerWatchFallback(clack, projectPath, { yes: options.yes });
@@ -761,6 +784,7 @@ async function runInit(
const { default: CodeGraph, getDatabasePath } = await loadCodeGraph();
const cg = await CodeGraph.init(projectPath, { index: false });
clack.log.success(`Initialized in ${projectPath}`);
if (options.localOnly) applyLocalOnlyGitignore(clack, projectPath);
// A fresh index on a Windows drive under WSL gets its own directory (#995).
// It isn't the documented name, so say where it went and why.
const dataDir = path.basename(getCodeGraphDir(projectPath));
@@ -831,7 +855,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('-y, --yes', 'Non-interactive: skip every prompt and take the defaults (for scripts / CI / container bootstraps)')
.action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean }) => {
.option('--local-only', 'Keep .codegraph/ out of git entirely: its .gitignore ignores itself too. Also converts an initialized project')
.action(async (pathArg: string | undefined, options: { index?: boolean; force?: boolean; verbose?: boolean; yes?: boolean; localOnly?: boolean }) => {
await runInit(path.resolve(pathArg || process.cwd()), options);
});
+57
View File
@@ -4,6 +4,7 @@
* Manages the .codegraph/ directory structure for CodeGraph data.
*/
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -955,6 +956,62 @@ function ensureGitignore(gitignorePath: string): boolean {
}
}
/**
* The local-only `.codegraph/.gitignore` (#910): the wildcard alone, which
* ignores this file too, so nothing of CodeGraph's shows in `git status` and
* nothing asks to be committed. It carries no header, so `ensureGitignore`
* treats it as the user's own and leaves it alone from then on.
*/
const LOCAL_ONLY_GITIGNORE_CONTENT = '*\n';
/** What `makeGitignoreLocalOnly` did. */
export type LocalOnlyGitignoreResult =
/** The file is local-only now. */
| 'written'
/** It already was. */
| 'unchanged'
/** It holds rules CodeGraph didn't write, so it was left alone. */
| 'customized'
/** It is committed, so rewriting it would show up as a change. Left alone. */
| 'tracked';
/** Whether git tracks `file`. False outside a repository, or without git. */
function isTrackedByGit(file: string): boolean {
try {
execFileSync('git', ['ls-files', '--error-unmatch', '--', path.basename(file)], {
cwd: path.dirname(file),
stdio: 'ignore',
windowsHide: true,
timeout: 10_000,
});
return true;
} catch {
return false;
}
}
/**
* Make `.codegraph/.gitignore` local-only, for `codegraph init --local-only`.
* Only a file CodeGraph generated is replaced: anything else, or one a project
* has committed to share the rule, stays as it is and the caller says why.
*/
export function makeGitignoreLocalOnly(projectRoot: string): LocalOnlyGitignoreResult {
const gitignorePath = path.join(getCodeGraphDir(projectRoot), '.gitignore');
let existing: string | null;
try {
existing = fs.readFileSync(gitignorePath, 'utf-8').replace(/\r\n/g, '\n');
} catch {
existing = null;
}
if (existing === LOCAL_ONLY_GITIGNORE_CONTENT) return 'unchanged';
if (existing !== null) {
if (existing !== GITIGNORE_CONTENT && !isStaleDefaultGitignore(existing)) return 'customized';
if (isTrackedByGit(gitignorePath)) return 'tracked';
}
fs.writeFileSync(gitignorePath, LOCAL_ONLY_GITIGNORE_CONTENT, 'utf-8');
return 'written';
}
/**
* Create the .codegraph directory structure
* Note: Only throws if codegraph.db already exists, not just if .codegraph/ exists.