mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 12:11:34 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -89,6 +89,7 @@ export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry {
|
||||
restartRecoveryTerminalDeliveryEvidence: undefined,
|
||||
restartRecoveryTerminalRunIds: undefined,
|
||||
sessionStartedAt: undefined,
|
||||
sessionDiffBaseline: undefined,
|
||||
lastInteractionAt: undefined,
|
||||
};
|
||||
transitionMainSessionRecovery(next, { kind: "clear" });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -32,6 +32,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"completionOwnerSessionKey",
|
||||
"spawnedWorkspaceDir",
|
||||
"spawnedCwd",
|
||||
"sessionDiffBaseline",
|
||||
"worktree",
|
||||
"parentSessionKey",
|
||||
"createdVia",
|
||||
|
||||
42
src/sessions/session-diff-baseline.ts
Normal file
42
src/sessions/session-diff-baseline.ts
Normal 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;
|
||||
}
|
||||
720
src/sessions/session-diff.ts
Normal file
720
src/sessions/session-diff.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user