Git commit history (#1534)

* feat(app): add fileView preference for changes panel

* feat(app): add buildDiffTree util for changed-files tree

* feat(app): add changed-files tree directory row

* feat(app): changed-files tree view mode in changes panel (#117)

* feat(protocol): add checkout.commits.list RPC + commitsList feature flag

* feat(server): list branch commits ahead of base with on-remote flags

* feat(server): handle checkout.commits.list RPC and advertise capability

* feat(client): checkout.commits.list method + useCommitsQuery hook

* feat(app): per-commit inline view with local-vs-remote markers (#117)

* feat(protocol,server): per-commit file diff RPC (checkout.commits.file_diff)

* feat(app): open per-commit file diff on click (#117)

* refactor: surface baseRef + commits loading/error, drop dead depth field

* fix(app): flip commit local/remote dot — local hollow, remote filled

* feat(app): list/tree view for expanded commit file list (#117)

* fix(app): syntax-highlight per-commit file diff to match Changes view

* feat(app): draggable resize between commits and diff sections

* refactor(app): dedupe wrap-text helpers into diff-highlighted-text

* fix(app): clean up commits resize drag on unmount + a11y label

* fix(app): collapse commits section by default

* fix(app): render per-commit file diff with the shared Changes line renderer

* perf(app): memoize shared diff line row; drop redundant DiffLineView wrapper

* refactor(app): extract shared DiffFileBody; render commit file diff through it

* fix(app): hide inline-comment affordance in per-commit diffs (no reviewActions)

* feat(app): move commits to a resizable bottom drawer in the Changes panel

* feat(review): commitSha scoping for per-commit review drafts + attachment

* feat(app): per-commit inline comments wired through to the composer (#117)

* refactor(app): own commit file-diff open state in CommitFileList (drop reset effect)

* refactor(app): colocate diff-render cluster under git/diff-file-body/

* feat(app): add diff tab target kind (working/commit diff tabs)

* feat(app): useDiffFiles hook unifying working + commit diff targets

* feat(app): diff tab panel rendering working/commit diffs

* feat(app): open a commit diff tab on commit click; drop per-commit drawer/file list

* feat(app): open/scroll working diff tab on changed-file click

* fix(app): keep commit diff tabs ephemeral; align working diff whitespace

* feat(app): collapsible file sections in the diff tab, collapsed by default

* feat(app): make the on-remote commit dot more subtle

Dims the filled green remote dot (row + legend) to ~0.55 opacity so the
local-only ring stays the state that draws the eye.

* Reshape commit diffs around the existing Changes view

* Fix diff tab migration complexity after rebase

* Address diff tab review findings

* Clean up diff tab review interfaces

* Collapse commit diffs into the existing view

* Load commits when expanded

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
adradr
2026-07-16 17:36:41 +02:00
committed by GitHub
co-authored by Mohamed Boudra
parent 5da6548aff
commit 0d3b717cf3
44 changed files with 2843 additions and 439 deletions
@@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => {
expect(serverInfo).not.toBeNull();
expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
expect(serverInfo?.features?.commitsList).toBe(true);
expect(serverInfo?.desktopManaged).toBe(false);
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
expect(serverInfo?.features?.worktreeRestore).toBe(true);
+4
View File
@@ -1620,6 +1620,10 @@ export class Session {
switch (msg.type) {
case "checkout_status_request":
return this.checkoutSession.handleStatusRequest(msg);
case "checkout.commits.list.request":
return this.checkoutSession.handleCommitsListRequest(msg);
case "checkout.commits.file_diff.request":
return this.checkoutSession.handleCommitFileDiffRequest(msg);
case "validate_branch_request":
return this.checkoutSession.handleValidateBranchRequest(msg);
case "branch_suggestions_request":
@@ -1,8 +1,11 @@
import type pino from "pino";
import { isAbsolute } from "node:path";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import type {
BranchSuggestionsRequest,
CheckoutCommitsListRequest,
CheckoutCommitFileDiffRequest,
CheckoutRefreshRequest,
CheckoutRenameBranchRequest,
CheckoutStatusRequest,
@@ -41,6 +44,8 @@ import {
mergeToBase,
pullCurrentBranch,
pushCurrentBranch,
listCheckoutCommits,
getCommitFileDiff,
} from "../../../utils/checkout-git.js";
import { execCommand } from "../../../utils/spawn.js";
import { expandTilde } from "../../../utils/path.js";
@@ -172,6 +177,44 @@ export class CheckoutSession {
}
}
async handleCommitsListRequest(msg: CheckoutCommitsListRequest): Promise<void> {
const { cwd, requestId } = msg;
try {
const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) });
this.host.emit({
type: "checkout.commits.list.response",
payload: { cwd, baseRef, commits, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "checkout.commits.list.response",
payload: { cwd, baseRef: null, commits: [], error: toCheckoutError(error), requestId },
});
}
}
async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise<void> {
const { cwd, sha, path, requestId } = msg;
try {
assertSafeGitRef(sha, "commit");
if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) {
throw new Error(`Invalid path: ${path}`);
}
const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path });
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "checkout.commits.file_diff.response",
payload: { cwd, sha, path, file: null, error: toCheckoutError(error), requestId },
});
}
}
async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise<void> {
const { cwd, branchName, requestId } = msg;
@@ -1262,6 +1262,8 @@ export class VoiceAssistantWebSocketServer {
workspaceGithubRepositorySearch: true,
// COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15.
projectCreateDirectory: true,
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
commitsList: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true,
},
@@ -0,0 +1,91 @@
import { execFileSync } from "child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import { getCommitFileDiff } from "./checkout-git.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeTempDir(): string {
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "commit-file-diff-test-")));
tempDirs.push(dir);
return dir;
}
function git(args: string[], cwd: string): string {
return execFileSync("git", args, { cwd }).toString();
}
function commitFile(repoDir: string, name: string, content: string, message: string): void {
writeFileSync(join(repoDir, name), content);
git(["add", "."], repoDir);
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
}
function initRepo(): string {
const tempDir = makeTempDir();
const repoDir = join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
git(["init", "-b", "main"], repoDir);
git(["config", "user.email", "test@test.com"], repoDir);
git(["config", "user.name", "Test User"], repoDir);
return repoDir;
}
function headSha(repoDir: string): string {
return git(["rev-parse", "HEAD"], repoDir).trim();
}
describe("getCommitFileDiff", () => {
it("returns the parsed diff for a modified file in a commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial");
commitFile(repoDir, "foo.txt", "a\nB\nc\nd\n", "edit foo");
const sha = headSha(repoDir);
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "foo.txt" });
expect(file).not.toBeNull();
expect(file?.path).toBe("foo.txt");
expect(file?.additions).toBe(2);
expect(file?.deletions).toBe(1);
expect(file?.hunks.length).toBeGreaterThan(0);
const lines = file?.hunks[0]?.lines ?? [];
expect(lines.some((line) => line.type === "add" && line.content === "B")).toBe(true);
expect(lines.some((line) => line.type === "add" && line.content === "d")).toBe(true);
expect(lines.some((line) => line.type === "remove" && line.content === "b")).toBe(true);
});
it("returns a diff flagged as new for an added file", async () => {
const repoDir = initRepo();
commitFile(repoDir, "README.md", "base\n", "initial");
commitFile(repoDir, "added.txt", "x\ny\n", "add file");
const sha = headSha(repoDir);
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" });
expect(file?.path).toBe("added.txt");
expect(file?.isNew).toBe(true);
expect(file?.additions).toBe(2);
expect(file?.deletions).toBe(0);
});
it("returns null for a path not changed in the commit", async () => {
const repoDir = initRepo();
commitFile(repoDir, "foo.txt", "a\n", "initial");
commitFile(repoDir, "foo.txt", "a\nb\n", "edit foo");
const sha = headSha(repoDir);
const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "does-not-exist.txt" });
expect(file).toBeNull();
});
});
@@ -0,0 +1,138 @@
import { execFileSync } from "child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { afterEach, describe, expect, it } from "vitest";
import { listCheckoutCommits } from "./checkout-git.js";
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function makeTempDir(): string {
const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-commits-test-")));
tempDirs.push(dir);
return dir;
}
function git(args: string[], cwd?: string): void {
execFileSync("git", args, cwd ? { cwd } : {});
}
function commit(repoDir: string, message: string): void {
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
}
function commitFile(repoDir: string, name: string, content: string, message: string): void {
writeFileSync(join(repoDir, name), content);
git(["add", "."], repoDir);
commit(repoDir, message);
}
function initRepoOnMain(): { repoDir: string; tempDir: string } {
const tempDir = makeTempDir();
const repoDir = join(tempDir, "repo");
mkdirSync(repoDir, { recursive: true });
git(["init", "-b", "main"], repoDir);
git(["config", "user.email", "test@test.com"], repoDir);
git(["config", "user.name", "Test User"], repoDir);
commitFile(repoDir, "README.md", "base\n", "initial");
return { repoDir, tempDir };
}
function addBareRemote(repoDir: string, tempDir: string): string {
const remoteDir = join(tempDir, "remote.git");
git(["init", "--bare", "-b", "main", remoteDir]);
git(["remote", "add", "origin", remoteDir], repoDir);
return remoteDir;
}
describe("listCheckoutCommits", () => {
it("lists commits ahead of base newest-first with on-remote flags and file stats", async () => {
const { repoDir, tempDir } = initRepoOnMain();
git(["checkout", "-b", "feature"], repoDir);
commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo");
// Push feature (containing only commit A) to the remote, then add B locally.
addBareRemote(repoDir, tempDir);
git(["push", "-u", "origin", "feature"], repoDir);
commitFile(repoDir, "bar.txt", "x\n", "Add bar");
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
expect(baseRef).toBe("main");
expect(commits).toHaveLength(2);
expect(commits[0]?.subject).toBe("Add bar");
expect(commits[1]?.subject).toBe("Add foo");
expect(commits[0]?.isOnRemote).toBe(false);
expect(commits[1]?.isOnRemote).toBe(true);
expect(commits[0]?.files).toEqual([
{ path: "bar.txt", additions: 1, deletions: 0, status: "added" },
]);
expect(commits[1]?.files).toEqual([
{ path: "foo.txt", additions: 3, deletions: 0, status: "added" },
]);
expect(commits[0]?.authorName).toBe("Test User");
expect(commits[0]?.sha).toHaveLength(40);
expect((commits[0]?.shortSha.length ?? 0) > 0).toBe(true);
expect(Number.isNaN(new Date(commits[0]?.authorDate ?? "").getTime())).toBe(false);
});
it("returns [] when there are no commits ahead of base", async () => {
const { repoDir } = initRepoOnMain();
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
expect(baseRef).toBeNull();
expect(commits).toEqual([]);
});
it("marks all commits local-only when there is no remote", async () => {
const { repoDir } = initRepoOnMain();
git(["checkout", "-b", "feature"], repoDir);
commitFile(repoDir, "foo.txt", "a\n", "Add foo");
commitFile(repoDir, "bar.txt", "b\n", "Add bar");
const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir });
expect(baseRef).toBe("main");
expect(commits).toHaveLength(2);
expect(commits.every((c) => c.isOnRemote === false)).toBe(true);
});
it("classifies renamed files with status renamed and correct destination path", async () => {
const { repoDir } = initRepoOnMain();
git(["checkout", "-b", "feature"], repoDir);
commitFile(repoDir, "original.txt", "content\n", "Add original");
git(["mv", "original.txt", "renamed.txt"], repoDir);
commit(repoDir, "Rename file");
const { commits } = await listCheckoutCommits({ cwd: repoDir });
expect(commits[0]?.files).toEqual([
{ path: "renamed.txt", additions: 0, deletions: 0, status: "renamed" },
]);
});
it("derives status for modified and deleted files", async () => {
const { repoDir } = initRepoOnMain();
git(["checkout", "-b", "feature"], repoDir);
commitFile(repoDir, "README.md", "base\nmore\n", "Edit readme");
git(["rm", "README.md"], repoDir);
commit(repoDir, "Delete readme");
const { commits } = await listCheckoutCommits({ cwd: repoDir });
expect(commits[0]?.files).toEqual([
{ path: "README.md", additions: 0, deletions: 2, status: "deleted" },
]);
expect(commits[1]?.files).toEqual([
{ path: "README.md", additions: 1, deletions: 0, status: "modified" },
]);
});
});
+318
View File
@@ -2,6 +2,7 @@ import { resolve, dirname, basename } from "path";
import { existsSync, realpathSync } from "fs";
import { open as openFile, readFile, stat as statFile } from "fs/promises";
import { TTLCache } from "@isaacs/ttlcache";
import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages";
import type { Logger } from "pino";
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
@@ -1923,6 +1924,323 @@ export async function getCheckoutStatus(
};
}
// Cap on how many ahead-of-base commits we enumerate. Branches with more than
// this many unmerged commits are truncated to the newest MAX_CHECKOUT_COMMITS.
const MAX_CHECKOUT_COMMITS = 200;
// Bytes git emits between fields/records. We split parsed output on these.
const COMMIT_FIELD_SEPARATOR = "\x00";
const COMMIT_RECORD_SEPARATOR = "\x1e";
// Record-separated, NUL-field-separated so arbitrary subject text stays parseable.
// `%x1e`/`%x00` are git placeholders (literal text in the arg, real bytes in the
// output) — passing actual NUL bytes as a process arg is rejected by Node.
const COMMIT_LOG_FORMAT = "%x1e%H%x00%h%x00%an%x00%aI%x00%s";
type CheckoutCommitFileStatus = NonNullable<CheckoutCommitFile["status"]>;
interface ParsedCheckoutCommit {
sha: string;
shortSha: string;
authorName: string;
authorDate: string;
subject: string;
files: CheckoutCommitFile[];
}
function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined {
switch (letter) {
case "A":
return "added";
case "C":
return "added";
case "M":
return "modified";
case "T":
return "modified";
case "D":
return "deleted";
case "R":
return "renamed";
default:
return undefined;
}
}
// A `--raw` line: `:<srcmode> <dstmode> <srcsha> <dstsha> <STATUS>\t<path>`
// (rename/copy add a second path: `R100\t<old>\t<new>`). The status token is the
// last space-separated field before the first tab. Keyed on the destination path.
function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFileStatus>): void {
const tabParts = line.split("\t");
const meta = tabParts[0] ?? "";
const statusToken = meta.slice(meta.lastIndexOf(" ") + 1);
const letter = statusToken.charAt(0);
const status = mapNameStatusLetter(letter);
if (!status) {
return;
}
const path =
letter === "R" || letter === "C" ? (tabParts[tabParts.length - 1] ?? "") : (tabParts[1] ?? "");
if (!path) {
return;
}
statuses.set(path, status);
}
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
// files report `-` for both counts). Keyed on the (normalized) destination path.
function parseNumstatLine(
line: string,
stats: Map<string, { additions: number; deletions: number }>,
): void {
const parts = line.split("\t");
if (parts.length < 3) {
return;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
const path = normalizeNumstatPath(parts.slice(2).join("\t"));
if (!path) {
return;
}
if (additionsField === "-" || deletionsField === "-") {
stats.set(path, { additions: 0, deletions: 0 });
return;
}
const additions = Number.parseInt(additionsField, 10);
const deletions = Number.parseInt(deletionsField, 10);
if (Number.isNaN(additions) || Number.isNaN(deletions)) {
return;
}
stats.set(path, { additions, deletions });
}
// Parses the single combined `git log ... --raw --numstat -M` stream. Each record
// (split on the record separator) starts with the NUL-field-separated header line,
// then a blank line, then the interleaved `--raw` (status) and `--numstat` (counts)
// blocks. We merge both by destination path so each file carries counts + status.
function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] {
const records = stdout.split(COMMIT_RECORD_SEPARATOR).filter((record) => record.length > 0);
const commits: ParsedCheckoutCommit[] = [];
for (const record of records) {
const lines = record.split("\n");
const fields = (lines[0] ?? "").split(COMMIT_FIELD_SEPARATOR);
if (fields.length < 5) {
continue;
}
const sha = (fields[0] ?? "").trim();
if (!sha) {
continue;
}
const stats = new Map<string, { additions: number; deletions: number }>();
const statuses = new Map<string, CheckoutCommitFileStatus>();
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index] ?? "";
if (!line) {
continue;
}
if (line.startsWith(":")) {
parseRawStatusLine(line, statuses);
} else {
parseNumstatLine(line, stats);
}
}
const files: CheckoutCommitFile[] = [];
for (const [path, stat] of stats) {
const status = statuses.get(path);
files.push({
path,
additions: stat.additions,
deletions: stat.deletions,
...(status ? { status } : {}),
});
}
commits.push({
sha,
shortSha: (fields[1] ?? "").trim(),
authorName: fields[2] ?? "",
authorDate: (fields[3] ?? "").trim(),
subject: fields[4] ?? "",
files,
});
}
return commits;
}
async function resolveCheckoutCommitUpstreamRef(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<string | null> {
// Prefer the branch's configured `@{u}`. If it's configured but the
// remote-tracking ref isn't present locally (e.g. configured upstream that was
// never fetched), fall back to `origin/<branch>` when that ref does exist, so a
// standard `origin` push is still recognized. Both missing => no remote.
const configured = await getConfiguredUpstreamRef(cwd, currentBranch, context);
if (configured && (await doesGitRefExist(cwd, `refs/remotes/${configured}`, context))) {
return configured;
}
if (await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`, context)) {
return `origin/${currentBranch}`;
}
return null;
}
// Returns the set of SHAs on the current branch that are NOT on the upstream
// (local-only/unpushed), or `null` when no upstream exists (no remote at all).
async function getLocalOnlyCommitShas(
cwd: string,
currentBranch: string,
context?: CheckoutContext,
): Promise<Set<string> | null> {
const upstreamRef = await resolveCheckoutCommitUpstreamRef(cwd, currentBranch, context);
if (!upstreamRef) {
return null;
}
const { stdout } = await runGitCommand(["rev-list", `${upstreamRef}..HEAD`], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
logger: context?.logger,
});
return new Set(
stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
);
}
/**
* Lists the current branch's commits that are ahead of its base branch, newest
* first, each flagged local-only vs on-remote with per-commit file +/- stats.
*
* Base ref is resolved exactly like {@link getCheckoutStatus}: the stored/default
* base branch, mapped to the best comparison ref (origin/<base> when present,
* else local <base>). Returns `[]` when the base cannot be resolved, the current
* ref is the base itself, or there are no commits ahead.
*/
export interface CheckoutCommitsResult {
baseRef: string | null;
commits: CheckoutCommit[];
}
export async function listCheckoutCommits({
cwd,
}: {
cwd: string;
}): Promise<CheckoutCommitsResult> {
const currentBranch = await getCurrentBranch(cwd);
if (!currentBranch) {
return { baseRef: null, commits: [] };
}
const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd);
if (!resolvedBaseRef) {
return { baseRef: null, commits: [] };
}
const normalizedBaseRef = normalizeLocalBranchRefName(resolvedBaseRef);
if (!normalizedBaseRef || normalizedBaseRef === currentBranch) {
return { baseRef: null, commits: [] };
}
let comparisonBaseRef: string;
try {
comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef);
} catch {
// Base branch is not present locally or on origin — nothing to compare against.
return { baseRef: null, commits: [] };
}
// Single pass: `--raw` carries the status letter, `--numstat` the +/- counts.
// (`--name-status` cannot be combined with `--numstat` — git emits only one.)
const logResult = await runGitCommand(
[
"log",
`${comparisonBaseRef}..HEAD`,
"--no-merges",
`--max-count=${MAX_CHECKOUT_COMMITS}`,
`--format=${COMMIT_LOG_FORMAT}`,
"--raw",
"--numstat",
"-M",
],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
const records = parseCheckoutCommitRecords(logResult.stdout);
if (records.length === 0) {
return { baseRef: comparisonBaseRef, commits: [] };
}
const localOnlyShas = await getLocalOnlyCommitShas(cwd, currentBranch);
const commits = records.map((record) => ({
sha: record.sha,
shortSha: record.shortSha,
subject: record.subject,
authorName: record.authorName,
authorDate: record.authorDate,
isOnRemote: localOnlyShas === null ? false : !localOnlyShas.has(record.sha),
files: record.files,
}));
return { baseRef: comparisonBaseRef, commits };
}
/**
* Fetches the unified diff of a single file as introduced by one commit and
* parses it into the same {@link ParsedDiffFile} shape the diff subscription
* emits (so the client can reuse its existing renderer).
*
* Runs `git show <sha> --format= -- <path>` with the sha and path passed as
* separate process args (never interpolated into a shell string), and `--format=`
* suppresses the commit header so the output is a pure unified diff. The text is
* parsed and highlighted by {@link parseAndHighlightDiff} — the exact parser the
* diff subscription uses. Returns `null` when the file is absent from the commit
* or the change is binary-only (no textual hunks). Throws on git failure (e.g. an
* unknown sha), which the caller maps to a typed checkout error.
*/
export async function getCommitFileDiff({
cwd,
sha,
path,
}: {
cwd: string;
sha: string;
path: string;
}): Promise<ParsedDiffFile | null> {
const { stdout } = await runGitCommand(["show", sha, "--format=", "--", path], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
if (stdout.trim().length === 0) {
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real
// entry. Pick by path to drop any stray header-only section the parser emits.
const file = parsedFiles.find((candidate) => candidate.path === path) ?? null;
if (!file) {
return null;
}
// Binary changes carry a "Binary files ... differ" marker and no hunks; there
// is nothing textual to render, so report them as absent.
if (file.hunks.length === 0 && /^Binary files .* differ$/m.test(stdout)) {
return null;
}
return file;
}
export interface CheckoutShortstat {
additions: number;
deletions: number;