/** * Coding-graph git invocation discipline. * * Mirrors the argv-array + bounded-timeout + never-throws pattern from * `packages/remnic-core/src/coding/git-context.ts` (rule 10: every git call * is argv + env, never string interpolation; rule 34: tagged failure codes). * * The coding-graph package owns its own invoker rather than importing * core's GitContextResolver because: * - The resolver's public surface (resolveGitContext) is project-level * detection, not diff/name-status/log plumbing. * - The reindex pipeline needs `git diff --name-status`, `git rev-parse`, * `git log --name-only`, and `git diff` (hunk headers) — none of which * the resolver exposes. * - Keeping the invoker local lets the package stay self-contained for * its git needs while still consuming the shared `launchProcessSync` * from `@remnic/core/runtime/child-process` (rule 23/38 — do not invent * a new process-spawning pattern). * * Never throws. Every failure is a tagged `GitFailure` so callers can * surface `git_unavailable` / `unreachable_head` distinctly (rule 34 + * issue #1553 pitfall 34). */ import { launchProcessSync } from "@remnic/core/runtime/child-process"; // ────────────────────────────────────────────────────────────────────────── // Public types // ────────────────────────────────────────────────────────────────────────── /** A line from `git diff --name-status `. */ export interface NameStatusEntry { /** * `git diff --name-status` status code: `A` (added), `M` (modified), * `D` (deleted), `R100` (renamed, 100% similarity), `C75` (copied, 75%), * etc. Renames carry the NEW path in `path` and the OLD path in * `oldPath`. */ readonly status: string; /** Repo-relative forward-slash path of the file in the NEW tree. */ readonly path: string; /** For renames/copies (`R*`/`C*`), the source path. Otherwise `undefined`. */ readonly oldPath?: string; } /** A hunk header from `git diff --unified=0` (`@@ -a,b +c,d @@`). */ export interface DiffHunk { /** File path (repo-relative, forward slashes). */ readonly path: string; /** * Half-open `[startLine, endLine)` line range in the NEW (post-change) * version of the file. `startLine` is 1-based; `endLine` is exclusive. * A single-line change has `endLine === startLine + 1`. */ readonly newRange: { readonly startLine: number; readonly endLine: number }; } /** A co-change commit entry from `git log --name-only`. */ export interface LogFilesEntry { /** Full commit SHA (40 hex chars). */ readonly sha: string; /** Files changed in this commit (repo-relative, forward slashes, deduped). */ readonly files: readonly string[]; } /** * Tagged failure — `code` is the load-bearing signal for programmatic * detection (rule 34). Never carries `error.message` (which may contain * absolute paths — rule 11). */ export interface GitFailure { readonly ok: false; readonly code: "git_unavailable" | "unreachable_head" | "git_error"; } // ────────────────────────────────────────────────────────────────────────── // Invoker // ────────────────────────────────────────────────────────────────────────── /** * Injectable git-invocation surface. Each method runs a specific git * subcommand with argv arrays (never string interpolation — rule 10) and * returns parsed output or a tagged failure. Tests inject a mock to avoid * spawning a real git process against synthetic fixture repos. * * The default implementation uses `launchProcessSync` with a 2-second * timeout (matching `git-context.ts`'s `DEFAULT_GIT_TIMEOUT_MS`). */ export interface CodingGitInvoker { /** * `git rev-parse HEAD` — the current HEAD SHA. Returns `null` when HEAD * does not exist (empty repo / no commits yet). */ revParseHead(cwd: string): { ok: true; head: string | null } | GitFailure; /** * `git rev-parse --verify ^{commit}` — check whether a prior head * is still reachable. Used to detect rebase/force-push scenarios * (issue #1553 pitfall 34: `unreachable_head`). */ isReachable( cwd: string, ref: string, ): { ok: true; reachable: boolean } | GitFailure; /** * `git diff --name-status ` — the changed files between two * commits (or between a commit and HEAD). Returns one entry per file. */ diffNameStatus( cwd: string, range: string, ): { ok: true; entries: readonly NameStatusEntry[] } | GitFailure; /** * Working-tree diff hunks with zero context lines. Captures BOTH staged * and unstaged changes (compared against HEAD) so `detect_changes` maps * every uncommitted edit to a symbol span. Each hunk carries its * new-version line range. */ diffHunks( cwd: string, paths: readonly string[], ): { ok: true; hunks: readonly DiffHunk[] } | GitFailure; /** * `git log --name-only --format=%H -n ` — commit SHAs + their * changed files over a bounded window. Used by co-change mining. */ logFiles( cwd: string, limit: number, ): { ok: true; entries: readonly LogFilesEntry[] } | GitFailure; /** * `git ls-files` — the repo's tracked files as repo-relative forward-slash * paths. Used by the codegraph runtime to source `candidatePaths` for a * full reindex (issue #1554: the executor treats an omitted candidate list * as non-authoritative and no-ops, so the runtime must supply one). * Returns an empty path list for a repo with no tracked files; a git * failure degrades to `{ ok: false, code: "git_unavailable" | "git_error" }`. */ listTrackedFiles( cwd: string, ): { ok: true; paths: readonly string[] } | GitFailure; } // ────────────────────────────────────────────────────────────────────────── // Default implementation — real git via launchProcessSync // ────────────────────────────────────────────────────────────────────────── const GIT_TIMEOUT_MS = 2_000; /** * Run `git ` synchronously with a bounded timeout. Never throws — * spawn failures (git not on PATH, timeout) surface as * `{ ok: false, code: "git_unavailable" }` so callers can degrade. */ function runGit( cwd: string, args: readonly string[], ): { ok: true; stdout: string; exitCode: number } | GitFailure { const result = launchProcessSync("git", [...args], { cwd, encoding: "utf-8", timeout: GIT_TIMEOUT_MS, shell: false, }); if (result.error) { return { ok: false, code: "git_unavailable" }; } return { ok: true, stdout: typeof result.stdout === "string" ? result.stdout : "", exitCode: typeof result.status === "number" ? result.status : 1, }; } /** * Construct the default git invoker that spawns real `git` via * `launchProcessSync`. Tests inject a mock instead. */ export function defaultCodingGitInvoker(): CodingGitInvoker { return { revParseHead(cwd: string) { const r = runGit(cwd, ["rev-parse", "HEAD"]); if (!r.ok) return r; const trimmed = r.stdout.trim(); if (r.exitCode === 0 && trimmed.length > 0) { return { ok: true, head: trimmed }; } // Non-zero exit: could be an UNBORN repo (no commits yet, HEAD is a // symbolic ref to a non-existent branch) OR a directory that is not // a git worktree at all. Distinguish them so a misconfigured workspace // surfaces as a git failure instead of silently looking like an empty // repo (chatgpt-codex-connector: 'Return a git failure for non- // repository HEAD lookups'). const wt = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]); if (wt.ok && wt.exitCode === 0 && wt.stdout.trim() === "true") { // Inside a worktree but HEAD has no commits → unborn, head null. return { ok: true, head: null }; } // Not a git worktree (or git failed to answer) → degrade visibly. return { ok: false, code: "git_error" }; }, isReachable(cwd: string, ref: string) { const r = runGit(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]); if (!r.ok) return r; return { ok: true, reachable: r.exitCode === 0 }; }, diffNameStatus(cwd: string, range: string) { const r = runGit(cwd, ["diff", "--name-status", range]); if (!r.ok) return r; if (r.exitCode !== 0) { return { ok: false, code: "git_error" }; } return { ok: true, entries: parseNameStatus(r.stdout) }; }, diffHunks(cwd: string, paths: readonly string[]) { // `git diff HEAD` compares the working tree (staged + unstaged) // against the last commit, so BOTH staged and unstaged edits are // captured (chatgpt-codex-connector: 'Include staged hunks in // diffHunks'). Plain `git diff` would miss staged-only changes. // --unified=0 → zero context lines so hunks are tight. // --no-color → no ANSI escape codes in output. // -- separator before paths to disambiguate refs from paths. const pathArgs: string[] = []; if (paths.length > 0) { pathArgs.push("--"); for (const p of paths) pathArgs.push(p); } const r = runGit(cwd, ["diff", "HEAD", "--unified=0", "--no-color", ...pathArgs]); if (!r.ok) return r; if (r.exitCode !== 0) { // HEAD may not exist (unborn repo, no commits yet). Fall back to // unstaged-only diff — an acceptable degradation for a repo that // has never been indexed (there are no committed symbols anyway). const fallback = runGit(cwd, ["diff", "--unified=0", "--no-color", ...pathArgs]); if (!fallback.ok) return fallback; if (fallback.exitCode !== 0) { return { ok: false, code: "git_error" }; } return { ok: true, hunks: parseHunks(fallback.stdout) }; } return { ok: true, hunks: parseHunks(r.stdout) }; }, logFiles(cwd: string, limit: number) { const r = runGit(cwd, [ "log", `--format=%H`, "--name-only", `-n`, String(limit), ]); if (!r.ok) return r; if (r.exitCode !== 0) { return { ok: false, code: "git_error" }; } return { ok: true, entries: parseLogFiles(r.stdout) }; }, listTrackedFiles(cwd: string) { // `git ls-files` lists the repo's tracked files (cached in the index) // as repo-relative forward-slash paths — exactly the candidatePaths // format executeReindex expects. --others is NOT used: only tracked // files belong in an authoritative full-reindex candidate set. const r = runGit(cwd, ["ls-files"]); if (!r.ok) return r; if (r.exitCode !== 0) { return { ok: false, code: "git_error" }; } const paths = r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0); return { ok: true, paths }; }, }; } // ────────────────────────────────────────────────────────────────────────── // Output parsers — pure functions (unit-testable without git) // ────────────────────────────────────────────────────────────────────────── /** * Parse `git diff --name-status` output. Each line is `\t` * for add/modify/delete, or `\t\t` for rename/copy. * * Empty lines are skipped (trailing newline). Lines that do not parse * are skipped rather than crashing the whole diff (rule 44 — a single * bad line should not make the index go stale silently; but a partial * parse is better than a total failure because the executor will fall * back to hash-scan if the file count looks wrong). */ export function parseNameStatus(stdout: string): NameStatusEntry[] { const out: NameStatusEntry[] = []; const lines = stdout.split("\n"); for (const line of lines) { if (line.length === 0) continue; // Split on the first tab; the path may contain spaces but not tabs. const tabIdx = line.indexOf("\t"); if (tabIdx < 0) continue; const status = line.slice(0, tabIdx); const rest = line.slice(tabIdx + 1); // Rename/copy: `R100\told\tnew` — second tab splits old from new. const secondTab = rest.indexOf("\t"); if ( (status.startsWith("R") || status.startsWith("C")) && secondTab >= 0 ) { const oldPath = rest.slice(0, secondTab); const newPath = rest.slice(secondTab + 1); if (oldPath.length > 0 && newPath.length > 0) { out.push({ status, path: newPath, oldPath }); } } else { if (rest.length > 0) { out.push({ status, path: rest }); } } } return out; } /** * Parse `git diff --unified=0` output into per-file hunks. Each hunk * header is `@@ -a,b +c,d @@ `. With `--unified=0` * the OLD range `(a,b)` is not useful for blast-radius (we want the * NEW range). We extract `+c,d` → `{ startLine: c, endLine: c + d }`. * * The file path comes from `diff --git a/path b/path` or * `+++ b/path` lines. We track the current file as we walk. */ export function parseHunks(stdout: string): DiffHunk[] { const out: DiffHunk[] = []; let currentPath: string | null = null; const lines = stdout.split("\n"); for (const line of lines) { // `+++ b/path` — the new-version path (post-rename). Prefer this // over `diff --git` because it correctly handles renames. if (line.startsWith("+++ ")) { if (line.startsWith("+++ /dev/null")) { // The new side of this file is empty → it was DELETED. Clear // currentPath so a following `@@` hunk is not mis-attributed to // the previous file (chatgpt-codex-connector: 'Clear hunk path // for deleted files'). currentPath = null; continue; } // Strip the leading `+++ b/` (or `+++ ` for non-`b/` forms). const raw = line.slice(4); currentPath = raw.startsWith("b/") ? raw.slice(2) : raw; continue; } // `@@ -a,b +c,d @@` — hunk header. if (line.startsWith("@@ ")) { const match = line.match(/\+(\d+)(?:,(\d+))?/); if (!match) continue; // match[1] is guaranteed by the first capture group; match[2] is // optional (omitted when the hunk is a single line). Guard both // with a fallback to avoid unchecked non-null assertions. const startStr = match[1] ?? ""; if (startStr.length === 0) continue; const startLine = parseInt(startStr, 10); const countStr = match[2] ?? ""; const count = countStr.length > 0 ? parseInt(countStr, 10) : 1; if (!currentPath) continue; // A deletion-only hunk emits a zero new-count (e.g. `@@ -2 +1,0 @@`). // An empty [startLine, startLine) range can never overlap a symbol // span, so pure deletions inside a function/class were missed. // Treat zero-count as covering the adjacent new line (chatgpt- // codex-connector: 'Map deletion-only hunks to a non-empty range'). out.push({ path: currentPath, newRange: { startLine, endLine: startLine + Math.max(count, 1), }, }); } } return out; } /** * Parse `git log --format=%H --name-only` output into one entry per * commit. Real git emits a blank line AFTER each SHA (before the file * names) AND between commits, so a naive `\n\n` block split puts the * SHA alone in one block and the file names in the next — the SHA-only * block yields a commit with zero files and the file-name block fails * SHA validation and is dropped. Walk line-by-line instead: a 40-hex * line starts a new commit; subsequent non-empty lines are its files * until the next SHA (chatgpt-codex-connector: 'Parse real git log * records before mining co-changes'). Robust to both the synthetic * (no blank-after-SHA) and real (blank-after-SHA) layouts. */ export function parseLogFiles(stdout: string): LogFilesEntry[] { const out: LogFilesEntry[] = []; let currentSha: string | null = null; let currentFiles: string[] = []; const seenInCommit = new Set(); const flush = (): void => { if (currentSha !== null) { out.push({ sha: currentSha, files: currentFiles }); } currentSha = null; currentFiles = []; seenInCommit.clear(); }; for (const line of stdout.split("\n")) { if (/^[0-9a-f]{40}$/.test(line)) { flush(); currentSha = line; } else if (line.length > 0 && currentSha !== null) { if (!seenInCommit.has(line)) { seenInCommit.add(line); currentFiles.push(line); } } // blank lines are ignored — they do not terminate a commit; the // next SHA does. } flush(); return out; }