Skip to content

Commit b28ed64

Browse files
authored
Add Git ignore setup for lifecycle init (#153)
1 parent 7b4e4d4 commit b28ed64

12 files changed

Lines changed: 451 additions & 25 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ node_modules/
22
*.env
33
dist/
44
/coverage/
5+
.codegraph/
56
.codegraph-cache/
67
codegraph.json
78
target/

codegraph-skill/codegraph/SKILL.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,16 @@ codegraph init --root .
141141
codegraph status --root . --json
142142
codegraph sync --root .
143143
codegraph uninit --root .
144+
# Opt out only when initializing
145+
codegraph init --root . --no-update-gitignore
146+
codegraph sync --root . --init --no-update-gitignore
144147
```
145148

146-
`init` and `sync` may warm or update `.codegraph-cache/index-v1/`. Lifecycle commands accept either one positional project path or `--root <path>`, never both.
149+
`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.
150+
151+
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/`.
152+
153+
Lifecycle commands accept either one positional project path or `--root <path>`, never both. Automatic ignore updates are bound to that same resolved project root.
147154

148155
## Installation
149156

docs/cli.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,13 @@ Graph, index, and review reports include `backend.native.byLanguage` so native u
120120
### Project lifecycle
121121

122122
- `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.
123+
- 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.
124+
- 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.
123125
- `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`.
124-
- `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed.
125-
- `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed.
126-
- Lifecycle commands accept either a positional project path or `--root <path>`. They reject using both together because lifecycle manifests always describe one project boundary, not include-root subsets.
126+
- `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.
127+
- 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.
128+
- `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.
129+
- Lifecycle commands accept either a positional project path or `--root <path>`. They reject using both together because lifecycle manifests and automatic ignore updates always use one resolved project boundary, not include-root subsets.
127130

128131
### Symbols, navigation, grep, and chunking
129132

src/cli/help.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,19 @@ export function isKnownCliCommand(command: string): boolean {
169169
export const LIFECYCLE_HELP_TEXT = `codegraph init/status/sync/uninit - Initialize, inspect, refresh, or remove project-local Codegraph state
170170
171171
Usage:
172-
codegraph init [path] [--force] [--json]
173-
codegraph init --root <path> [--force] [--json]
172+
codegraph init [path] [--force] [--no-update-gitignore] [--json]
173+
codegraph init --root <path> [--force] [--no-update-gitignore] [--json]
174174
codegraph status [path] [--json]
175175
codegraph status --root <path> [--json]
176-
codegraph sync [path] [--init] [--json]
177-
codegraph sync --root <path> [--init] [--json]
176+
codegraph sync [path] [--init] [--no-update-gitignore] [--json]
177+
codegraph sync --root <path> [--init] [--no-update-gitignore] [--json]
178178
codegraph uninit [path] [--force] [--json]
179179
codegraph uninit --root <path> [--force] [--json]
180180
181181
State:
182-
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.
182+
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.
183+
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.
184+
Init and sync may warm or update the disk cache under .codegraph-cache/index-v1/. Other commands do not depend on the manifest.
183185
Positional paths and --root are alternatives for lifecycle commands; do not combine them.
184186
`;
185187

src/cli/lifecycle.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from "node:path";
12
import type { BuildOptions } from "../indexer/types.js";
23
import {
34
getCodegraphLifecycleStatus,
@@ -23,6 +24,7 @@ export async function handleLifecycleCommand(context: LifecycleCommandContext):
2324
const result = await initCodegraphLifecycle(context.root, {
2425
...(context.buildOptions ? { buildOptions: context.buildOptions } : {}),
2526
force: context.hasFlag("--force"),
27+
updateGitignore: !context.hasFlag("--no-update-gitignore"),
2628
});
2729
writeLifecycleResult(context, result, formatSyncResult("Initialized", result));
2830
return;
@@ -32,6 +34,7 @@ export async function handleLifecycleCommand(context: LifecycleCommandContext):
3234
const result = await syncCodegraphLifecycle(context.root, {
3335
...(context.buildOptions ? { buildOptions: context.buildOptions } : {}),
3436
init: context.hasFlag("--init"),
37+
updateGitignore: !context.hasFlag("--no-update-gitignore"),
3538
});
3639
writeLifecycleResult(context, result, formatSyncResult("Synced", result));
3740
return;
@@ -66,7 +69,15 @@ function formatSyncResult(label: string, result: CodegraphLifecycleSyncResult):
6669
// Report added/removed explicitly rather than the net delta alone: equal adds and removes
6770
// cancel out to a delta of 0, which would otherwise hide real file churn.
6871
const changeLabel = added || removed ? `, +${added}/-${removed}` : "";
69-
return `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${changeLabel}. Manifest: ${result.manifestPath}`;
72+
const summary = `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${changeLabel}. Manifest: ${result.manifestPath}`;
73+
if (result.gitignore?.status === "added") {
74+
const gitignorePath = path.join(result.root, result.gitignore.path);
75+
return `${summary}\nUpdated Git ignore policy at ${gitignorePath}: added .codegraph/.`;
76+
}
77+
if (result.gitignore?.status === "tracked") {
78+
return `${summary}\nWarning: .codegraph/manifest.json is tracked by Git; the ignore policy was not changed.`;
79+
}
80+
return summary;
7081
}
7182

7283
function formatUninitResult(result: CodegraphLifecycleUninitResult): string {

src/cli/options.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ const CLI_VALUE_OPTIONS = new Set<string>([
8888
]);
8989

9090
type CliPositionalPolicy =
91-
{ kind: "any" } | { kind: "max"; max: number; usage: string } | { kind: "none"; usage: string };
91+
| { kind: "any" }
92+
| { kind: "max"; max: number; usage: string }
93+
| { kind: "none"; usage: string };
9294

9395
type CliCommandSchema = {
9496
flags?: readonly string[];
@@ -388,10 +390,11 @@ const CLI_COMMAND_SCHEMAS = new Map<string, CliCommandSchema>([
388390
["index", graphCommandSchema({ kind: "any" })],
389391
[
390392
"init",
391-
commandSchema([...SHARED_BUILD_FLAGS, "--json", "--force"], LIFECYCLE_BUILD_OPTIONS, {
393+
commandSchema([...SHARED_BUILD_FLAGS, "--json", "--force", "--no-update-gitignore"], LIFECYCLE_BUILD_OPTIONS, {
392394
kind: "max",
393395
max: 1,
394-
usage: "Usage: codegraph init [path] [--force] [--json] OR codegraph init --root <path> [--force] [--json]",
396+
usage:
397+
"Usage: codegraph init [path] [--force] [--no-update-gitignore] [--json] OR codegraph init --root <path> [--force] [--no-update-gitignore] [--json]",
395398
}),
396399
],
397400
[
@@ -526,10 +529,11 @@ const CLI_COMMAND_SCHEMAS = new Map<string, CliCommandSchema>([
526529
],
527530
[
528531
"sync",
529-
commandSchema([...SHARED_BUILD_FLAGS, "--json", "--init"], LIFECYCLE_BUILD_OPTIONS, {
532+
commandSchema([...SHARED_BUILD_FLAGS, "--json", "--init", "--no-update-gitignore"], LIFECYCLE_BUILD_OPTIONS, {
530533
kind: "max",
531534
max: 1,
532-
usage: "Usage: codegraph sync [path] [--init] [--json] OR codegraph sync --root <path> [--init] [--json]",
535+
usage:
536+
"Usage: codegraph sync [path] [--init] [--no-update-gitignore] [--json] OR codegraph sync --root <path> [--init] [--no-update-gitignore] [--json]",
533537
}),
534538
],
535539
[
@@ -589,6 +593,10 @@ export function validateCliArgs(command: string, parsed: ParsedCliArgs): void {
589593
}
590594
}
591595

596+
if (command === "sync" && parsed.flags.has("--no-update-gitignore") && !parsed.flags.has("--init")) {
597+
throw new Error("--no-update-gitignore for sync requires --init.");
598+
}
599+
592600
if (schema.positionals.kind === "none" && parsed.positionals.length) {
593601
throw new Error(
594602
`Unexpected positional argument for ${command}: ${parsed.positionals[0]!}\n${schema.positionals.usage}`,

src/lifecycle/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class CodegraphLifecycleUserError extends Error {
2+
override name = "CodegraphLifecycleUserError";
3+
}

src/lifecycle/gitignore.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Stats } from "node:fs";
2+
import fsp from "node:fs/promises";
3+
import path from "node:path";
4+
import { isGitPathIgnored, isGitPathTracked, isGitRepo } from "../util/git.js";
5+
import { CodegraphLifecycleUserError } from "./errors.js";
6+
7+
const LIFECYCLE_MANIFEST_PATH = ".codegraph/manifest.json";
8+
const GITIGNORE_PATH = ".gitignore";
9+
const GITIGNORE_RULE = ".codegraph/";
10+
11+
export type CodegraphLifecycleGitignoreResult = {
12+
status: "added" | "already-ignored" | "tracked" | "not-git" | "disabled";
13+
path: ".gitignore";
14+
};
15+
16+
export async function prepareCodegraphLifecycleGitignore(
17+
root: string,
18+
options: { updateGitignore?: boolean } = {},
19+
): Promise<CodegraphLifecycleGitignoreResult> {
20+
const { updateGitignore = true } = options;
21+
if (!updateGitignore) return { status: "disabled", path: GITIGNORE_PATH };
22+
23+
const resolvedRoot = path.resolve(root);
24+
if (!(await isGitRepo(resolvedRoot))) return { status: "not-git", path: GITIGNORE_PATH };
25+
if (await isGitPathTracked(resolvedRoot, LIFECYCLE_MANIFEST_PATH)) {
26+
return { status: "tracked", path: GITIGNORE_PATH };
27+
}
28+
if (await isGitPathIgnored(resolvedRoot, LIFECYCLE_MANIFEST_PATH)) {
29+
return { status: "already-ignored", path: GITIGNORE_PATH };
30+
}
31+
32+
const gitignorePath = path.join(resolvedRoot, GITIGNORE_PATH);
33+
let existing = "";
34+
let stats: Stats | undefined;
35+
try {
36+
stats = await fsp.lstat(gitignorePath);
37+
} catch (error) {
38+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
39+
const detail = error instanceof Error ? error.message : String(error);
40+
throw new CodegraphLifecycleUserError(
41+
`Unable to inspect ${gitignorePath}: ${detail}. Check the path and permissions or rerun with --no-update-gitignore.`,
42+
);
43+
}
44+
}
45+
46+
if (stats) {
47+
if (!stats.isFile()) {
48+
let kind = "non-regular file";
49+
if (stats.isDirectory()) kind = "directory";
50+
if (stats.isSymbolicLink()) kind = "symbolic link";
51+
throw new CodegraphLifecycleUserError(
52+
`Cannot update ${gitignorePath}: expected a regular file, but found a ${kind}. ` +
53+
"Replace it with a regular file or rerun with --no-update-gitignore.",
54+
);
55+
}
56+
try {
57+
existing = await fsp.readFile(gitignorePath, "utf8");
58+
} catch (error) {
59+
const detail = error instanceof Error ? error.message : String(error);
60+
throw new CodegraphLifecycleUserError(
61+
`Unable to read ${gitignorePath}: ${detail}. Check file permissions or rerun with --no-update-gitignore.`,
62+
);
63+
}
64+
}
65+
66+
const newline = existing.includes("\r\n") ? "\r\n" : "\n";
67+
let suffix = `${GITIGNORE_RULE}${newline}`;
68+
if (existing && !existing.endsWith("\n")) suffix = `${newline}${suffix}`;
69+
try {
70+
await fsp.appendFile(gitignorePath, suffix, "utf8");
71+
} catch (error) {
72+
const detail = error instanceof Error ? error.message : String(error);
73+
throw new CodegraphLifecycleUserError(
74+
`Unable to update ${gitignorePath}: ${detail}. Check file permissions or rerun with --no-update-gitignore.`,
75+
);
76+
}
77+
return { status: "added", path: GITIGNORE_PATH };
78+
}

src/lifecycle/manifest.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
} from "../indexer/build-cache/options.js";
1313
import type { BuildOptions } from "../indexer/types.js";
1414
import type { AnalysisSummary } from "../analysisSummary.js";
15+
import { CodegraphLifecycleUserError } from "./errors.js";
16+
import { prepareCodegraphLifecycleGitignore, type CodegraphLifecycleGitignoreResult } from "./gitignore.js";
1517

1618
export type CodegraphLifecycleManifest = {
1719
schemaVersion: 1;
@@ -44,6 +46,9 @@ export type CodegraphLifecycleStatus = {
4446
suggestedNextCommand: string;
4547
};
4648

49+
export type { CodegraphLifecycleGitignoreResult } from "./gitignore.js";
50+
export { CodegraphLifecycleUserError } from "./errors.js";
51+
4752
export type CodegraphLifecycleSyncResult = {
4853
schemaVersion: 1;
4954
root: string;
@@ -55,6 +60,7 @@ export type CodegraphLifecycleSyncResult = {
5560
removed: number;
5661
totalDelta: number;
5762
};
63+
gitignore?: CodegraphLifecycleGitignoreResult;
5864
};
5965

6066
export type CodegraphLifecycleUninitResult = {
@@ -74,18 +80,17 @@ type LifecycleBuildOptionsSummary = ManifestBuildOptions & {
7480
};
7581
const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]);
7682

77-
export class CodegraphLifecycleUserError extends Error {
78-
override name = "CodegraphLifecycleUserError";
79-
}
80-
8183
export function codegraphLifecycleManifestPath(root: string): string {
8284
return path.join(root, CODEGRAPH_DIR, MANIFEST_FILE);
8385
}
8486

8587
export async function initCodegraphLifecycle(
8688
root: string,
87-
options: { buildOptions?: BuildOptions; force?: boolean } = {},
89+
options: { buildOptions?: BuildOptions; force?: boolean; updateGitignore?: boolean } = {},
8890
): Promise<CodegraphLifecycleSyncResult> {
91+
const gitignore = await prepareCodegraphLifecycleGitignore(root, {
92+
updateGitignore: options.updateGitignore ?? true,
93+
});
8994
const existing = await readLifecycleManifest(root, options.force ? { allowInvalid: true } : {});
9095
if (existing && !options.force) {
9196
const status = await getCodegraphLifecycleStatus(root, options);
@@ -97,17 +102,32 @@ export async function initCodegraphLifecycle(
97102
manifestPath: codegraphLifecycleManifestPath(root),
98103
manifest: existing,
99104
changedFiles: { added: 0, removed: 0, totalDelta: 0 },
105+
gitignore,
100106
};
101107
}
102108
}
103-
return await syncCodegraphLifecycle(root, { ...options, init: true });
109+
return await syncCodegraphLifecycleCore(root, { ...options, init: true }, existing, gitignore);
104110
}
105111

106112
export async function syncCodegraphLifecycle(
107113
root: string,
108-
options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean } = {},
114+
options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean; updateGitignore?: boolean } = {},
109115
): Promise<CodegraphLifecycleSyncResult> {
116+
const gitignore = options.init
117+
? await prepareCodegraphLifecycleGitignore(root, {
118+
updateGitignore: options.updateGitignore ?? true,
119+
})
120+
: undefined;
110121
const existing = await readLifecycleManifest(root, { allowInvalid: Boolean(options.init && options.force) });
122+
return await syncCodegraphLifecycleCore(root, options, existing, gitignore);
123+
}
124+
125+
async function syncCodegraphLifecycleCore(
126+
root: string,
127+
options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean },
128+
existing: CodegraphLifecycleManifest | null,
129+
gitignore?: CodegraphLifecycleGitignoreResult,
130+
): Promise<CodegraphLifecycleSyncResult> {
111131
if (!existing && !options.init) {
112132
throw new CodegraphLifecycleUserError(
113133
"Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init.",
@@ -129,6 +149,7 @@ export async function syncCodegraphLifecycle(
129149
manifestPath: codegraphLifecycleManifestPath(root),
130150
manifest,
131151
changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, fallbackTotalDelta),
152+
...(gitignore ? { gitignore } : {}),
132153
};
133154
}
134155

src/util/git.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,27 @@ export async function isGitRepo(projectRoot: string): Promise<boolean> {
6262
}
6363
}
6464

65+
export async function isGitPathTracked(projectRoot: string, file: string): Promise<boolean> {
66+
return await runGitPathPredicate(projectRoot, ["ls-files", "--error-unmatch", "--", normalizePath(file)]);
67+
}
68+
69+
export async function isGitPathIgnored(projectRoot: string, file: string): Promise<boolean> {
70+
return await runGitPathPredicate(projectRoot, ["check-ignore", "--quiet", "--no-index", "--", normalizePath(file)]);
71+
}
72+
73+
async function runGitPathPredicate(projectRoot: string, args: string[]): Promise<boolean> {
74+
try {
75+
await execFileAsync("git", args, {
76+
cwd: projectRoot,
77+
env: process.env,
78+
});
79+
return true;
80+
} catch (error) {
81+
if (typeof error === "object" && error !== null && "code" in error && error.code === 1) return false;
82+
throw createGitError(projectRoot, args, error);
83+
}
84+
}
85+
6586
export async function getGitBlobHash(
6687
projectRoot: string,
6788
file: string,
@@ -200,7 +221,7 @@ export async function listChangedFiles(
200221
}
201222
return Array.from(new Set(out));
202223
} catch (error) {
203-
throw createGitDiffError(projectRoot, args, error);
224+
throw createGitError(projectRoot, args, error);
204225
}
205226
}
206227

@@ -235,11 +256,11 @@ export async function getUnifiedDiff(
235256
});
236257
return stdout;
237258
} catch (error) {
238-
throw createGitDiffError(projectRoot, args, error);
259+
throw createGitError(projectRoot, args, error);
239260
}
240261
}
241262

242-
function createGitDiffError(projectRoot: string, args: string[], error: unknown): Error {
263+
function createGitError(projectRoot: string, args: string[], error: unknown): Error {
243264
let detail = stringifyUnknown(error);
244265
if (
245266
typeof error === "object" &&

0 commit comments

Comments
 (0)