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:
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user