diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b559613..95b72160f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 8866ec732..b2eb521d7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/__tests__/local-only-gitignore.test.ts b/__tests__/local-only-gitignore.test.ts new file mode 100644 index 000000000..6dd6c908d --- /dev/null +++ b/__tests__/local-only-gitignore.test.ts @@ -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'); + }); + }); +}); diff --git a/site/src/content/docs/reference/cli.md b/site/src/content/docs/reference/cli.md index 46b2bfd40..5425ee7fb 100644 --- a/site/src/content/docs/reference/cli.md +++ b/site/src/content/docs/reference/cli.md @@ -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: diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index ebee81f6d..83194b4c8 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -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 { 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); }); diff --git a/src/directory.ts b/src/directory.ts index 56277ee1a..ef76af713 100644 --- a/src/directory.ts +++ b/src/directory.ts @@ -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.