/** * Local git diff source: compare the current branch against a base branch, * the same "merge-base diff" semantics GitHub/GitLab use for PR/MR diffs — * no forge, no network call, works on any repo. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); export class GitError extends Error {} async function gitRaw(args: string[], cwd = process.cwd()): Promise { try { const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 64 }); return stdout; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new GitError(`git ${args.join(" ")} failed: ${message}`); } } async function git(args: string[], cwd = process.cwd()): Promise { return (await gitRaw(args, cwd)).trim(); } /** `git diff --no-index` returns 1 when it successfully finds differences. */ async function gitDiffNoIndex(args: string[], cwd: string): Promise { try { const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 64 }); return stdout; } catch (error) { const result = error as Error & { code?: number | string; stdout?: string | Buffer }; if (Number(result.code) === 1) return typeof result.stdout === "string" ? result.stdout : result.stdout?.toString() ?? ""; throw new GitError(`git ${args.join(" ")} failed: ${result.message}`); } } async function refExists(ref: string, cwd = process.cwd()): Promise { try { await git(["rev-parse", "--verify", "--quiet", ref], cwd); return true; } catch { return false; } } /** Best-effort default base branch: origin/HEAD's target, else main, else master. */ async function detectBase(cwd = process.cwd()): Promise { try { const symbolic = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd); const branch = symbolic.replace(/^refs\/remotes\//, ""); if (branch && (await refExists(branch, cwd))) return branch; } catch { // no remote HEAD configured; fall through } for (const candidate of ["origin/main", "main", "origin/master", "master"]) { if (await refExists(candidate, cwd)) return candidate; } throw new GitError("Could not detect a default base branch (tried origin/HEAD, main, master). Pass one explicitly: /diff "); } export interface LocalReviewMeta { id: number; title: string; sourceBranch: string; targetBranch: string; webUrl: string; repo: string; kind: "branch" | "working-tree"; } export interface LocalReviewData { meta: LocalReviewMeta; diffText: string; } /** Fetch a merge-base diff between `base` (or an auto-detected default) and HEAD. */ export async function fetchLocalDiff(base?: string, cwd = process.cwd()): Promise { const repoRoot = await git(["rev-parse", "--show-toplevel"], cwd); const currentBranch = await git(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot); const resolvedBase = base ?? (await detectBase(repoRoot)); if (!(await refExists(resolvedBase, repoRoot))) { throw new GitError(`Base ref "${resolvedBase}" does not exist.`); } // Three-dot diff: compare HEAD against the merge-base of (base, HEAD), i.e. // "what does this branch add", ignoring unrelated changes on base since divergence. const diffText = await git(["diff", "--no-color", `${resolvedBase}...HEAD`], repoRoot); return { diffText, meta: { id: 0, title: `${currentBranch} → ${resolvedBase}`, sourceBranch: currentBranch, targetBranch: resolvedBase, webUrl: "", repo: repoRoot, kind: "branch", }, }; } async function workingTreeName(repoRoot: string): Promise { try { // Works on an unborn branch too, where rev-parse HEAD does not. return await git(["symbolic-ref", "--short", "HEAD"], repoRoot); } catch { return `detached@${await git(["rev-parse", "--short", "HEAD"], repoRoot)}`; } } /** * Compare HEAD with the final working tree. This naturally combines staged and * unstaged edits to tracked files; untracked, non-ignored files are appended as * `/dev/null` new-file diffs so they appear in the same panel. Before the first * commit, the index/worktree is compared with Git's empty tree instead. */ export async function fetchWorkingTreeDiff(cwd = process.cwd()): Promise { const repoRoot = await git(["rev-parse", "--show-toplevel"], cwd); const currentBranch = await workingTreeName(repoRoot); const comparisonTree = (await refExists("HEAD", repoRoot)) ? "HEAD" : await git(["hash-object", "-t", "tree", "/dev/null"], repoRoot); const trackedDiff = await gitRaw(["-c", "core.quotePath=false", "diff", "--no-color", comparisonTree, "--"], repoRoot); const untrackedOutput = await gitRaw(["ls-files", "--others", "--exclude-standard", "-z"], repoRoot); const untrackedPaths = untrackedOutput.split("\0").filter(Boolean); const untrackedDiffs: string[] = []; // Run sequentially: repositories can contain thousands of untracked files, // and spawning one git process per file concurrently can exhaust descriptors. for (const path of untrackedPaths) { untrackedDiffs.push( await gitDiffNoIndex(["-c", "core.quotePath=false", "diff", "--no-index", "--no-color", "--", "/dev/null", path], repoRoot), ); } const diffText = [trackedDiff, ...untrackedDiffs].filter((part) => part.trim()).join("\n"); return { diffText, meta: { id: 0, title: `${currentBranch} · working tree`, sourceBranch: currentBranch, targetBranch: "WORKTREE", webUrl: "", repo: repoRoot, kind: "working-tree", }, }; }