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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
*.env
dist/
/coverage/
.codegraph/
.codegraph-cache/
codegraph.json
target/
Expand Down
9 changes: 8 additions & 1 deletion codegraph-skill/codegraph/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`, 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 <path>`, never both. Automatic ignore updates are bound to that same resolved project root.

## Installation

Expand Down
9 changes: 6 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`. 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 <path>`. 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

Expand Down
12 changes: 7 additions & 5 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> [--force] [--json]
codegraph init [path] [--force] [--no-update-gitignore] [--json]
codegraph init --root <path> [--force] [--no-update-gitignore] [--json]
codegraph status [path] [--json]
codegraph status --root <path> [--json]
codegraph sync [path] [--init] [--json]
codegraph sync --root <path> [--init] [--json]
codegraph sync [path] [--init] [--no-update-gitignore] [--json]
codegraph sync --root <path> [--init] [--no-update-gitignore] [--json]
codegraph uninit [path] [--force] [--json]
codegraph uninit --root <path> [--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.
`;

Expand Down
13 changes: 12 additions & 1 deletion src/cli/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import path from "node:path";
import type { BuildOptions } from "../indexer/types.js";
import {
getCodegraphLifecycleStatus,
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 13 additions & 5 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ const CLI_VALUE_OPTIONS = new Set<string>([
]);

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[];
Expand Down Expand Up @@ -388,10 +390,11 @@ const CLI_COMMAND_SCHEMAS = new Map<string, CliCommandSchema>([
["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 <path> [--force] [--json]",
usage:
"Usage: codegraph init [path] [--force] [--no-update-gitignore] [--json] OR codegraph init --root <path> [--force] [--no-update-gitignore] [--json]",
}),
],
[
Expand Down Expand Up @@ -526,10 +529,11 @@ const CLI_COMMAND_SCHEMAS = new Map<string, CliCommandSchema>([
],
[
"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 <path> [--init] [--json]",
usage:
"Usage: codegraph sync [path] [--init] [--no-update-gitignore] [--json] OR codegraph sync --root <path> [--init] [--no-update-gitignore] [--json]",
}),
],
[
Expand Down Expand Up @@ -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}`,
Expand Down
3 changes: 3 additions & 0 deletions src/lifecycle/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class CodegraphLifecycleUserError extends Error {
override name = "CodegraphLifecycleUserError";
}
78 changes: 78 additions & 0 deletions src/lifecycle/gitignore.ts
Original file line number Diff line number Diff line change
@@ -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<CodegraphLifecycleGitignoreResult> {
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 };
}
35 changes: 28 additions & 7 deletions src/lifecycle/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -55,6 +60,7 @@ export type CodegraphLifecycleSyncResult = {
removed: number;
totalDelta: number;
};
gitignore?: CodegraphLifecycleGitignoreResult;
};

export type CodegraphLifecycleUninitResult = {
Expand All @@ -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<CodegraphLifecycleSyncResult> {
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);
Expand All @@ -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<CodegraphLifecycleSyncResult> {
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<CodegraphLifecycleSyncResult> {
if (!existing && !options.init) {
throw new CodegraphLifecycleUserError(
"Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init.",
Expand All @@ -129,6 +149,7 @@ export async function syncCodegraphLifecycle(
manifestPath: codegraphLifecycleManifestPath(root),
manifest,
changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, fallbackTotalDelta),
...(gitignore ? { gitignore } : {}),
};
}

Expand Down
27 changes: 24 additions & 3 deletions src/util/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,27 @@ export async function isGitRepo(projectRoot: string): Promise<boolean> {
}
}

export async function isGitPathTracked(projectRoot: string, file: string): Promise<boolean> {
return await runGitPathPredicate(projectRoot, ["ls-files", "--error-unmatch", "--", normalizePath(file)]);
}

export async function isGitPathIgnored(projectRoot: string, file: string): Promise<boolean> {
return await runGitPathPredicate(projectRoot, ["check-ignore", "--quiet", "--no-index", "--", normalizePath(file)]);
}

async function runGitPathPredicate(projectRoot: string, args: string[]): Promise<boolean> {
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,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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" &&
Expand Down
12 changes: 12 additions & 0 deletions tests/cli-options-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});
});
Loading