fix(ui): hide pre-thread changes from session diffs (#115708)

* fix(ui): scope session diffs to thread changes

* docs: refresh generated map
This commit is contained in:
Vincent Koc
2026-07-29 15:17:47 +08:00
committed by GitHub
parent 844329284e
commit dd3d1d1a8b
12 changed files with 1034 additions and 478 deletions

View File

@@ -51,6 +51,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI session diffs:** hide unchanged checkout modifications and untracked files that already existed when a thread started, so the diff panel attributes only files touched by that session. Fixes #115628.
- **Shared state corruption recovery:** evict only the exact cached SQLite owner after proven read or write corruption so a repaired database recovers without a Gateway restart while caller-injected handles remain untouched. Fixes #114269. Thanks @rizquuula.
- **Dev-channel updates:** finish package-to-git switches in a fresh CLI process even when source SHA and version metadata are unchanged, preventing stale hashed chunks from loading after the global package root changes.
- **Parallels release smoke:** preserve Windows installer reboot results across Parallels, wait for WSL MSI/default-version readiness, force explicit test-owned gateway stops, and reset Linux package, config, and cache state before install lanes, preventing false prerequisite, safety-gate, and stale-config failures.

View File

@@ -1282,6 +1282,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings:
- H1: openclaw agent
- H2: agent exec
- H3: Code Mode model matrix
- H3: agent exec options
- H2: Options
- H2: Examples

View File

@@ -19,6 +19,7 @@ import { isSubagentSessionKey } from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { isAgentMediatedCompletionSourceTool } from "../sessions/input-provenance.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { ensureSessionDiffBaseline } from "../sessions/session-diff-baseline.js";
import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js";
import { classifySessionStateActor } from "../sessions/session-state-events.js";
import { sessionDeliveryChannel, type DeliveryContext } from "../utils/delivery-context.shared.js";
@@ -124,6 +125,7 @@ async function agentCommandInternal(
sessionAgentId,
outboundSession,
workspaceDir,
cwd,
runId,
isSubagentLane,
acpManager,
@@ -335,6 +337,26 @@ async function agentCommandInternal(
sessionEntry = persisted;
trackedRestartRecoveryDeliveryClaim = persisted?.restartRecoveryDeliveryRunId === runId;
}
if (sessionEntry && sessionKey && !suppressVisibleSessionEffects) {
try {
sessionEntry = await ensureSessionDiffBaseline({
cwd: cwd ?? workspaceDir,
entry: sessionEntry,
isNewSession,
sessionKey,
storePath,
});
if (sessionStore) {
sessionStore[sessionKey] = sessionEntry;
}
} catch (error) {
log.warn(
`session diff baseline capture failed; continuing without attribution filtering: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
await prepareDeliveryForRun(sessionEntry);
if (!isRawModelRun && acpResolution?.kind === "ready" && sessionKey) {

View File

@@ -89,6 +89,7 @@ export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry {
restartRecoveryTerminalDeliveryEvidence: undefined,
restartRecoveryTerminalRunIds: undefined,
sessionStartedAt: undefined,
sessionDiffBaseline: undefined,
lastInteractionAt: undefined,
};
transitionMainSessionRecovery(next, { kind: "clear" });

View File

@@ -29,6 +29,7 @@ import {
isModelSelectionLocked,
ModelSelectionLockedError,
} from "../../sessions/model-overrides.js";
import { ensureSessionDiffBaseline } from "../../sessions/session-diff-baseline.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import {
sessionDeliveryChannel,
@@ -501,6 +502,29 @@ export async function getReplyFromConfig(
}
throw error;
}
if (!useFastTestBootstrap) {
try {
const baselineEntry = await traceGetReplyPhase("reply.capture_session_diff_baseline", () =>
ensureSessionDiffBaseline({
cwd:
normalizeOptionalString(sessionState.sessionEntry.spawnedCwd) ??
normalizeOptionalString(sessionState.sessionEntry.spawnedWorkspaceDir) ??
workspaceDir,
entry: sessionState.sessionEntry,
isNewSession: sessionState.isNewSession,
sessionKey: sessionState.sessionKey,
storePath: sessionState.storePath,
}),
);
sessionState.sessionEntry = baselineEntry;
sessionState.sessionEntryHandle.replaceCurrent(baselineEntry);
sessionState.sessionStore[sessionState.sessionKey] = baselineEntry;
} catch (error) {
logVerbose(
`session diff baseline capture failed; continuing without attribution filtering: ${formatErrorMessage(error)}`,
);
}
}
const {
sessionCtx,
sessionEntry,

View File

@@ -82,6 +82,15 @@ export type CliSessionReseedReceipt = {
userTurnDisposition: "persisted" | "omitted";
};
export type SessionDiffBaseline = {
version: 1;
sessionId: string;
root: string;
files: Array<{ path: string; fingerprint: string }>;
/** Some checkout entries could not be fingerprinted without exceeding diff safety caps. */
truncated?: true;
};
export type CliSessionBinding = {
sessionId: string;
/** Last successful assistant boundary accepted by the backend's resume contract. */
@@ -334,6 +343,8 @@ type SessionEntryCore = SessionRestartRecoveryState &
spawnedWorkspaceDir?: string;
/** Task working directory inherited by spawned sessions and reused on later turns. */
spawnedCwd?: string;
/** Content-free fingerprints for checkout changes that predate this session generation. */
sessionDiffBaseline?: SessionDiffBaseline;
/**
* Managed worktree bound to this session; set with spawnedCwd at worktree
* creation and cleared together when a plain New Chat detaches the checkout.

View File

@@ -12,15 +12,18 @@ import {
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { resolveDefaultModelForAgent } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { insideGitCheckout } from "../../agents/worktrees/git.js";
import { slugifyWorktreeTitle } from "../../agents/worktrees/name.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isPathInside } from "../../infra/path-guards.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { ensureSessionDiffBaseline } from "../../sessions/session-diff-baseline.js";
import { resolveUserPath } from "../../utils.js";
import { stripInlineDirectiveTagsForDisplay } from "../../utils/directive-tags.js";
import { generateDashboardSessionTitle } from "../dashboard-session-title.js";
@@ -46,6 +49,31 @@ import { sessionLog } from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
async function prepareOperatorSessionDiffBaseline(params: {
agentId: string;
cfg: OpenClawConfig;
entry: SessionEntry;
sessionKey: string;
storePath: string;
}): Promise<SessionEntry> {
const workspace = await ensureAgentWorkspace({
dir: resolveAgentWorkspaceDir(params.cfg, params.agentId),
ensureBootstrapFiles: !params.cfg.agents?.defaults?.skipBootstrap,
skipOptionalBootstrapFiles: params.cfg.agents?.defaults?.skipOptionalBootstrapFiles,
});
return await ensureSessionDiffBaseline({
cwd:
normalizeOptionalString(params.entry.spawnedCwd) ??
normalizeOptionalString(params.entry.spawnedWorkspaceDir) ??
workspace.dir,
entry: params.entry,
force: true,
isNewSession: true,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
}
export const sessionCreateHandlers: GatewayRequestHandlers = {
"sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => {
if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) {
@@ -381,6 +409,29 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
parseAgentSessionKey(sessionKey ?? "")?.agentId ??
resolveDefaultAgentId(cfg),
);
const captureCreatedSessionBaseline = async (created: {
agentId: string;
entry: SessionEntry;
key: string;
storePath: string;
}) => {
try {
Object.assign(
created.entry,
await prepareOperatorSessionDiffBaseline({
agentId: created.agentId,
cfg,
entry: created.entry,
sessionKey: created.key,
storePath: created.storePath,
}),
);
} catch (error) {
sessionLog.warn(
`session diff baseline capture failed for ${created.key}: ${formatErrorMessage(error)}`,
);
}
};
const created = await createGatewaySession({
cfg,
key: sessionKey,
@@ -417,42 +468,43 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
loadGatewayModelCatalog: () =>
context.loadGatewayModelCatalog({ agentId: modelCatalogAgentId }),
afterCreate: hasInitialTurn
? async ({ key, agentId, entry, storePath }) => {
messageSeq =
(await readSessionMessageCountAsync({
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
})) + 1;
await expectDefined(
chatHandlers["chat.send"],
"chat.send handler",
)({
req,
params: {
sessionKey: key,
...(key === "global" ? { agentId } : {}),
message: initialMessage ?? "",
idempotencyKey: randomUUID(),
...(initialAttachments ? { attachments: initialAttachments } : {}),
},
respond: (ok, payload, error, meta) => {
if (ok && payload && typeof payload === "object") {
runPayload = payload as Record<string, unknown>;
} else {
runError = error;
}
runMeta = meta;
},
context,
client,
isWebchatConnect,
});
}
: undefined,
afterCreate: async ({ key, agentId, entry, storePath }) => {
await captureCreatedSessionBaseline({ key, agentId, entry, storePath });
if (hasInitialTurn) {
messageSeq =
(await readSessionMessageCountAsync({
agentId,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: key,
storePath,
})) + 1;
await expectDefined(
chatHandlers["chat.send"],
"chat.send handler",
)({
req,
params: {
sessionKey: key,
...(key === "global" ? { agentId } : {}),
message: initialMessage ?? "",
idempotencyKey: randomUUID(),
...(initialAttachments ? { attachments: initialAttachments } : {}),
},
respond: (ok, payload, error, meta) => {
if (ok && payload && typeof payload === "object") {
runPayload = payload as Record<string, unknown>;
} else {
runError = error;
}
runMeta = meta;
},
context,
client,
isWebchatConnect,
});
}
},
});
if (!created.ok) {
if (sessionWorktree && provisionedSessionWorktree) {
@@ -485,6 +537,18 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
);
}
}
if (created.resetExisting) {
await captureCreatedSessionBaseline({
key: created.key,
agentId: created.agentId,
entry: created.entry,
storePath: resolveGatewaySessionStoreTarget({
cfg,
key: created.key,
agentId: created.agentId,
}).storePath,
});
}
const createdWorktree = sessionWorktree
? {
id: sessionWorktree.id,

View File

@@ -5,6 +5,9 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions/types.js";
import { ensureSessionDiffBaseline } from "../../sessions/session-diff-baseline.js";
import { captureSessionDiffBaseline } from "../../sessions/session-diff.js";
import {
loadSessionDiff,
parseNameStatusZ,
@@ -15,6 +18,7 @@ import {
const hoisted = vi.hoisted(() => ({
loadSessionEntry: vi.fn(),
patchSessionEntry: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
}));
@@ -29,6 +33,10 @@ vi.mock("../../agents/agent-scope.js", () => ({
resolveDefaultAgentId: hoisted.resolveDefaultAgentId,
}));
vi.mock("../../config/sessions/session-accessor.js", () => ({
patchSessionEntry: hoisted.patchSessionEntry,
}));
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
}
@@ -40,10 +48,10 @@ function initRepo(root: string): void {
git(root, "config", "commit.gpgsign", "false");
}
function mockSession(spawnedCwd: string): void {
function mockSession(spawnedCwd: string, entry: Record<string, unknown> = {}): void {
hoisted.loadSessionEntry.mockReturnValue({
cfg: {},
entry: { sessionId: "s1", spawnedCwd },
entry: { sessionId: "s1", spawnedCwd, ...entry },
storePath: "/tmp/sessions.json",
canonicalKey: "agent:main:s1",
});
@@ -268,6 +276,77 @@ describe("loadSessionDiff", () => {
expect(result.files.find((file) => file.path === "loose.txt")?.untracked).toBe(true);
});
it("hides unchanged files captured at session start and resurfaces later edits", async () => {
initRepo(repoRoot);
fs.writeFileSync(path.join(repoRoot, "AGENTS.md"), "existing bootstrap\n");
mockSession(repoRoot);
const baseline = await captureSessionDiffBaseline({
cwd: repoRoot,
sessionId: "s1",
});
expect(baseline?.files).toHaveLength(1);
mockSession(repoRoot, { sessionDiffBaseline: baseline });
const unchanged = await loadSessionDiff({ sessionKey: "agent:main:s1" });
expect(unchanged.files).toEqual([]);
expect(unchanged.additions).toBe(0);
fs.appendFileSync(path.join(repoRoot, "AGENTS.md"), "added by this session\n");
const changed = await loadSessionDiff({ sessionKey: "agent:main:s1" });
expect(changed.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(changed.files[0]?.patch).toContain("+added by this session");
});
it("hides unchanged binary files and resurfaces later binary edits", async () => {
initRepo(repoRoot);
fs.writeFileSync(path.join(repoRoot, "icon.bin"), Buffer.from([0, 1, 2, 0, 3]));
mockSession(repoRoot);
const baseline = await captureSessionDiffBaseline({
cwd: repoRoot,
sessionId: "s1",
});
expect(baseline?.files).toHaveLength(1);
mockSession(repoRoot, { sessionDiffBaseline: baseline });
const unchanged = await loadSessionDiff({ sessionKey: "agent:main:s1" });
expect(unchanged.files).toEqual([]);
fs.writeFileSync(path.join(repoRoot, "icon.bin"), Buffer.from([0, 1, 9, 0, 3]));
const changed = await loadSessionDiff({ sessionKey: "agent:main:s1" });
expect(changed.files.map((file) => file.path)).toEqual(["icon.bin"]);
expect(changed.files[0]?.binary).toBe(true);
});
it("skips oversized files instead of materializing them during baseline capture", async () => {
initRepo(repoRoot);
fs.writeFileSync(path.join(repoRoot, "large.txt"), Buffer.alloc(4 * 1024 * 1024 + 1, 97));
const baseline = await captureSessionDiffBaseline({
cwd: repoRoot,
sessionId: "s1",
});
expect(baseline?.files).toEqual([]);
expect(baseline?.truncated).toBe(true);
});
it("ignores a baseline from an older session generation", async () => {
initRepo(repoRoot);
fs.writeFileSync(path.join(repoRoot, "AGENTS.md"), "existing bootstrap\n");
mockSession(repoRoot);
const baseline = await captureSessionDiffBaseline({
cwd: repoRoot,
sessionId: "old-session",
});
mockSession(repoRoot, { sessionDiffBaseline: baseline });
const result = await loadSessionDiff({ sessionKey: "agent:main:s1" });
expect(result.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
});
it("counts untracked additions whose content begins with plus signs", async () => {
initRepo(repoRoot);
fs.writeFileSync(path.join(repoRoot, "seed.txt"), "seed\n");
@@ -299,3 +378,24 @@ describe("loadSessionDiff", () => {
expect(calls[0]?.ok).toBe(false);
});
});
describe("ensureSessionDiffBaseline", () => {
it("does not baseline an existing operator session after upgrade", async () => {
const entry: SessionEntry = {
createdVia: "operator",
sessionId: "existing-session",
updatedAt: Date.now(),
};
const result = await ensureSessionDiffBaseline({
cwd: "/unused",
entry,
isNewSession: false,
sessionKey: "agent:main:existing",
storePath: "/unused/sessions.json",
});
expect(result).toBe(entry);
expect(hoisted.patchSessionEntry).not.toHaveBeenCalled();
});
});

View File

@@ -1,416 +1,19 @@
// Session checkout diff for operator clients: branch + working-tree changes
// against the checkout's default-branch merge base, structured per file so the
// Control UI diff panel can render without shelling out client-side.
import fs from "node:fs/promises";
import nodePath from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
// Session checkout diff for operator clients, filtered against the exact
// working-tree state captured when the logical session started.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
validateSessionsDiffParams,
type SessionDiffFile,
type SessionsDiffParams,
type SessionsDiffResult,
} from "../../../packages/gateway-protocol/src/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { runGit } from "../../agents/worktrees/git.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { applySessionDiffBaseline, loadCheckoutDiff } from "../../sessions/session-diff.js";
import { loadSessionEntryReadOnly } from "../session-utils.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
const MAX_FILES = 500;
const MAX_UNTRACKED_FILES = 100;
const MAX_PATCH_BYTES_PER_FILE = 100_000;
const MAX_TOTAL_PATCH_BYTES = 1_500_000;
// Past this the full-patch git call is skipped entirely: runGit buffers stdout
// in memory, so a pathological diff must degrade to stats-only entries.
const MAX_TOTAL_CHANGED_LINES = 100_000;
type FileStatus = SessionDiffFile["status"];
type NameStatusEntry = { path: string; oldPath?: string; status: FileStatus };
type NumstatEntry = { additions: number; deletions: number; binary: boolean };
async function gitOut(
cwd: string,
args: string[],
okCodes: readonly number[] = [0],
): Promise<string | null> {
try {
// quotePath=false keeps non-ASCII paths raw instead of octal-escaped, so
// -z output tokens match the byte-for-byte paths git reports elsewhere.
const result = await runGit(cwd, ["-c", "core.quotePath=false", ...args]);
return okCodes.includes(result.code ?? -1) ? result.stdout : null;
} catch {
return null;
}
}
/** Parses `git diff --name-status -z -M` output; R/C entries consume two paths. */
export function parseNameStatusZ(text: string): NameStatusEntry[] {
const tokens = text.split("\0");
const entries: NameStatusEntry[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const code = tokens[i];
if (!code) {
continue;
}
const letter = code[0];
if (letter === "R" || letter === "C") {
const oldPath = tokens[i + 1];
const path = tokens[i + 2];
i += 2;
if (path) {
entries.push({ path, oldPath, status: letter === "R" ? "renamed" : "added" });
}
continue;
}
const path = tokens[i + 1];
i += 1;
if (!path) {
continue;
}
const status: FileStatus = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified";
entries.push({ path, status });
}
return entries;
}
/** Parses `git diff --numstat -z -M`; rename entries put paths in follow-up tokens. */
export function parseNumstatZ(text: string): Map<string, NumstatEntry> {
const tokens = text.split("\0");
const byPath = new Map<string, NumstatEntry>();
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (!token) {
continue;
}
const [added, deleted, inlinePath] = token.split("\t");
if (added === undefined || deleted === undefined) {
continue;
}
const binary = added === "-";
const entry: NumstatEntry = {
additions: binary ? 0 : Number.parseInt(added, 10) || 0,
deletions: binary ? 0 : Number.parseInt(deleted, 10) || 0,
binary,
};
if (inlinePath) {
byPath.set(inlinePath, entry);
continue;
}
// Rename: `a\tb\t` token, then old and new path tokens; key by new path.
const path = tokens[i + 2];
i += 2;
if (path) {
byPath.set(path, entry);
}
}
return byPath;
}
function chunkPath(chunk: string): string | null {
const newFile = /^\+\+\+ b\/(.+)$/m.exec(chunk);
if (newFile) {
return expectDefined(newFile[1], "new file capture group 1");
}
// Deleted files have `+++ /dev/null`; key the chunk by the old path.
const oldFile = /^--- a\/(.+)$/m.exec(chunk);
if (oldFile) {
return expectDefined(oldFile[1], "old file capture group 1");
}
// Pure renames and binary chunks have neither marker line.
const renameTo = /^rename to (.+)$/m.exec(chunk);
if (renameTo) {
return expectDefined(renameTo[1], "rename to capture group 1");
}
const header = /^diff --git a\/.+ b\/(.+)$/m.exec(chunk);
return header ? expectDefined(header[1], "header capture group 1") : null;
}
/** Splits a multi-file `git diff --patch` into per-file chunks keyed by path. */
export function splitPatchByFile(patch: string): Map<string, string> {
const byPath = new Map<string, string>();
if (!patch.trim()) {
return byPath;
}
const parts = patch.split(/^(?=diff --git )/m);
for (const part of parts) {
if (!part.startsWith("diff --git ")) {
continue;
}
const path = chunkPath(part);
if (path) {
byPath.set(path, part);
}
}
return byPath;
}
function isBinaryChunk(chunk: string): boolean {
return /^Binary files .* differ$/m.test(chunk) || chunk.includes("\nGIT binary patch\n");
}
function countPatchAdditions(chunk: string): number {
let additions = 0;
let inHunk = false;
for (const line of chunk.split("\n")) {
if (line.startsWith("@@")) {
inHunk = true;
continue;
}
// Count only hunk-body additions so a `+++foo` content line is not mistaken
// for the `+++ b/path` header (which always precedes the first hunk).
if (inHunk && line.startsWith("+")) {
additions += 1;
}
}
return additions;
}
/**
* A patch-producing `git diff` reads working-tree file contents, so a
* checkout-planted hardlink to an out-of-tree secret would otherwise leak
* through this read-scoped RPC (same threat the fs-safe workspace readers
* reject). Content is only emitted for a real, single-linked regular file
* whose realpath stays inside the checkout. Deleted files are exempt: git
* reads their content from the object DB, never the filesystem.
*/
async function isPatchableWorkingTreePath(realRoot: string, relPath: string): Promise<boolean> {
const abs = nodePath.resolve(realRoot, relPath);
try {
const info = await fs.lstat(abs);
// Symlinks never leak file contents (git diff shows the link target text,
// not the pointee), but a hardlink is a second name for another inode.
if (!info.isFile() || info.nlink !== 1) {
return false;
}
const resolved = await fs.realpath(abs);
return resolved === realRoot || resolved.startsWith(realRoot + nodePath.sep);
} catch {
return false;
}
}
type PatchBudget = { remaining: number };
function takePatch(
chunk: string | undefined,
budget: PatchBudget,
): { patch?: string; truncated?: boolean } {
if (!chunk) {
return { truncated: true };
}
const bytes = Buffer.byteLength(chunk, "utf8");
if (bytes > MAX_PATCH_BYTES_PER_FILE || bytes > budget.remaining) {
return { truncated: true };
}
budget.remaining -= bytes;
return { patch: chunk };
}
/**
* Picks the ref the session diff is computed against: merge-base with the
* remote default branch when on a feature branch, otherwise HEAD so sessions
* on the default branch still surface uncommitted work.
*/
async function resolveDiffBase(
root: string,
branch: string | undefined,
): Promise<{ base: string; baseRef: string }> {
const defaultRef = await gitOut(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
const remoteDefault = defaultRef?.trim() || null;
const defaultShort = remoteDefault?.replace(/^origin\//, "");
if (remoteDefault && defaultShort && branch && branch !== defaultShort) {
const mergeBase = await gitOut(root, ["merge-base", remoteDefault, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: defaultShort };
}
}
// No usable remote default: try a local main/master so plain clones still
// get a branch-relative diff instead of only uncommitted changes.
if (branch && branch !== "main" && branch !== "master") {
for (const candidate of ["main", "master"]) {
const verified = await gitOut(root, ["rev-parse", "--verify", "--quiet", candidate]);
if (verified?.trim()) {
const mergeBase = await gitOut(root, ["merge-base", candidate, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: candidate };
}
}
}
}
return { base: "HEAD", baseRef: "HEAD" };
}
/**
* Diff base for a repo before its first commit: the empty-tree object id, so
* `git diff <empty>` reports staged/index files as additions. `hash-object`
* derives the id for the repo's object format (SHA-1 vs SHA-256) and does not
* write to the object DB. baseRef stays undefined — there is no named base.
*/
async function resolveUnbornDiffBase(
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;
}
}
async function collectUntrackedFiles(
root: string,
realRoot: string,
budget: PatchBudget,
): Promise<{ files: SessionDiffFile[]; truncated: boolean }> {
const listing = await gitOut(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
const paths = (listing ?? "").split("\0").filter(Boolean);
const truncated = paths.length > MAX_UNTRACKED_FILES;
const files: SessionDiffFile[] = [];
for (const filePath of paths.slice(0, MAX_UNTRACKED_FILES)) {
// Hardlink/escape guard before git reads the file contents.
if (!(await isPatchableWorkingTreePath(realRoot, filePath))) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
truncated: true,
});
continue;
}
// Exit code 1 is git's "files differ" for --no-index, not a failure.
// --no-textconv: checkout-configurable textconv drivers must never run
// from this read-scoped RPC (same reason as --no-ext-diff).
const patch = await gitOut(
root,
[
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--no-index",
"--",
"/dev/null",
filePath,
],
[0, 1],
);
if (patch === null) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
truncated: true,
});
continue;
}
if (isBinaryChunk(patch)) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
binary: true,
});
continue;
}
const additions = countPatchAdditions(patch);
files.push({
path: filePath,
status: "added",
additions,
deletions: 0,
untracked: true,
...takePatch(patch, budget),
});
}
return { files, truncated };
}
async function collectTrackedFiles(
root: string,
realRoot: string,
base: string,
budget: PatchBudget,
): Promise<{ files: SessionDiffFile[]; truncated: boolean }> {
const diffArgs = ["diff", "-M", base];
const nameStatus = await gitOut(root, [...diffArgs, "--name-status", "-z"]);
if (nameStatus === null) {
return { files: [], truncated: false };
}
const entries = parseNameStatusZ(nameStatus);
if (entries.length === 0) {
return { files: [], truncated: false };
}
const numstatText = (await gitOut(root, [...diffArgs, "--numstat", "-z"])) ?? "";
const numstat = parseNumstatZ(numstatText);
const totalChangedLines = [...numstat.values()].reduce(
(sum, entry) => sum + entry.additions + entry.deletions,
0,
);
// --no-textconv alongside --no-ext-diff: repo config + .gitattributes can
// define textconv commands, and a read RPC must never execute them.
const patchText =
totalChangedLines > MAX_TOTAL_CHANGED_LINES
? null
: await gitOut(root, [
...diffArgs,
"--patch",
"--no-color",
"--no-ext-diff",
"--no-textconv",
]);
const chunks = patchText === null ? new Map<string, string>() : splitPatchByFile(patchText);
const truncated = entries.length > MAX_FILES;
const files: SessionDiffFile[] = [];
for (const entry of entries.slice(0, MAX_FILES)) {
const stat = numstat.get(entry.path);
const chunk = chunks.get(entry.path);
const binary = stat?.binary === true || (chunk !== undefined && isBinaryChunk(chunk));
const file: SessionDiffFile = {
path: entry.path,
status: entry.status,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
};
if (entry.oldPath) {
file.oldPath = entry.oldPath;
}
if (binary) {
file.binary = true;
files.push(file);
continue;
}
// Deleted files diff against the object DB (no filesystem read); every
// other status reads the working-tree file, so hardlink-guard it before
// returning content the bulk diff already buffered server-side.
const safe =
entry.status === "deleted" || (await isPatchableWorkingTreePath(realRoot, entry.path));
if (!safe) {
file.truncated = true;
files.push(file);
continue;
}
const taken = takePatch(chunk, budget);
if (taken.patch !== undefined) {
file.patch = taken.patch;
}
if (taken.truncated) {
file.truncated = true;
}
files.push(file);
}
return { files, truncated };
}
export { parseNameStatusZ, parseNumstatZ, splitPatchByFile } from "../../sessions/session-diff.js";
export async function loadSessionDiff(params: SessionsDiffParams): Promise<SessionsDiffResult> {
const empty = (
@@ -445,45 +48,11 @@ export async function loadSessionDiff(params: SessionsDiffParams): Promise<Sessi
if (!cwd) {
return empty("unknown_session");
}
const root = (await gitOut(cwd, ["rev-parse", "--show-toplevel"]))?.trim();
if (!root) {
return empty("not_git");
}
// Canonical root for the hardlink/escape guard: show-toplevel can contain
// symlinked path segments, and containment is compared against realpaths.
const realRoot = await fs.realpath(root).catch(() => root);
const branchOut = (await gitOut(root, ["rev-parse", "--abbrev-ref", "HEAD"]))?.trim();
const branch = branchOut && branchOut !== "HEAD" ? branchOut : undefined;
const budget: PatchBudget = { remaining: MAX_TOTAL_PATCH_BYTES };
// Repos before their first commit have no HEAD, so diff the index/worktree
// against the empty tree to surface staged files (the untracked scan below
// only covers files git does not track yet). hash-object derives the empty
// tree id for the repo's object format without writing to the object DB.
const hasHead = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"])) !== null;
const baseInfo = hasHead
? await resolveDiffBase(root, branch)
: await resolveUnbornDiffBase(root);
const tracked = baseInfo
? await collectTrackedFiles(root, realRoot, baseInfo.base, budget)
: { files: [], truncated: false };
const untracked = await collectUntrackedFiles(root, realRoot, budget);
const files = [...tracked.files, ...untracked.files].toSorted((a, b) =>
a.path.localeCompare(b.path),
);
const additions = files.reduce((sum, file) => sum + file.additions, 0);
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
const truncated =
tracked.truncated || untracked.truncated || files.some((file) => file.truncated === true);
return {
sessionKey: params.sessionKey,
root,
...(branch ? { branch } : {}),
...(baseInfo?.baseRef ? { baseRef: baseInfo.baseRef } : {}),
files,
additions,
deletions,
...(truncated ? { truncated: true } : {}),
};
return await applySessionDiffBaseline({
baseline: entry.sessionDiffBaseline,
diff: await loadCheckoutDiff({ cwd, sessionKey: params.sessionKey }),
sessionId: entry.sessionId,
});
}
export const sessionsDiffHandlers: GatewayRequestHandlers = {

View File

@@ -32,6 +32,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"completionOwnerSessionKey",
"spawnedWorkspaceDir",
"spawnedCwd",
"sessionDiffBaseline",
"worktree",
"parentSessionKey",
"createdVia",

View File

@@ -0,0 +1,42 @@
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { captureSessionDiffBaseline } from "./session-diff.js";
export async function ensureSessionDiffBaseline(params: {
cwd: string;
entry: SessionEntry;
force?: boolean;
isNewSession: boolean;
sessionKey: string;
storePath: string;
}): Promise<SessionEntry> {
if (
!params.isNewSession ||
params.entry.execNode ||
(!params.force && params.entry.createdVia !== "operator") ||
params.entry.sessionDiffBaseline?.sessionId === params.entry.sessionId
) {
return params.entry;
}
const baseline = await captureSessionDiffBaseline({
cwd: params.cwd,
sessionId: params.entry.sessionId,
});
if (!baseline) {
return params.entry;
}
const persisted = await patchSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(current) => {
if (
current.sessionId !== params.entry.sessionId ||
current.sessionDiffBaseline?.sessionId === current.sessionId
) {
return null;
}
return { sessionDiffBaseline: baseline };
},
{ preserveActivity: true, skipMaintenance: true },
);
return persisted ?? params.entry;
}

View File

@@ -0,0 +1,720 @@
// Session checkout diff collection and session-start baseline filtering.
import crypto from "node:crypto";
import { constants as fsConstants, type BigIntStats } from "node:fs";
import fs from "node:fs/promises";
import nodePath from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import type {
SessionDiffFile,
SessionsDiffResult,
} from "../../packages/gateway-protocol/src/index.js";
import { runGit } from "../agents/worktrees/git.js";
import type { SessionDiffBaseline } from "../config/sessions/types.js";
import { runCommandBuffered } from "../process/exec.js";
const MAX_FILES = 500;
const MAX_UNTRACKED_FILES = 100;
const MAX_PATCH_BYTES_PER_FILE = 100_000;
const MAX_TOTAL_PATCH_BYTES = 1_500_000;
const MAX_BASELINE_GIT_OUTPUT_BYTES = 512_000;
const MAX_BASELINE_FILE_BYTES = 4 * 1024 * 1024;
const MAX_BASELINE_TOTAL_BYTES = 16 * 1024 * 1024;
// Past this the full-patch git call is skipped entirely: runGit buffers stdout
// in memory, so a pathological diff must degrade to stats-only entries.
const MAX_TOTAL_CHANGED_LINES = 100_000;
type FileStatus = SessionDiffFile["status"];
type NameStatusEntry = { path: string; oldPath?: string; status: FileStatus };
type NumstatEntry = { additions: number; deletions: number; binary: boolean };
async function gitOut(
cwd: string,
args: string[],
okCodes: readonly number[] = [0],
): Promise<string | null> {
try {
// quotePath=false keeps non-ASCII paths raw instead of octal-escaped, so
// -z output tokens match the byte-for-byte paths git reports elsewhere.
const result = await runGit(cwd, ["-c", "core.quotePath=false", ...args]);
return okCodes.includes(result.code ?? -1) ? result.stdout : null;
} catch {
return null;
}
}
/** Parses `git diff --name-status -z -M` output; R/C entries consume two paths. */
export function parseNameStatusZ(text: string): NameStatusEntry[] {
const tokens = text.split("\0");
const entries: NameStatusEntry[] = [];
for (let i = 0; i < tokens.length; i += 1) {
const code = tokens[i];
if (!code) {
continue;
}
const letter = code[0];
if (letter === "R" || letter === "C") {
const oldPath = tokens[i + 1];
const path = tokens[i + 2];
i += 2;
if (path) {
entries.push({ path, oldPath, status: letter === "R" ? "renamed" : "added" });
}
continue;
}
const path = tokens[i + 1];
i += 1;
if (!path) {
continue;
}
const status: FileStatus = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified";
entries.push({ path, status });
}
return entries;
}
/** Parses `git diff --numstat -z -M`; rename entries put paths in follow-up tokens. */
export function parseNumstatZ(text: string): Map<string, NumstatEntry> {
const tokens = text.split("\0");
const byPath = new Map<string, NumstatEntry>();
for (let i = 0; i < tokens.length; i += 1) {
const token = tokens[i];
if (!token) {
continue;
}
const [added, deleted, inlinePath] = token.split("\t");
if (added === undefined || deleted === undefined) {
continue;
}
const binary = added === "-";
const entry: NumstatEntry = {
additions: binary ? 0 : Number.parseInt(added, 10) || 0,
deletions: binary ? 0 : Number.parseInt(deleted, 10) || 0,
binary,
};
if (inlinePath) {
byPath.set(inlinePath, entry);
continue;
}
// Rename: `a\tb\t` token, then old and new path tokens; key by new path.
const path = tokens[i + 2];
i += 2;
if (path) {
byPath.set(path, entry);
}
}
return byPath;
}
function chunkPath(chunk: string): string | null {
const newFile = /^\+\+\+ b\/(.+)$/m.exec(chunk);
if (newFile) {
return expectDefined(newFile[1], "new file capture group 1");
}
// Deleted files have `+++ /dev/null`; key the chunk by the old path.
const oldFile = /^--- a\/(.+)$/m.exec(chunk);
if (oldFile) {
return expectDefined(oldFile[1], "old file capture group 1");
}
// Pure renames and binary chunks have neither marker line.
const renameTo = /^rename to (.+)$/m.exec(chunk);
if (renameTo) {
return expectDefined(renameTo[1], "rename to capture group 1");
}
const header = /^diff --git a\/.+ b\/(.+)$/m.exec(chunk);
return header ? expectDefined(header[1], "header capture group 1") : null;
}
/** Splits a multi-file `git diff --patch` into per-file chunks keyed by path. */
export function splitPatchByFile(patch: string): Map<string, string> {
const byPath = new Map<string, string>();
if (!patch.trim()) {
return byPath;
}
const parts = patch.split(/^(?=diff --git )/m);
for (const part of parts) {
if (!part.startsWith("diff --git ")) {
continue;
}
const path = chunkPath(part);
if (path) {
byPath.set(path, part);
}
}
return byPath;
}
function isBinaryChunk(chunk: string): boolean {
return /^Binary files .* differ$/m.test(chunk) || chunk.includes("\nGIT binary patch\n");
}
function countPatchAdditions(chunk: string): number {
let additions = 0;
let inHunk = false;
for (const line of chunk.split("\n")) {
if (line.startsWith("@@")) {
inHunk = true;
continue;
}
// Count only hunk-body additions so a `+++foo` content line is not mistaken
// for the `+++ b/path` header (which always precedes the first hunk).
if (inHunk && line.startsWith("+")) {
additions += 1;
}
}
return additions;
}
/**
* A patch-producing `git diff` reads working-tree file contents, so a
* checkout-planted hardlink to an out-of-tree secret would otherwise leak
* through this read-scoped RPC (same threat the fs-safe workspace readers
* reject). Content is only emitted for a real, single-linked regular file
* whose realpath stays inside the checkout. Deleted files are exempt: git
* reads their content from the object DB, never the filesystem.
*/
async function isPatchableWorkingTreePath(realRoot: string, relPath: string): Promise<boolean> {
const abs = nodePath.resolve(realRoot, relPath);
try {
const info = await fs.lstat(abs);
// Symlinks never leak file contents (git diff shows the link target text,
// not the pointee), but a hardlink is a second name for another inode.
if (!info.isFile() || info.nlink !== 1) {
return false;
}
const resolved = await fs.realpath(abs);
return resolved === realRoot || resolved.startsWith(realRoot + nodePath.sep);
} catch {
return false;
}
}
type PatchBudget = { remaining: number };
function takePatch(
chunk: string | undefined,
budget: PatchBudget,
): { patch?: string; truncated?: boolean } {
if (!chunk) {
return { truncated: true };
}
const bytes = Buffer.byteLength(chunk, "utf8");
if (bytes > MAX_PATCH_BYTES_PER_FILE || bytes > budget.remaining) {
return { truncated: true };
}
budget.remaining -= bytes;
return { patch: chunk };
}
/**
* Picks the ref the session diff is computed against: merge-base with the
* remote default branch when on a feature branch, otherwise HEAD so sessions
* on the default branch still surface uncommitted work.
*/
async function resolveDiffBase(
root: string,
branch: string | undefined,
): Promise<{ base: string; baseRef: string }> {
const defaultRef = await gitOut(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
const remoteDefault = defaultRef?.trim() || null;
const defaultShort = remoteDefault?.replace(/^origin\//, "");
if (remoteDefault && defaultShort && branch && branch !== defaultShort) {
const mergeBase = await gitOut(root, ["merge-base", remoteDefault, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: defaultShort };
}
}
// No usable remote default: try a local main/master so plain clones still
// get a branch-relative diff instead of only uncommitted changes.
if (branch && branch !== "main" && branch !== "master") {
for (const candidate of ["main", "master"]) {
const verified = await gitOut(root, ["rev-parse", "--verify", "--quiet", candidate]);
if (verified?.trim()) {
const mergeBase = await gitOut(root, ["merge-base", candidate, "HEAD"]);
if (mergeBase?.trim()) {
return { base: mergeBase.trim(), baseRef: candidate };
}
}
}
}
return { base: "HEAD", baseRef: "HEAD" };
}
/**
* Diff base for a repo before its first commit: the empty-tree object id, so
* `git diff <empty>` reports staged/index files as additions. `hash-object`
* derives the id for the repo's object format (SHA-1 vs SHA-256) and does not
* write to the object DB. baseRef stays undefined — there is no named base.
*/
async function resolveUnbornDiffBase(
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;
}
}
async function collectUntrackedFiles(
root: string,
realRoot: string,
budget: PatchBudget,
): Promise<{ files: SessionDiffFile[]; truncated: boolean }> {
const listing = await gitOut(root, ["ls-files", "--others", "--exclude-standard", "-z"]);
const paths = (listing ?? "").split("\0").filter(Boolean);
const truncated = paths.length > MAX_UNTRACKED_FILES;
const files: SessionDiffFile[] = [];
for (const filePath of paths.slice(0, MAX_UNTRACKED_FILES)) {
// Hardlink/escape guard before git reads the file contents.
if (!(await isPatchableWorkingTreePath(realRoot, filePath))) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
truncated: true,
});
continue;
}
// Exit code 1 is git's "files differ" for --no-index, not a failure.
// --no-textconv: checkout-configurable textconv drivers must never run
// from this read-scoped RPC (same reason as --no-ext-diff).
const patch = await gitOut(
root,
[
"diff",
"--no-color",
"--no-ext-diff",
"--no-textconv",
"--no-index",
"--",
"/dev/null",
filePath,
],
[0, 1],
);
if (patch === null) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
truncated: true,
});
continue;
}
if (isBinaryChunk(patch)) {
files.push({
path: filePath,
status: "added",
additions: 0,
deletions: 0,
untracked: true,
binary: true,
});
continue;
}
const additions = countPatchAdditions(patch);
files.push({
path: filePath,
status: "added",
additions,
deletions: 0,
untracked: true,
...takePatch(patch, budget),
});
}
return { files, truncated };
}
async function collectTrackedFiles(
root: string,
realRoot: string,
base: string,
budget: PatchBudget,
): Promise<{ files: SessionDiffFile[]; truncated: boolean }> {
const diffArgs = ["diff", "-M", base];
const nameStatus = await gitOut(root, [...diffArgs, "--name-status", "-z"]);
if (nameStatus === null) {
return { files: [], truncated: false };
}
const entries = parseNameStatusZ(nameStatus);
if (entries.length === 0) {
return { files: [], truncated: false };
}
const numstatText = (await gitOut(root, [...diffArgs, "--numstat", "-z"])) ?? "";
const numstat = parseNumstatZ(numstatText);
const totalChangedLines = [...numstat.values()].reduce(
(sum, entry) => sum + entry.additions + entry.deletions,
0,
);
// --no-textconv alongside --no-ext-diff: repo config + .gitattributes can
// define textconv commands, and a read RPC must never execute them.
const patchText =
totalChangedLines > MAX_TOTAL_CHANGED_LINES
? null
: await gitOut(root, [
...diffArgs,
"--patch",
"--no-color",
"--no-ext-diff",
"--no-textconv",
]);
const chunks = patchText === null ? new Map<string, string>() : splitPatchByFile(patchText);
const truncated = entries.length > MAX_FILES;
const files: SessionDiffFile[] = [];
for (const entry of entries.slice(0, MAX_FILES)) {
const stat = numstat.get(entry.path);
const chunk = chunks.get(entry.path);
const binary = stat?.binary === true || (chunk !== undefined && isBinaryChunk(chunk));
const file: SessionDiffFile = {
path: entry.path,
status: entry.status,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
};
if (entry.oldPath) {
file.oldPath = entry.oldPath;
}
if (binary) {
file.binary = true;
files.push(file);
continue;
}
// Deleted files diff against the object DB (no filesystem read); every
// other status reads the working-tree file, so hardlink-guard it before
// returning content the bulk diff already buffered server-side.
const safe =
entry.status === "deleted" || (await isPatchableWorkingTreePath(realRoot, entry.path));
if (!safe) {
file.truncated = true;
files.push(file);
continue;
}
const taken = takePatch(chunk, budget);
if (taken.patch !== undefined) {
file.patch = taken.patch;
}
if (taken.truncated) {
file.truncated = true;
}
files.push(file);
}
return { files, truncated };
}
export async function loadCheckoutDiff(params: {
cwd: string;
sessionKey: string;
}): Promise<SessionsDiffResult> {
const empty = (
unavailableReason?: NonNullable<SessionsDiffResult["unavailableReason"]>,
): SessionsDiffResult => ({
sessionKey: params.sessionKey,
files: [],
additions: 0,
deletions: 0,
...(unavailableReason ? { unavailableReason } : {}),
});
const root = (await gitOut(params.cwd, ["rev-parse", "--show-toplevel"]))?.trim();
if (!root) {
return empty("not_git");
}
// Canonical root for the hardlink/escape guard: show-toplevel can contain
// symlinked path segments, and containment is compared against realpaths.
const realRoot = await fs.realpath(root).catch(() => root);
const branchOut = (await gitOut(root, ["rev-parse", "--abbrev-ref", "HEAD"]))?.trim();
const branch = branchOut && branchOut !== "HEAD" ? branchOut : undefined;
const budget: PatchBudget = { remaining: MAX_TOTAL_PATCH_BYTES };
// Repos before their first commit have no HEAD, so diff the index/worktree
// against the empty tree to surface staged files (the untracked scan below
// only covers files git does not track yet). hash-object derives the empty
// tree id for the repo's object format without writing to the object DB.
const hasHead = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"])) !== null;
const baseInfo = hasHead
? await resolveDiffBase(root, branch)
: await resolveUnbornDiffBase(root);
const tracked = baseInfo
? await collectTrackedFiles(root, realRoot, baseInfo.base, budget)
: { files: [], truncated: false };
const untracked = await collectUntrackedFiles(root, realRoot, budget);
const files = [...tracked.files, ...untracked.files].toSorted((a, b) =>
a.path.localeCompare(b.path),
);
const additions = files.reduce((sum, file) => sum + file.additions, 0);
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
const truncated =
tracked.truncated || untracked.truncated || files.some((file) => file.truncated === true);
return {
sessionKey: params.sessionKey,
root,
...(branch ? { branch } : {}),
...(baseInfo?.baseRef ? { baseRef: baseInfo.baseRef } : {}),
files,
additions,
deletions,
...(truncated ? { truncated: true } : {}),
};
}
type BaselineCandidate = Pick<SessionDiffFile, "oldPath" | "path" | "status" | "untracked">;
type BaselineHashBudget = { remaining: number };
function sameMutationFingerprint(left: BigIntStats, right: BigIntStats): boolean {
return (
left.ctimeNs === right.ctimeNs &&
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.mtimeNs === right.mtimeNs &&
left.nlink === right.nlink &&
left.size === right.size
);
}
function hashBaselineDescriptor(candidate: BaselineCandidate, content: string): string {
return crypto
.createHash("sha256")
.update(
[
candidate.path,
candidate.oldPath ?? "",
candidate.status,
candidate.untracked === true ? "untracked" : "tracked",
content,
].join("\0"),
)
.digest("hex");
}
async function fingerprintBaselineCandidate(params: {
budget: BaselineHashBudget;
candidate: BaselineCandidate;
realRoot: string;
root: string;
}): Promise<string | undefined> {
const { candidate } = params;
if (candidate.status === "deleted") {
return hashBaselineDescriptor(candidate, "deleted");
}
const absolutePath = nodePath.resolve(params.root, candidate.path);
const relativePath = nodePath.relative(params.root, absolutePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${nodePath.sep}`) ||
nodePath.isAbsolute(relativePath)
) {
return undefined;
}
const initial = await fs.lstat(absolutePath, { bigint: true }).catch(() => undefined);
if (!initial) {
return undefined;
}
if (initial.isSymbolicLink()) {
const target = await fs.readlink(absolutePath).catch(() => undefined);
return target === undefined
? undefined
: hashBaselineDescriptor(candidate, `symlink:${target}`);
}
if (
!initial.isFile() ||
initial.nlink !== 1n ||
initial.size > BigInt(MAX_BASELINE_FILE_BYTES) ||
initial.size > BigInt(params.budget.remaining)
) {
return undefined;
}
const resolved = await fs.realpath(absolutePath).catch(() => undefined);
if (
!resolved ||
(resolved !== params.realRoot && !resolved.startsWith(params.realRoot + nodePath.sep))
) {
return undefined;
}
const handle = await fs
.open(absolutePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0))
.catch(() => undefined);
if (!handle) {
return undefined;
}
params.budget.remaining -= Number(initial.size);
try {
const opened = await handle.stat({ bigint: true });
if (!opened.isFile() || opened.nlink !== 1n || !sameMutationFingerprint(initial, opened)) {
return undefined;
}
const digest = crypto.createHash("sha256");
digest.update(
[
candidate.path,
candidate.oldPath ?? "",
candidate.status,
candidate.untracked === true ? "untracked" : "tracked",
opened.mode.toString(),
opened.size.toString(),
].join("\0"),
);
const buffer = Buffer.allocUnsafe(64 * 1024);
let offset = 0;
while (offset < Number(opened.size)) {
const { bytesRead } = await handle.read(
buffer,
0,
Math.min(buffer.length, Number(opened.size) - offset),
offset,
);
if (bytesRead === 0) {
return undefined;
}
digest.update(buffer.subarray(0, bytesRead));
offset += bytesRead;
}
const final = await handle.stat({ bigint: true });
return sameMutationFingerprint(opened, final) ? digest.digest("hex") : undefined;
} finally {
await handle.close().catch(() => undefined);
}
}
async function gitOutForBaseline(cwd: string, args: string[]): Promise<string | null> {
const result = await runCommandBuffered(
["git", "-C", cwd, "-c", "core.quotePath=false", ...args],
{
timeoutMs: 30_000,
maxOutputBytes: {
stdout: MAX_BASELINE_GIT_OUTPUT_BYTES,
stderr: 32 * 1024,
},
},
);
if (result.termination !== "exit" || result.code !== 0) {
return null;
}
return result.stdout.toString("utf8");
}
async function collectBaselineCandidates(params: {
cwd: string;
}): Promise<{ candidates: BaselineCandidate[]; root: string; truncated: boolean } | undefined> {
const root = (await gitOut(params.cwd, ["rev-parse", "--show-toplevel"]))?.trim();
if (!root) {
return undefined;
}
const branchOut = (await gitOut(root, ["rev-parse", "--abbrev-ref", "HEAD"]))?.trim();
const branch = branchOut && branchOut !== "HEAD" ? branchOut : undefined;
const hasHead = (await gitOut(root, ["rev-parse", "--verify", "--quiet", "HEAD"])) !== null;
const baseInfo = hasHead
? await resolveDiffBase(root, branch)
: await resolveUnbornDiffBase(root);
const trackedText = baseInfo
? await gitOutForBaseline(root, ["diff", "-M", baseInfo.base, "--name-status", "-z"])
: "";
const untrackedText = await gitOutForBaseline(root, [
"ls-files",
"--others",
"--exclude-standard",
"-z",
]);
if (trackedText === null || untrackedText === null) {
return { root, candidates: [], truncated: true };
}
const tracked = parseNameStatusZ(trackedText);
const untrackedPaths = untrackedText.split("\0").filter(Boolean);
const candidates = [
...tracked.slice(0, MAX_FILES),
...untrackedPaths.slice(0, MAX_UNTRACKED_FILES).map((path) => ({
path,
status: "added" as const,
untracked: true,
})),
].toSorted((left, right) => left.path.localeCompare(right.path));
return {
root,
candidates,
truncated: tracked.length > MAX_FILES || untrackedPaths.length > MAX_UNTRACKED_FILES,
};
}
async function fingerprintBaselineCandidates(params: {
candidates: BaselineCandidate[];
root: string;
}): Promise<{ files: SessionDiffBaseline["files"]; truncated: boolean }> {
const realRoot = await fs.realpath(params.root).catch(() => params.root);
const budget: BaselineHashBudget = { remaining: MAX_BASELINE_TOTAL_BYTES };
const files: SessionDiffBaseline["files"] = [];
for (const candidate of params.candidates) {
const fingerprint = await fingerprintBaselineCandidate({
budget,
candidate,
realRoot,
root: params.root,
});
if (fingerprint) {
files.push({ path: candidate.path, fingerprint });
}
}
return { files, truncated: files.length !== params.candidates.length };
}
export async function captureSessionDiffBaseline(params: {
cwd: string;
sessionId: string;
}): Promise<SessionDiffBaseline | undefined> {
const collected = await collectBaselineCandidates({ cwd: params.cwd });
if (!collected) {
return undefined;
}
const fingerprinted = await fingerprintBaselineCandidates({
candidates: collected.candidates,
root: collected.root,
});
return {
version: 1,
sessionId: params.sessionId,
root: collected.root,
files: fingerprinted.files,
...(collected.truncated || fingerprinted.truncated ? { truncated: true } : {}),
};
}
export async function applySessionDiffBaseline(params: {
baseline: SessionDiffBaseline | undefined;
diff: SessionsDiffResult;
sessionId: string;
}): Promise<SessionsDiffResult> {
const { baseline, diff } = params;
if (
baseline?.version !== 1 ||
baseline.sessionId !== params.sessionId ||
!diff.root ||
baseline.root !== diff.root
) {
return diff;
}
const fingerprints = new Map(baseline.files.map((file) => [file.path, file.fingerprint]));
const current = await fingerprintBaselineCandidates({
candidates: diff.files,
root: diff.root,
});
const currentFingerprints = new Map(current.files.map((file) => [file.path, file.fingerprint]));
const files = diff.files.filter((file) => {
const baselineFingerprint = fingerprints.get(file.path);
return !baselineFingerprint || currentFingerprints.get(file.path) !== baselineFingerprint;
});
if (files.length === diff.files.length) {
return diff;
}
return {
...diff,
files,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}