Files
openclaw/src/sessions/session-diff-revisions.ts
T
Peter Steinberger 85a176b98a feat(ui): commit-scoped session diff viewer with file, sync, and view menus (#122470)
* feat(ui): commit-scoped session diff viewer with file, sync, and view menus

The Control UI session diff panel becomes a dense Amp-style viewer: per-file
menus (copy path, open file, reveal in file tree, open in editor), a Sync
Locally popover with a copyable git fetch command, view options (collapse all,
wrapping, split/unified layout), and a sticky footer that reports how far the
branch is ahead of its merge base and switches between all changes,
uncommitted work, and individual commits.

sessions.diff gains an additive scope param (all | uncommitted | commit) plus
commits, aheadCount, and mergeBase metadata. Commit-scope diffs read only the
object database, skip untracked collection, and bypass session-start baseline
filtering; unknown commits surface a typed unavailableReason. Offscreen file
bodies use content-visibility so large diffs stay responsive.

* fix(ui): satisfy session diff CI gates

* fix(gateway): fence commit-scoped session diffs to the advertised branch history

ClawSweeper found that commit-scoped sessions.diff accepted any commit resolvable in the checkout. Fence operator.read commit reads to the advertised merge-base..HEAD history and cover sibling-branch and base-history commits.
2026-08-12 01:08:33 -07:00

109 lines
3.8 KiB
TypeScript

import type { SessionsDiffResult } from "../../packages/gateway-protocol/src/index.js";
import { runGit } from "../agents/worktrees/git.js";
type GitOutput = (
cwd: string,
args: string[],
okCodes?: readonly number[],
) => Promise<string | null>;
/** Picks the merge base used for branch-relative session diffs. */
export async function resolveSessionDiffBase(params: {
branch: string | undefined;
gitOut: GitOutput;
root: string;
}): Promise<{ base: string; baseRef: string }> {
const defaultRef = await params.gitOut(params.root, [
"symbolic-ref",
"--short",
"refs/remotes/origin/HEAD",
]);
const remoteDefault = defaultRef?.trim() || null;
const defaultShort = remoteDefault?.replace(/^origin\//, "");
if (remoteDefault && defaultShort && params.branch && params.branch !== defaultShort) {
const mergeBase = await params.gitOut(params.root, ["merge-base", remoteDefault, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: defaultShort };
}
}
// Plain clones without origin/HEAD still get a branch-relative diff.
if (params.branch && params.branch !== "main" && params.branch !== "master") {
for (const candidate of ["main", "master"]) {
const verified = await params.gitOut(params.root, [
"rev-parse",
"--verify",
"--quiet",
candidate,
]);
if (verified?.trim()) {
const mergeBase = await params.gitOut(params.root, ["merge-base", candidate, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: candidate };
}
}
}
}
return { base: "HEAD", baseRef: "HEAD" };
}
/** Resolves the repository-format-specific empty tree without writing it. */
export async function resolveSessionDiffEmptyTree(
root: string,
): Promise<{ base: string; baseRef?: string } | null> {
try {
const result = await runGit(root, ["hash-object", "-t", "tree", "--stdin"], { input: "" });
const emptyTree = result.code === 0 ? result.stdout.trim() : "";
return emptyTree ? { base: emptyTree } : null;
} catch {
return null;
}
}
type BranchDiffMetadata = Pick<SessionsDiffResult, "aheadCount" | "commits" | "mergeBase">;
function parseCommitRecord(line: string): NonNullable<SessionsDiffResult["mergeBase"]> | undefined {
const separator = line.indexOf("\0");
if (separator <= 0) {
return undefined;
}
return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
}
function parseCommitRecords(text: string): NonNullable<SessionsDiffResult["commits"]> {
return text
.split("\n")
.map(parseCommitRecord)
.filter(
(record): record is NonNullable<SessionsDiffResult["mergeBase"]> => record !== undefined,
);
}
/** Loads the bounded branch history metadata shared by every diff scope. */
export async function loadSessionDiffBranchMetadata(params: {
base: string;
gitOut: GitOutput;
head: string;
root: string;
}): Promise<BranchDiffMetadata> {
if (params.base === "HEAD" || params.base === params.head) {
return {};
}
const range = `${params.base}..HEAD`;
const [aheadText, commitsText, mergeBaseText] = await Promise.all([
params.gitOut(params.root, ["rev-list", "--count", range]),
params.gitOut(params.root, ["log", "--max-count=50", "--format=%h%x00%s", range, "--"]),
params.gitOut(params.root, ["show", "--no-patch", "--format=%h%x00%s", params.base, "--"]),
]);
const normalizedAhead = aheadText?.trim();
const aheadCount =
normalizedAhead && /^\d+$/.test(normalizedAhead)
? Number.parseInt(normalizedAhead, 10)
: undefined;
const mergeBase = mergeBaseText ? parseCommitRecords(mergeBaseText)[0] : undefined;
return {
...(aheadCount !== undefined ? { aheadCount } : {}),
...(commitsText !== null ? { commits: parseCommitRecords(commitsText) } : {}),
...(mergeBase ? { mergeBase } : {}),
};
}