From e1199dce1e6d756115c621a21bf3303da73ed70d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Sat, 11 Jul 2026 14:56:59 -0400 Subject: [PATCH] Add Git ignore setup for lifecycle init --- .gitignore | 1 + codegraph-skill/codegraph/SKILL.md | 9 +- docs/cli.md | 9 +- src/cli/help.ts | 12 +- src/cli/lifecycle.ts | 13 +- src/cli/options.ts | 18 +- src/lifecycle/errors.ts | 3 + src/lifecycle/gitignore.ts | 78 ++++++++ src/lifecycle/manifest.ts | 35 +++- src/util/git.ts | 27 ++- tests/cli-options-validation.test.ts | 12 ++ tests/lifecycle.test.ts | 259 +++++++++++++++++++++++++++ 12 files changed, 451 insertions(+), 25 deletions(-) create mode 100644 src/lifecycle/errors.ts create mode 100644 src/lifecycle/gitignore.ts diff --git a/.gitignore b/.gitignore index cca17687..96d6e109 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ *.env dist/ /coverage/ +.codegraph/ .codegraph-cache/ codegraph.json target/ diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 60d430e7..01e63817 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -141,9 +141,16 @@ codegraph init --root . codegraph status --root . --json codegraph sync --root . codegraph uninit --root . +# Opt out only when initializing +codegraph init --root . --no-update-gitignore +codegraph sync --root . --init --no-update-gitignore ``` -`init` and `sync` may warm or update `.codegraph-cache/index-v1/`. Lifecycle commands accept either one positional project path or `--root `, never both. +`init` and `sync --init` use Git's effective ignore semantics before lifecycle hashing. When the untracked manifest is not already ignored, they append exactly `.codegraph/` to the resolved root's `.gitignore`; effective parent/global/info excludes are honored, tracked manifests are left unchanged with a warning, and non-Git roots are not modified. + +Use `--no-update-gitignore` to opt out during `init` or `sync --init`; ordinary `sync` never updates ignore policy. `uninit` removes lifecycle state but leaves the root rule, while `init` and `sync` may warm or update `.codegraph-cache/index-v1/`. + +Lifecycle commands accept either one positional project path or `--root `, never both. Automatic ignore updates are bound to that same resolved project root. ## Installation diff --git a/docs/cli.md b/docs/cli.md index 243f647a..4cce5acc 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,10 +120,13 @@ Graph, index, and review reports include `backend.native.byLanguage` so native u ### Project lifecycle - `init` creates `.codegraph/manifest.json`, warms the existing disk cache through the index build path, and is idempotent when the manifest is current. Use `--force` to rebuild and overwrite the manifest metadata. +- In a Git worktree, `init` first checks Git's effective ignore policy for `.codegraph/manifest.json`. If the manifest is untracked and not already ignored by root/parent rules, negations, `.git/info/exclude`, or global excludes, it appends exactly `.codegraph/` to the resolved project root's `.gitignore`; use `--no-update-gitignore` to opt out. +- A tracked manifest is left tracked and the ignore policy is unchanged. Non-Git projects remain supported without creating `.gitignore`, and directory or symlink `.gitignore` paths fail before manifest creation with guidance to replace the path or opt out. - `status` reports whether lifecycle metadata exists, last sync time, then/current file counts, per-file content drift (files changed even when counts match, e.g. edits in place or N files swapped for N others), config/build-option drift, analysis label, and the suggested next command. Use `--json` for `schemaVersion: 1`. -- `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed. -- `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed. -- Lifecycle commands accept either a positional project path or `--root `. They reject using both together because lifecycle manifests always describe one project boundary, not include-root subsets. +- `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed. `sync --init` performs the same ignore preparation and accepts `--no-update-gitignore`; ordinary `sync` never changes ignore policy. +- Initializing JSON results add an optional `gitignore` object with `.gitignore` path and `added`, `already-ignored`, `tracked`, `not-git`, or `disabled` status. The lifecycle manifest schema remains unchanged. +- `uninit` removes only recognized lifecycle state by default and leaves any root `.gitignore` rule in place. It refuses unknown `.codegraph/` entries unless `--force` is passed. +- Lifecycle commands accept either a positional project path or `--root `. They reject using both together because lifecycle manifests and automatic ignore updates always use one resolved project boundary, not include-root subsets. ### Symbols, navigation, grep, and chunking diff --git a/src/cli/help.ts b/src/cli/help.ts index d1ee9dac..29991d9e 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -169,17 +169,19 @@ export function isKnownCliCommand(command: string): boolean { export const LIFECYCLE_HELP_TEXT = `codegraph init/status/sync/uninit - Initialize, inspect, refresh, or remove project-local Codegraph state Usage: - codegraph init [path] [--force] [--json] - codegraph init --root [--force] [--json] + codegraph init [path] [--force] [--no-update-gitignore] [--json] + codegraph init --root [--force] [--no-update-gitignore] [--json] codegraph status [path] [--json] codegraph status --root [--json] - codegraph sync [path] [--init] [--json] - codegraph sync --root [--init] [--json] + codegraph sync [path] [--init] [--no-update-gitignore] [--json] + codegraph sync --root [--init] [--no-update-gitignore] [--json] codegraph uninit [path] [--force] [--json] codegraph uninit --root [--force] [--json] State: - Lifecycle commands own only .codegraph/manifest.json metadata. Init and sync may warm or update the disk cache under .codegraph-cache/index-v1/. Other commands do not depend on the manifest. + Lifecycle commands own only .codegraph/manifest.json metadata. In a Git worktree, init and sync --init ensure it is effectively ignored, appending .codegraph/ to the resolved root's .gitignore only when needed; opt out with --no-update-gitignore. + A tracked manifest is left tracked with a warning. Uninit removes lifecycle state but leaves the root .gitignore rule; ordinary sync never changes ignore policy. + Init and sync may warm or update the disk cache under .codegraph-cache/index-v1/. Other commands do not depend on the manifest. Positional paths and --root are alternatives for lifecycle commands; do not combine them. `; diff --git a/src/cli/lifecycle.ts b/src/cli/lifecycle.ts index b2ec2725..44a8ef54 100644 --- a/src/cli/lifecycle.ts +++ b/src/cli/lifecycle.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { BuildOptions } from "../indexer/types.js"; import { getCodegraphLifecycleStatus, @@ -23,6 +24,7 @@ export async function handleLifecycleCommand(context: LifecycleCommandContext): const result = await initCodegraphLifecycle(context.root, { ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), force: context.hasFlag("--force"), + updateGitignore: !context.hasFlag("--no-update-gitignore"), }); writeLifecycleResult(context, result, formatSyncResult("Initialized", result)); return; @@ -32,6 +34,7 @@ export async function handleLifecycleCommand(context: LifecycleCommandContext): const result = await syncCodegraphLifecycle(context.root, { ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), init: context.hasFlag("--init"), + updateGitignore: !context.hasFlag("--no-update-gitignore"), }); writeLifecycleResult(context, result, formatSyncResult("Synced", result)); return; @@ -66,7 +69,15 @@ function formatSyncResult(label: string, result: CodegraphLifecycleSyncResult): // Report added/removed explicitly rather than the net delta alone: equal adds and removes // cancel out to a delta of 0, which would otherwise hide real file churn. const changeLabel = added || removed ? `, +${added}/-${removed}` : ""; - return `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${changeLabel}. Manifest: ${result.manifestPath}`; + const summary = `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${changeLabel}. Manifest: ${result.manifestPath}`; + if (result.gitignore?.status === "added") { + const gitignorePath = path.join(result.root, result.gitignore.path); + return `${summary}\nUpdated Git ignore policy at ${gitignorePath}: added .codegraph/.`; + } + if (result.gitignore?.status === "tracked") { + return `${summary}\nWarning: .codegraph/manifest.json is tracked by Git; the ignore policy was not changed.`; + } + return summary; } function formatUninitResult(result: CodegraphLifecycleUninitResult): string { diff --git a/src/cli/options.ts b/src/cli/options.ts index bfa34b62..f367157e 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -88,7 +88,9 @@ const CLI_VALUE_OPTIONS = new Set([ ]); type CliPositionalPolicy = - { kind: "any" } | { kind: "max"; max: number; usage: string } | { kind: "none"; usage: string }; + | { kind: "any" } + | { kind: "max"; max: number; usage: string } + | { kind: "none"; usage: string }; type CliCommandSchema = { flags?: readonly string[]; @@ -388,10 +390,11 @@ const CLI_COMMAND_SCHEMAS = new Map([ ["index", graphCommandSchema({ kind: "any" })], [ "init", - commandSchema([...SHARED_BUILD_FLAGS, "--json", "--force"], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, "--json", "--force", "--no-update-gitignore"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph init [path] [--force] [--json] OR codegraph init --root [--force] [--json]", + usage: + "Usage: codegraph init [path] [--force] [--no-update-gitignore] [--json] OR codegraph init --root [--force] [--no-update-gitignore] [--json]", }), ], [ @@ -526,10 +529,11 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "sync", - commandSchema([...SHARED_BUILD_FLAGS, "--json", "--init"], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, "--json", "--init", "--no-update-gitignore"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph sync [path] [--init] [--json] OR codegraph sync --root [--init] [--json]", + usage: + "Usage: codegraph sync [path] [--init] [--no-update-gitignore] [--json] OR codegraph sync --root [--init] [--no-update-gitignore] [--json]", }), ], [ @@ -589,6 +593,10 @@ export function validateCliArgs(command: string, parsed: ParsedCliArgs): void { } } + if (command === "sync" && parsed.flags.has("--no-update-gitignore") && !parsed.flags.has("--init")) { + throw new Error("--no-update-gitignore for sync requires --init."); + } + if (schema.positionals.kind === "none" && parsed.positionals.length) { throw new Error( `Unexpected positional argument for ${command}: ${parsed.positionals[0]!}\n${schema.positionals.usage}`, diff --git a/src/lifecycle/errors.ts b/src/lifecycle/errors.ts new file mode 100644 index 00000000..8c2d257c --- /dev/null +++ b/src/lifecycle/errors.ts @@ -0,0 +1,3 @@ +export class CodegraphLifecycleUserError extends Error { + override name = "CodegraphLifecycleUserError"; +} diff --git a/src/lifecycle/gitignore.ts b/src/lifecycle/gitignore.ts new file mode 100644 index 00000000..ec374a1a --- /dev/null +++ b/src/lifecycle/gitignore.ts @@ -0,0 +1,78 @@ +import type { Stats } from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { isGitPathIgnored, isGitPathTracked, isGitRepo } from "../util/git.js"; +import { CodegraphLifecycleUserError } from "./errors.js"; + +const LIFECYCLE_MANIFEST_PATH = ".codegraph/manifest.json"; +const GITIGNORE_PATH = ".gitignore"; +const GITIGNORE_RULE = ".codegraph/"; + +export type CodegraphLifecycleGitignoreResult = { + status: "added" | "already-ignored" | "tracked" | "not-git" | "disabled"; + path: ".gitignore"; +}; + +export async function prepareCodegraphLifecycleGitignore( + root: string, + options: { updateGitignore?: boolean } = {}, +): Promise { + const { updateGitignore = true } = options; + if (!updateGitignore) return { status: "disabled", path: GITIGNORE_PATH }; + + const resolvedRoot = path.resolve(root); + if (!(await isGitRepo(resolvedRoot))) return { status: "not-git", path: GITIGNORE_PATH }; + if (await isGitPathTracked(resolvedRoot, LIFECYCLE_MANIFEST_PATH)) { + return { status: "tracked", path: GITIGNORE_PATH }; + } + if (await isGitPathIgnored(resolvedRoot, LIFECYCLE_MANIFEST_PATH)) { + return { status: "already-ignored", path: GITIGNORE_PATH }; + } + + const gitignorePath = path.join(resolvedRoot, GITIGNORE_PATH); + let existing = ""; + let stats: Stats | undefined; + try { + stats = await fsp.lstat(gitignorePath); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + const detail = error instanceof Error ? error.message : String(error); + throw new CodegraphLifecycleUserError( + `Unable to inspect ${gitignorePath}: ${detail}. Check the path and permissions or rerun with --no-update-gitignore.`, + ); + } + } + + if (stats) { + if (!stats.isFile()) { + let kind = "non-regular file"; + if (stats.isDirectory()) kind = "directory"; + if (stats.isSymbolicLink()) kind = "symbolic link"; + throw new CodegraphLifecycleUserError( + `Cannot update ${gitignorePath}: expected a regular file, but found a ${kind}. ` + + "Replace it with a regular file or rerun with --no-update-gitignore.", + ); + } + try { + existing = await fsp.readFile(gitignorePath, "utf8"); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new CodegraphLifecycleUserError( + `Unable to read ${gitignorePath}: ${detail}. Check file permissions or rerun with --no-update-gitignore.`, + ); + } + } + + const newline = existing.includes("\r\n") ? "\r\n" : "\n"; + let suffix = `${GITIGNORE_RULE}${newline}`; + if (existing && !existing.endsWith("\n")) suffix = `${newline}${suffix}`; + try { + await fsp.appendFile(gitignorePath, suffix, "utf8"); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new CodegraphLifecycleUserError( + `Unable to update ${gitignorePath}: ${detail}. Check file permissions or rerun with --no-update-gitignore.`, + ); + } + return { status: "added", path: GITIGNORE_PATH }; +} diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 4ec787d3..05e96ed7 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -12,6 +12,8 @@ import { } from "../indexer/build-cache/options.js"; import type { BuildOptions } from "../indexer/types.js"; import type { AnalysisSummary } from "../analysisSummary.js"; +import { CodegraphLifecycleUserError } from "./errors.js"; +import { prepareCodegraphLifecycleGitignore, type CodegraphLifecycleGitignoreResult } from "./gitignore.js"; export type CodegraphLifecycleManifest = { schemaVersion: 1; @@ -44,6 +46,9 @@ export type CodegraphLifecycleStatus = { suggestedNextCommand: string; }; +export type { CodegraphLifecycleGitignoreResult } from "./gitignore.js"; +export { CodegraphLifecycleUserError } from "./errors.js"; + export type CodegraphLifecycleSyncResult = { schemaVersion: 1; root: string; @@ -55,6 +60,7 @@ export type CodegraphLifecycleSyncResult = { removed: number; totalDelta: number; }; + gitignore?: CodegraphLifecycleGitignoreResult; }; export type CodegraphLifecycleUninitResult = { @@ -74,18 +80,17 @@ type LifecycleBuildOptionsSummary = ManifestBuildOptions & { }; const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]); -export class CodegraphLifecycleUserError extends Error { - override name = "CodegraphLifecycleUserError"; -} - export function codegraphLifecycleManifestPath(root: string): string { return path.join(root, CODEGRAPH_DIR, MANIFEST_FILE); } export async function initCodegraphLifecycle( root: string, - options: { buildOptions?: BuildOptions; force?: boolean } = {}, + options: { buildOptions?: BuildOptions; force?: boolean; updateGitignore?: boolean } = {}, ): Promise { + const gitignore = await prepareCodegraphLifecycleGitignore(root, { + updateGitignore: options.updateGitignore ?? true, + }); const existing = await readLifecycleManifest(root, options.force ? { allowInvalid: true } : {}); if (existing && !options.force) { const status = await getCodegraphLifecycleStatus(root, options); @@ -97,17 +102,32 @@ export async function initCodegraphLifecycle( manifestPath: codegraphLifecycleManifestPath(root), manifest: existing, changedFiles: { added: 0, removed: 0, totalDelta: 0 }, + gitignore, }; } } - return await syncCodegraphLifecycle(root, { ...options, init: true }); + return await syncCodegraphLifecycleCore(root, { ...options, init: true }, existing, gitignore); } export async function syncCodegraphLifecycle( root: string, - options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean } = {}, + options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean; updateGitignore?: boolean } = {}, ): Promise { + const gitignore = options.init + ? await prepareCodegraphLifecycleGitignore(root, { + updateGitignore: options.updateGitignore ?? true, + }) + : undefined; const existing = await readLifecycleManifest(root, { allowInvalid: Boolean(options.init && options.force) }); + return await syncCodegraphLifecycleCore(root, options, existing, gitignore); +} + +async function syncCodegraphLifecycleCore( + root: string, + options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean }, + existing: CodegraphLifecycleManifest | null, + gitignore?: CodegraphLifecycleGitignoreResult, +): Promise { if (!existing && !options.init) { throw new CodegraphLifecycleUserError( "Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init.", @@ -129,6 +149,7 @@ export async function syncCodegraphLifecycle( manifestPath: codegraphLifecycleManifestPath(root), manifest, changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, fallbackTotalDelta), + ...(gitignore ? { gitignore } : {}), }; } diff --git a/src/util/git.ts b/src/util/git.ts index fcb43afe..64a5626d 100644 --- a/src/util/git.ts +++ b/src/util/git.ts @@ -62,6 +62,27 @@ export async function isGitRepo(projectRoot: string): Promise { } } +export async function isGitPathTracked(projectRoot: string, file: string): Promise { + return await runGitPathPredicate(projectRoot, ["ls-files", "--error-unmatch", "--", normalizePath(file)]); +} + +export async function isGitPathIgnored(projectRoot: string, file: string): Promise { + return await runGitPathPredicate(projectRoot, ["check-ignore", "--quiet", "--no-index", "--", normalizePath(file)]); +} + +async function runGitPathPredicate(projectRoot: string, args: string[]): Promise { + try { + await execFileAsync("git", args, { + cwd: projectRoot, + env: process.env, + }); + return true; + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === 1) return false; + throw createGitError(projectRoot, args, error); + } +} + export async function getGitBlobHash( projectRoot: string, file: string, @@ -200,7 +221,7 @@ export async function listChangedFiles( } return Array.from(new Set(out)); } catch (error) { - throw createGitDiffError(projectRoot, args, error); + throw createGitError(projectRoot, args, error); } } @@ -235,11 +256,11 @@ export async function getUnifiedDiff( }); return stdout; } catch (error) { - throw createGitDiffError(projectRoot, args, error); + throw createGitError(projectRoot, args, error); } } -function createGitDiffError(projectRoot: string, args: string[], error: unknown): Error { +function createGitError(projectRoot: string, args: string[], error: unknown): Error { let detail = stringifyUnknown(error); if ( typeof error === "object" && diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 69e0453a..e8d97bbe 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -138,4 +138,16 @@ describe("CLI command option validation", () => { expect(() => validateCliArgs(command, parsedThreads)).not.toThrow(); } }); + it("accepts --no-update-gitignore only for init and initializing sync", () => { + expect(() => validateCliArgs("init", parseCliArgs("init", ["--no-update-gitignore"]))).not.toThrow(); + expect(() => validateCliArgs("sync", parseCliArgs("sync", ["--init", "--no-update-gitignore"]))).not.toThrow(); + expect(() => validateCliArgs("sync", parseCliArgs("sync", ["--no-update-gitignore"]))).toThrow( + "--no-update-gitignore for sync requires --init", + ); + + for (const command of ["status", "uninit"]) { + const parsed = parseCliArgs(command, ["--no-update-gitignore"]); + expect(() => validateCliArgs(command, parsed)).toThrow(`Unknown option for ${command}: --no-update-gitignore`); + } + }); }); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 54bc03a0..e27cbc2a 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import fsp from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { codegraphLifecycleManifestPath, @@ -21,6 +23,14 @@ import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; import { CODEGRAPH_CONFIG_FILE } from "../src/config.js"; +const execFileAsync = promisify(execFile); + +async function initializeGitRepository(root: string): Promise { + await execFileAsync("git", ["init", "--quiet"], { cwd: root }); + await execFileAsync("git", ["config", "user.email", "codegraph-tests@example.com"], { cwd: root }); + await execFileAsync("git", ["config", "user.name", "Codegraph Tests"], { cwd: root }); +} + async function writeFile(root: string, relativePath: string, content: string): Promise { const filePath = path.join(root, relativePath); await fsp.mkdir(path.dirname(filePath), { recursive: true }); @@ -62,6 +72,216 @@ describe("project lifecycle commands", () => { expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); + it("init appends one root Git ignore rule while preserving file bytes, newline style, and permissions", async () => { + const cases = [ + { name: "missing", initial: null, expected: ".codegraph/\n" }, + { name: "LF", initial: "node_modules/\n", expected: "node_modules/\n.codegraph/\n" }, + { name: "no-final-newline", initial: "node_modules/", expected: "node_modules/\n.codegraph/\n" }, + { name: "CRLF", initial: "node_modules/\r\n", expected: "node_modules/\r\n.codegraph/\r\n" }, + ] as const; + + for (const testCase of cases) { + const root = await mkTmpDir(`cg-life-gitignore-${testCase.name}-`); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + if (testCase.initial !== null) { + await writeFile(root, ".gitignore", testCase.initial); + await fsp.chmod(path.join(root, ".gitignore"), 0o640); + } + + const first = await initCodegraphLifecycle(root); + const second = await initCodegraphLifecycle(root); + + expect(first.gitignore).toEqual({ status: "added", path: ".gitignore" }); + expect(second.gitignore).toEqual({ status: "already-ignored", path: ".gitignore" }); + expect(await fsp.readFile(path.join(root, ".gitignore"), "utf8")).toBe(testCase.expected); + if (testCase.initial !== null) { + expect((await fsp.stat(path.join(root, ".gitignore"))).mode & 0o777).toBe(0o640); + } + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); + } + }); + + it("init honors exact, broader, and repository-external effective Git ignore rules", async () => { + const cases = [ + { name: "exact", policyPath: ".gitignore", policy: ".codegraph/manifest.json\n" }, + { name: "broader", policyPath: ".gitignore", policy: ".codegraph/\n" }, + { name: "info-exclude", policyPath: ".git/info/exclude", policy: ".codegraph/\n" }, + ] as const; + + for (const testCase of cases) { + const root = await mkTmpDir(`cg-life-effective-ignore-${testCase.name}-`); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await writeFile(root, testCase.policyPath, testCase.policy); + + const result = await initCodegraphLifecycle(root); + + expect(result.gitignore).toEqual({ status: "already-ignored", path: ".gitignore" }); + if (testCase.policyPath !== ".gitignore") { + await expect(fsp.stat(path.join(root, ".gitignore"))).rejects.toMatchObject({ code: "ENOENT" }); + } else { + expect(await fsp.readFile(path.join(root, ".gitignore"), "utf8")).toBe(testCase.policy); + } + } + }); + + it("init appends after an effective negation and refreshes a previously current unignored manifest", async () => { + const root = await mkTmpDir("cg-life-gitignore-negation-"); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const negatedPolicy = ".codegraph/\n!.codegraph/\n!.codegraph/manifest.json\n"; + await writeFile(root, ".gitignore", negatedPolicy); + const before = await initCodegraphLifecycle(root, { updateGitignore: false }); + expect((await getCodegraphLifecycleStatus(root)).configChanged).toBeFalsy(); + + const refreshed = await initCodegraphLifecycle(root); + const status = await getCodegraphLifecycleStatus(root); + + expect(refreshed.gitignore).toEqual({ status: "added", path: ".gitignore" }); + expect(refreshed.manifest.configHash).not.toBe(before.manifest.configHash); + expect(await fsp.readFile(path.join(root, ".gitignore"), "utf8")).toBe(`${negatedPolicy}.codegraph/\n`); + expect(status.configChanged).toBeFalsy(); + expect(status.suggestedNextCommand).toBe("codegraph status"); + }); + + it("init is non-fatal outside Git and supports an explicit ignore-policy opt-out", async () => { + const nonGitRoot = await mkTmpDir("cg-life-gitignore-non-git-"); + await writeFile(nonGitRoot, "src/main.ts", "export const main = 1;\n"); + const nonGit = await initCodegraphLifecycle(nonGitRoot); + expect(nonGit.gitignore).toEqual({ status: "not-git", path: ".gitignore" }); + await expect(fsp.stat(path.join(nonGitRoot, ".gitignore"))).rejects.toMatchObject({ code: "ENOENT" }); + + const disabledRoot = await mkTmpDir("cg-life-gitignore-disabled-"); + await initializeGitRepository(disabledRoot); + await writeFile(disabledRoot, "src/main.ts", "export const main = 1;\n"); + const disabled = await initCodegraphLifecycle(disabledRoot, { updateGitignore: false }); + expect(disabled.gitignore).toEqual({ status: "disabled", path: ".gitignore" }); + await expect(fsp.stat(path.join(disabledRoot, ".gitignore"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("sync --init prepares ignore policy once, while ordinary sync never changes it", async () => { + const initializedRoot = await mkTmpDir("cg-life-sync-init-gitignore-"); + await initializeGitRepository(initializedRoot); + await writeFile(initializedRoot, "src/main.ts", "export const main = 1;\n"); + const initialized = await syncCodegraphLifecycle(initializedRoot, { init: true }); + expect(initialized.gitignore).toEqual({ status: "added", path: ".gitignore" }); + expect(await fsp.readFile(path.join(initializedRoot, ".gitignore"), "utf8")).toBe(".codegraph/\n"); + + const disabledRoot = await mkTmpDir("cg-life-sync-init-gitignore-disabled-"); + await initializeGitRepository(disabledRoot); + await writeFile(disabledRoot, "src/main.ts", "export const main = 1;\n"); + const disabled = await syncCodegraphLifecycle(disabledRoot, { init: true, updateGitignore: false }); + expect(disabled.gitignore).toEqual({ status: "disabled", path: ".gitignore" }); + await writeFile(disabledRoot, ".gitignore", "operator-policy\n"); + + const ordinary = await syncCodegraphLifecycle(disabledRoot, { updateGitignore: true }); + expect(ordinary.gitignore).toBeUndefined(); + expect(await fsp.readFile(path.join(disabledRoot, ".gitignore"), "utf8")).toBe("operator-policy\n"); + }); + + it("leaves a tracked lifecycle manifest and Git policy unchanged", async () => { + const root = await mkTmpDir("cg-life-gitignore-tracked-"); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root, { updateGitignore: false }); + await execFileAsync("git", ["add", "--", ".codegraph/manifest.json"], { cwd: root }); + + const result = await initCodegraphLifecycle(root); + + expect(result.gitignore).toEqual({ status: "tracked", path: ".gitignore" }); + await expect(fsp.stat(path.join(root, ".gitignore"))).rejects.toMatchObject({ code: "ENOENT" }); + const { stdout } = await execFileAsync("git", ["ls-files", "--", ".codegraph/manifest.json"], { cwd: root }); + expect(stdout.trim()).toBe(".codegraph/manifest.json"); + }); + + it("rejects directory and symlink .gitignore paths before creating a manifest", async () => { + for (const kind of ["directory", "symbolic link"] as const) { + const root = await mkTmpDir(`cg-life-gitignore-${kind.replace(" ", "-")}-`); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const gitignorePath = path.join(root, ".gitignore"); + if (kind === "directory") { + await fsp.mkdir(gitignorePath); + } else { + await writeFile(root, "ignore-target", "operator policy\n"); + await fsp.symlink("ignore-target", gitignorePath); + } + + await expect(initCodegraphLifecycle(root)).rejects.toThrow(CodegraphLifecycleUserError); + await expect(initCodegraphLifecycle(root)).rejects.toThrow(kind); + await expect(fsp.stat(codegraphLifecycleManifestPath(root))).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + + it("wraps .gitignore read failures as actionable lifecycle errors before manifest creation", async () => { + const root = await mkTmpDir("cg-life-gitignore-read-error-"); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await writeFile(root, ".gitignore", "operator-policy\n"); + const gitignorePath = path.join(root, ".gitignore"); + const originalReadFile = fsp.readFile.bind(fsp); + vi.spyOn(fsp, "readFile").mockImplementation(async (filePath, options) => { + if (filePath === gitignorePath) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return await originalReadFile(filePath, options as never); + }); + + await expect(initCodegraphLifecycle(root)).rejects.toThrow(CodegraphLifecycleUserError); + await expect(initCodegraphLifecycle(root)).rejects.toThrow( + `Unable to read ${gitignorePath}: permission denied. Check file permissions or rerun with --no-update-gitignore.`, + ); + await expect(fsp.stat(codegraphLifecycleManifestPath(root))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("wraps .gitignore write failures as actionable lifecycle errors before manifest creation", async () => { + const root = await mkTmpDir("cg-life-gitignore-write-error-"); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const gitignorePath = path.join(root, ".gitignore"); + vi.spyOn(fsp, "appendFile").mockImplementation(async (filePath) => { + if (filePath === gitignorePath) { + throw Object.assign(new Error("read-only filesystem"), { code: "EROFS" }); + } + }); + + await expect(initCodegraphLifecycle(root)).rejects.toThrow(CodegraphLifecycleUserError); + await expect(initCodegraphLifecycle(root)).rejects.toThrow( + `Unable to update ${gitignorePath}: read-only filesystem. Check file permissions or rerun with --no-update-gitignore.`, + ); + await expect(fsp.stat(codegraphLifecycleManifestPath(root))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("uninit removes lifecycle state but leaves the appended root ignore rule", async () => { + const root = await mkTmpDir("cg-life-uninit-gitignore-"); + await initializeGitRepository(root); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const first = await initCodegraphLifecycle(root); + const status = await getCodegraphLifecycleStatus(root); + const repeated = await initCodegraphLifecycle(root); + const { stdout: gitStatus } = await execFileAsync("git", ["status", "--short", "--untracked-files=all"], { + cwd: root, + }); + const rules = (await fsp.readFile(path.join(root, ".gitignore"), "utf8")) + .split(/\r?\n/u) + .filter((line) => line === ".codegraph/"); + + expect(first.gitignore?.status).toBe("added"); + expect(repeated.gitignore?.status).toBe("already-ignored"); + expect(rules).toHaveLength(1); + expect(gitStatus).not.toContain(".codegraph/"); + expect(status.configChanged).toBeFalsy(); + expect(status.buildOptionsChanged).toBeFalsy(); + expect(status.filesChanged).toBeFalsy(); + expect(status.suggestedNextCommand).toBe("codegraph status"); + + await uninitCodegraphLifecycle(root); + + expect(await fsp.readFile(path.join(root, ".gitignore"), "utf8")).toBe(".codegraph/\n"); + await expect(fsp.stat(path.join(root, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("init --force refreshes lastSyncAt when project files and options are current", async () => { const root = await mkTmpDir("cg-life-force-current-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); @@ -524,6 +744,45 @@ describe("project lifecycle commands", () => { await expect(fsp.stat(path.join(syncRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); }); + it("CLI JSON and pretty output expose only initializing ignore-policy outcomes", async () => { + const jsonRoot = await mkTmpDir("cg-life-cli-gitignore-json-"); + await initializeGitRepository(jsonRoot); + await writeFile(jsonRoot, "src/main.ts", "export const main = 1;\n"); + + const jsonResult = await captureCli(["init", jsonRoot, "--json"]); + + expect(jsonResult.exitCode).toBeUndefined(); + expect(jsonResult.stderr).toBe(""); + const payload = JSON.parse(jsonResult.stdout) as CodegraphLifecycleSyncResult; + expect(payload.gitignore).toEqual({ status: "added", path: ".gitignore" }); + expect(jsonResult.stdout.trim().startsWith("{")).toBeTruthy(); + expect(jsonResult.stdout.trim().endsWith("}")).toBeTruthy(); + + const prettyRoot = await mkTmpDir("cg-life-cli-gitignore-pretty-"); + await initializeGitRepository(prettyRoot); + await writeFile(prettyRoot, "src/main.ts", "export const main = 1;\n"); + const prettyResult = await captureCli(["init", prettyRoot]); + expect(prettyResult.stderr).toBe(""); + expect(prettyResult.stdout).toContain(`Updated Git ignore policy at ${path.join(prettyRoot, ".gitignore")}`); + expect(prettyResult.stdout).toContain("added .codegraph/"); + + const trackedRoot = await mkTmpDir("cg-life-cli-gitignore-tracked-"); + await initializeGitRepository(trackedRoot); + await writeFile(trackedRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(trackedRoot, { updateGitignore: false }); + await execFileAsync("git", ["add", "--", ".codegraph/manifest.json"], { cwd: trackedRoot }); + const trackedResult = await captureCli(["init", trackedRoot]); + expect(trackedResult.stderr).toBe(""); + expect(trackedResult.stdout).toContain("Warning: .codegraph/manifest.json is tracked by Git"); + + const ordinary = await captureCli(["sync", jsonRoot, "--json"]); + expect((JSON.parse(ordinary.stdout) as CodegraphLifecycleSyncResult).gitignore).toBeUndefined(); + + const help = await captureCli(["init", "--help"]); + expect(help.stdout).toContain("--no-update-gitignore"); + expect(help.stdout).toContain("sync --init"); + }); + it("CLI lifecycle commands reject positional roots when --root is supplied", async () => { const commands = ["init", "status", "sync", "uninit"] as const;