From dd3d1d1a8b15b65e37ea61da3e34e913b6ec514b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 29 Jul 2026 15:17:47 +0800 Subject: [PATCH] fix(ui): hide pre-thread changes from session diffs (#115708) * fix(ui): scope session diffs to thread changes * docs: refresh generated map --- CHANGELOG.md | 1 + docs/docs_map.md | 1 + src/agents/agent-command.ts | 22 + src/agents/command/session.ts | 1 + src/auto-reply/reply/get-reply.ts | 24 + src/config/sessions/types.ts | 11 + src/gateway/server-methods/sessions-create.ts | 136 +++- .../server-methods/sessions-diff.test.ts | 104 ++- src/gateway/server-methods/sessions-diff.ts | 449 +---------- src/plugins/session-entry-slot-keys.ts | 1 + src/sessions/session-diff-baseline.ts | 42 + src/sessions/session-diff.ts | 720 ++++++++++++++++++ 12 files changed, 1034 insertions(+), 478 deletions(-) create mode 100644 src/sessions/session-diff-baseline.ts create mode 100644 src/sessions/session-diff.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 09938e688874..49c8f9205786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/docs_map.md b/docs/docs_map.md index 469e0119e209..c087e299f980 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index a5f140ad770c..1e18b44f602d 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -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) { diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 65b4504ef96b..f69206050863 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -89,6 +89,7 @@ export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry { restartRecoveryTerminalDeliveryEvidence: undefined, restartRecoveryTerminalRunIds: undefined, sessionStartedAt: undefined, + sessionDiffBaseline: undefined, lastInteractionAt: undefined, }; transitionMainSessionRecovery(next, { kind: "clear" }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index e5a3f9f0456b..01cbc79a146c 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -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, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 68ec9dd07c48..c0306afc2738 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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. diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 7d86e6019ca2..dc246bc0c826 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -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 { + 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; - } 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; + } 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, diff --git a/src/gateway/server-methods/sessions-diff.test.ts b/src/gateway/server-methods/sessions-diff.test.ts index fd556da60d3f..eebdd5f94642 100644 --- a/src/gateway/server-methods/sessions-diff.test.ts +++ b/src/gateway/server-methods/sessions-diff.test.ts @@ -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 = {}): 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(); + }); +}); diff --git a/src/gateway/server-methods/sessions-diff.ts b/src/gateway/server-methods/sessions-diff.ts index e8c3c12b391e..39263624c735 100644 --- a/src/gateway/server-methods/sessions-diff.ts +++ b/src/gateway/server-methods/sessions-diff.ts @@ -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 { - 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 { - const tokens = text.split("\0"); - const byPath = new Map(); - 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 { - const byPath = new Map(); - 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 { - 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 ` 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() : 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 { const empty = ( @@ -445,45 +48,11 @@ export async function loadSessionDiff(params: SessionsDiffParams): Promise 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 = { diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 7d384c8e861f..936919d271fc 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -32,6 +32,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "completionOwnerSessionKey", "spawnedWorkspaceDir", "spawnedCwd", + "sessionDiffBaseline", "worktree", "parentSessionKey", "createdVia", diff --git a/src/sessions/session-diff-baseline.ts b/src/sessions/session-diff-baseline.ts new file mode 100644 index 000000000000..39172dd36047 --- /dev/null +++ b/src/sessions/session-diff-baseline.ts @@ -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 { + 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; +} diff --git a/src/sessions/session-diff.ts b/src/sessions/session-diff.ts new file mode 100644 index 000000000000..e8a93cef1da2 --- /dev/null +++ b/src/sessions/session-diff.ts @@ -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 { + 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 { + const tokens = text.split("\0"); + const byPath = new Map(); + 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 { + const byPath = new Map(); + 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 { + 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 ` 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() : 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 { + const empty = ( + unavailableReason?: NonNullable, + ): 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; + +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 { + 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 { + 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 { + 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 { + 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), + }; +}