From 8bc533279acfbfd1eb898a159458beaf2398b5cc Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 04:01:48 +0100 Subject: [PATCH] feat(protocol): define streamed diff revisions and resolve tag bases --- packages/protocol/src/binary-frames/demux.ts | 9 + .../protocol/src/binary-frames/diff.test.ts | 240 ++++++++++++ packages/protocol/src/binary-frames/diff.ts | 359 ++++++++++++++++++ packages/protocol/src/binary-frames/index.ts | 1 + packages/protocol/src/messages.diff.test.ts | 24 ++ packages/protocol/src/messages.ts | 50 +++ .../authorization/operation-permissions.ts | 3 + .../src/server/checkout/status-projection.ts | 2 + packages/server/src/server/session.ts | 4 +- .../src/server/workspace-git-service.ts | 2 + .../src/utils/checkout-git.commits.test.ts | 33 +- packages/server/src/utils/checkout-git.ts | 30 +- 12 files changed, 749 insertions(+), 8 deletions(-) create mode 100644 packages/protocol/src/binary-frames/diff.test.ts create mode 100644 packages/protocol/src/binary-frames/diff.ts create mode 100644 packages/protocol/src/messages.diff.test.ts diff --git a/packages/protocol/src/binary-frames/demux.ts b/packages/protocol/src/binary-frames/demux.ts index 1a7617e35b..b21118d301 100644 --- a/packages/protocol/src/binary-frames/demux.ts +++ b/packages/protocol/src/binary-frames/demux.ts @@ -1,3 +1,4 @@ +import { decodeDiffFrame, DiffOpcode, type DiffFrame } from "./diff.js"; import { HOST_ROUTE_OPCODE, decodeHostRouteFrame, type HostRouteFrame } from "./host-route.js"; import { decodeFileTransferFrame, @@ -12,12 +13,20 @@ import { } from "./terminal.js"; export type BinaryFrame = + | { kind: "diff"; frame: DiffFrame } | { kind: "host_route"; frame: HostRouteFrame } | { kind: "terminal"; frame: TerminalStreamFrame } | { kind: "file_transfer"; frame: FileTransferFrame } | { kind: "plugin"; frame: PluginFrame }; export function decodeBinaryFrame(bytes: Uint8Array): BinaryFrame | null { + if (Object.values(DiffOpcode).some((opcode) => opcode === bytes[0])) { + return { kind: "diff", frame: decodeDiffFrame(bytes) }; + } + return decodeExistingBinaryFrame(bytes); +} + +function decodeExistingBinaryFrame(bytes: Uint8Array): BinaryFrame | null { switch (bytes[0]) { case HOST_ROUTE_OPCODE: { const frame = decodeHostRouteFrame(bytes); diff --git a/packages/protocol/src/binary-frames/diff.test.ts b/packages/protocol/src/binary-frames/diff.test.ts new file mode 100644 index 0000000000..dc8aea6ff8 --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.test.ts @@ -0,0 +1,240 @@ +import { expect, test } from "vitest"; +import { decodeDiffFrame, encodeDiffFrame } from "./diff.js"; +import { decodeBinaryFrame } from "./demux.js"; +import { decodePluginFrame } from "./plugin.js"; + +test("routes diff revisions independently of plugin frames", () => { + const frame = { + opcode: 0x40 as const, + slot: 0, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [], + }; + const bytes = encodeDiffFrame(frame); + expect([...bytes]).toEqual([64, 0, 1, 1, 0, 0, 0, 0, 0]); + expect(decodeDiffFrame(bytes)).toEqual(frame); + expect(decodeBinaryFrame(bytes)).toEqual({ kind: "diff", frame }); + expect(decodePluginFrame(bytes)).toBeNull(); +}); + +test("roundtrips Unicode runs and materializes the existing file shape", async () => { + const { materializeDiffFile } = await import("./diff.js"); + const frame = { + opcode: 0x41 as const, + slot: 255, + revision: 128, + files: [ + { + path: "Ć©.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [ + { + type: "add" as const, + content: "šŸ˜€é", + runs: [ + { style: "string", length: 2 }, + { style: null, length: 2 }, + ], + }, + ], + }, + ], + }, + ], + }; + expect(decodeDiffFrame(encodeDiffFrame(frame))).toEqual(frame); + const file = materializeDiffFile( + { + path: "Ć©.ts", + oldPath: null, + change: "modified", + content: "text", + additions: 1, + deletions: 1, + body: true, + }, + frame.files[0], + false, + ); + expect(file.hunks[0].lines[0].tokens).toEqual([ + { text: "šŸ˜€", style: "string" }, + { text: "é", style: null }, + ]); +}); + +test("preserves binary additions, modifications, deletions and mode-only text", async () => { + const { materializeDiffFile } = await import("./diff.js"); + for (const change of ["added", "modified", "deleted"] as const) { + const entry = { + path: "image.png", + oldPath: null, + change, + content: "binary" as const, + additions: 0, + deletions: 0, + body: false, + }; + const begin = { + opcode: 0x40 as const, + slot: 1, + revision: 1, + baseRevision: 0, + baseSha: "", + headSha: "", + mergeBaseSha: "", + entries: [entry], + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + expect(materializeDiffFile(entry, null, false)).toEqual({ + path: "image.png", + isNew: change === "added", + isDeleted: change === "deleted", + additions: 0, + deletions: 0, + status: "binary", + hunks: [], + }); + } + expect( + materializeDiffFile( + { + path: "script", + oldPath: null, + change: "modified", + content: "text", + additions: 0, + deletions: 0, + body: true, + }, + { path: "script", hunks: [] }, + false, + ), + ).toEqual({ + path: "script", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + status: "ok", + hunks: [], + }); +}); + +test("rejects every truncated prefix, unknown version, trailing data, and invalid coverage", () => { + const end = { + opcode: 0x42 as const, + slot: 1, + revision: 9007199254740991, + diffTooLarge: true, + omitted: ["a.ts"], + error: { code: "REF_NOT_FOUND" as const, message: "missing" }, + }; + const bytes = encodeDiffFrame(end); + expect(decodeDiffFrame(bytes)).toEqual(end); + for (let length = 0; length < bytes.length; length++) + expect(() => decodeDiffFrame(bytes.slice(0, length))).toThrow(); + const version = bytes.slice(); + version[2] = 2; + expect(() => decodeDiffFrame(version)).toThrow("version"); + expect(() => decodeDiffFrame(Uint8Array.from([...bytes, 0]))).toThrow("Trailing"); + expect(() => + encodeDiffFrame({ + opcode: 0x41, + slot: 1, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [{ type: "add", content: "šŸ˜€", runs: [{ style: null, length: 1 }] }], + }, + ], + }, + ], + }), + ).toThrow("cover"); +}); + +test("uses fixed UTF-8 byte vectors while runs count UTF-16 units", () => { + const bytes = Uint8Array.from([ + 65, 0, 1, 1, 1, 1, 120, 1, 0, 0, 1, 1, 1, 129, 7, 240, 159, 152, 128, 101, 204, 129, 1, 3, 4, + ]); + const frame = { + opcode: 0x41 as const, + slot: 0, + revision: 1, + files: [ + { + path: "x", + hunks: [ + { + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [ + { type: "add" as const, content: "šŸ˜€é", runs: [{ style: "string", length: 4 }] }, + ], + }, + ], + }, + ], + }; + expect(encodeDiffFrame(frame)).toEqual(bytes); + expect(decodeDiffFrame(bytes)).toEqual(frame); + const unknownStyle = bytes.slice(); + unknownStyle[23] = 255; + expect(decodeDiffFrame(unknownStyle)).toEqual({ + ...frame, + files: [ + { + ...frame.files[0], + hunks: [ + { + ...frame.files[0].hunks[0], + lines: [{ type: "add", content: "šŸ˜€é", runs: [{ style: null, length: 4 }] }], + }, + ], + }, + ], + }); +}); + +test("roundtrips generated entry lists across varint boundaries", () => { + for (const revision of [0, 1, 127, 128, 16383, 16384, 4294967296, Number.MAX_SAFE_INTEGER]) { + const entries = Array.from({ length: 130 }, (_, index) => ({ + path: `src/${index}-šŸ˜€.ts`, + oldPath: `old/${index}.ts`, + change: "renamed" as const, + content: "text" as const, + additions: index * 128, + deletions: index, + body: true, + })); + const begin = { + opcode: 0x40 as const, + slot: 255, + revision, + baseRevision: 0, + baseSha: "base", + headSha: "head", + mergeBaseSha: "merge", + entries, + }; + expect(decodeDiffFrame(encodeDiffFrame(begin))).toEqual(begin); + } +}); diff --git a/packages/protocol/src/binary-frames/diff.ts b/packages/protocol/src/binary-frames/diff.ts new file mode 100644 index 0000000000..c3e6fb5f7e --- /dev/null +++ b/packages/protocol/src/binary-frames/diff.ts @@ -0,0 +1,359 @@ +import type { ParsedDiffFile } from "../messages.js"; + +// Opcodes 0x40–0x4f are reserved for diff revisions. Plugins own 0x20–0x2f. +export const DiffOpcode = { Begin: 0x40, Bodies: 0x41, End: 0x42 } as const; +export const DIFF_HIGHLIGHT_STYLES = [ + null, + "keyword", + "comment", + "string", + "number", + "literal", + "function", + "definition", + "class", + "type", + "tag", + "attribute", + "property", + "variable", + "operator", + "punctuation", + "regexp", + "escape", + "meta", + "heading", + "link", +] as const; +const CHANGES = ["added", "modified", "deleted", "renamed"] as const; +const CONTENTS = ["text", "binary"] as const; +const KINDS = ["context", "add", "remove", "header"] as const; +const ERRORS = [ + null, + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", +] as const; +export interface DiffEntry { + path: string; + oldPath: string | null; + change: (typeof CHANGES)[number]; + content: (typeof CONTENTS)[number]; + additions: number; + deletions: number; + body: boolean; +} +export interface DiffRun { + style: string | null; + length: number; +} +export interface DiffStreamLine { + type: (typeof KINDS)[number]; + content: string; + runs?: DiffRun[]; +} +export interface DiffStreamHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: DiffStreamLine[]; +} +export interface DiffBody { + path: string; + hunks: DiffStreamHunk[]; +} +export interface DiffBegin { + opcode: typeof DiffOpcode.Begin; + slot: number; + revision: number; + baseRevision: number; + baseSha: string; + headSha: string; + mergeBaseSha: string; + entries: DiffEntry[]; +} +export interface DiffBodies { + opcode: typeof DiffOpcode.Bodies; + slot: number; + revision: number; + files: DiffBody[]; +} +export interface DiffEnd { + opcode: typeof DiffOpcode.End; + slot: number; + revision: number; + diffTooLarge: boolean; + omitted: string[]; + error: { code: NonNullable<(typeof ERRORS)[number]>; message: string } | null; +} +export type DiffFrame = DiffBegin | DiffBodies | DiffEnd; +export class InvalidDiffFrameError extends Error {} + +class Writer { + private bytes: number[] = []; + byte(value: number): void { + if (!Number.isInteger(value) || value < 0 || value > 255) + throw new InvalidDiffFrameError("Invalid byte"); + this.bytes.push(value); + } + uint(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new InvalidDiffFrameError("Invalid unsigned integer"); + do { + const next = value % 128; + value = Math.floor(value / 128); + this.byte(next + (value ? 128 : 0)); + } while (value); + } + str(value: string): void { + const bytes = new TextEncoder().encode(value); + this.uint(bytes.length); + for (const byte of bytes) this.byte(byte); + } + finish(): Uint8Array { + return Uint8Array.from(this.bytes); + } +} +class Reader { + private offset = 0; + constructor(private bytes: Uint8Array) {} + byte(): number { + if (this.offset >= this.bytes.length) throw new InvalidDiffFrameError("Truncated diff frame"); + return this.bytes[this.offset++]; + } + uint(): number { + let value = 0; + let factor = 1; + for (let index = 0; index < 8; index++) { + const byte = this.byte(); + value += (byte & 127) * factor; + if (!Number.isSafeInteger(value)) throw new InvalidDiffFrameError("Integer overflow"); + if (byte < 128) return value; + factor *= 128; + } + throw new InvalidDiffFrameError("Invalid varint"); + } + count(): number { + const value = this.uint(); + if (value > this.bytes.length - this.offset) + throw new InvalidDiffFrameError("Count exceeds frame"); + return value; + } + str(): string { + const size = this.count(); + const start = this.offset; + this.offset += size; + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + this.bytes.subarray(start, this.offset), + ); + } catch { + throw new InvalidDiffFrameError("Invalid UTF-8"); + } + } + done(): void { + if (this.offset !== this.bytes.length) throw new InvalidDiffFrameError("Trailing bytes"); + } +} +function enumAt(values: readonly T[], index: number): T { + if (index >= values.length) throw new InvalidDiffFrameError("Unknown enum value"); + return values[index]; +} +function validateRuns(line: DiffStreamLine): void { + if (!line.runs) return; + let length = 0; + for (const run of line.runs) { + if (!Number.isSafeInteger(run.length) || run.length <= 0) + throw new InvalidDiffFrameError("Invalid run length"); + length += run.length; + } + if (length !== line.content.length) throw new InvalidDiffFrameError("Runs do not cover line"); +} +function writeLine(writer: Writer, line: DiffStreamLine): void { + validateRuns(line); + writer.byte(KINDS.indexOf(line.type) | (line.runs ? 128 : 0)); + writer.str(line.content); + writer.uint(line.runs ? line.runs.length : 0); + for (const run of line.runs ?? []) { + const style = DIFF_HIGHLIGHT_STYLES.findIndex((value) => value === run.style); + writer.byte(Math.max(0, style)); + writer.uint(run.length); + } +} +function readLine(reader: Reader): DiffStreamLine { + const flags = reader.byte(); + const type = enumAt(KINDS, flags & 127); + const content = reader.str(); + const count = reader.count(); + const runs: DiffRun[] = []; + for (let index = 0; index < count; index++) { + const styleIndex = reader.byte(); + const style = DIFF_HIGHLIGHT_STYLES[styleIndex] ?? null; + runs.push({ style, length: reader.uint() }); + } + if (!(flags & 128)) { + if (count) throw new InvalidDiffFrameError("Runs without flag"); + return { type, content }; + } + const line = { type, content, runs }; + validateRuns(line); + return line; +} +export function encodeDiffFrame(frame: DiffFrame): Uint8Array { + const writer = new Writer(); + writer.byte(frame.opcode); + writer.byte(frame.slot); + writer.byte(1); + writer.uint(frame.revision); + if (frame.opcode === DiffOpcode.Begin) { + writer.uint(frame.baseRevision); + writer.str(frame.baseSha); + writer.str(frame.headSha); + writer.str(frame.mergeBaseSha); + writer.uint(frame.entries.length); + for (const entry of frame.entries) { + if (entry.content === "binary" && entry.body) + throw new InvalidDiffFrameError("Binary entry has a body"); + writer.str(entry.path); + writer.str(entry.oldPath ?? ""); + writer.byte(CHANGES.indexOf(entry.change)); + writer.byte(CONTENTS.indexOf(entry.content)); + writer.uint(entry.additions); + writer.uint(entry.deletions); + writer.byte(Number(entry.body)); + } + } else if (frame.opcode === DiffOpcode.Bodies) { + writer.uint(frame.files.length); + for (const file of frame.files) { + writer.str(file.path); + writer.uint(file.hunks.length); + for (const hunk of file.hunks) { + writer.uint(hunk.oldStart); + writer.uint(hunk.oldCount); + writer.uint(hunk.newStart); + writer.uint(hunk.newCount); + writer.uint(hunk.lines.length); + for (const line of hunk.lines) writeLine(writer, line); + } + } + } else { + writer.byte(Number(frame.diffTooLarge)); + writer.uint(frame.omitted.length); + for (const path of frame.omitted) writer.str(path); + writer.byte(frame.error ? ERRORS.indexOf(frame.error.code) : 0); + writer.str(frame.error ? frame.error.message : ""); + } + return writer.finish(); +} +export function decodeDiffFrame(bytes: Uint8Array): DiffFrame { + const reader = new Reader(bytes); + const opcode = reader.byte(); + const slot = reader.byte(); + if (reader.byte() !== 1) throw new InvalidDiffFrameError("Unknown diff version"); + const revision = reader.uint(); + let frame: DiffFrame; + if (opcode === DiffOpcode.Begin) { + const baseRevision = reader.uint(); + const baseSha = reader.str(); + const headSha = reader.str(); + const mergeBaseSha = reader.str(); + const count = reader.count(); + const entries: DiffEntry[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const oldPath = reader.str() || null; + const change = enumAt(CHANGES, reader.byte()); + const content = enumAt(CONTENTS, reader.byte()); + const additions = reader.uint(); + const deletions = reader.uint(); + const bodyByte = reader.byte(); + if (bodyByte > 1 || (content === "binary" && bodyByte)) + throw new InvalidDiffFrameError("Invalid entry body"); + entries.push({ path, oldPath, change, content, additions, deletions, body: bodyByte === 1 }); + } + frame = { opcode, slot, revision, baseRevision, baseSha, headSha, mergeBaseSha, entries }; + } else if (opcode === DiffOpcode.Bodies) { + const count = reader.count(); + const files: DiffBody[] = []; + for (let index = 0; index < count; index++) { + const path = reader.str(); + const hunkCount = reader.count(); + const hunks: DiffStreamHunk[] = []; + for (let h = 0; h < hunkCount; h++) { + const oldStart = reader.uint(); + const oldCount = reader.uint(); + const newStart = reader.uint(); + const newCount = reader.uint(); + const lineCount = reader.count(); + const lines: DiffStreamLine[] = []; + for (let l = 0; l < lineCount; l++) lines.push(readLine(reader)); + hunks.push({ oldStart, oldCount, newStart, newCount, lines }); + } + files.push({ path, hunks }); + } + frame = { opcode, slot, revision, files }; + } else if (opcode === DiffOpcode.End) { + const flags = reader.byte(); + if (flags > 1) throw new InvalidDiffFrameError("Unknown flags"); + const count = reader.count(); + const omitted: string[] = []; + for (let index = 0; index < count; index++) omitted.push(reader.str()); + const code = enumAt(ERRORS, reader.byte()); + const message = reader.str(); + frame = { + opcode, + slot, + revision, + diffTooLarge: flags === 1, + omitted, + error: code ? { code, message } : null, + }; + } else { + throw new InvalidDiffFrameError("Unknown diff opcode"); + } + reader.done(); + return frame; +} +export function runsToTokens( + line: DiffStreamLine, +): NonNullable { + let offset = 0; + return (line.runs ?? []).map((run) => { + const text = line.content.slice(offset, offset + run.length); + offset += run.length; + return { text, style: run.style }; + }); +} +export function materializeDiffFile( + entry: DiffEntry, + body: DiffBody | null, + omitted: boolean, +): ParsedDiffFile { + let status: "ok" | "binary" | "too_large" = "ok"; + if (entry.content === "binary") status = "binary"; + else if (omitted) status = "too_large"; + const hunks = body + ? body.hunks.map((hunk) => ({ + ...hunk, + lines: hunk.lines.map((line) => { + if (line.runs) + return { type: line.type, content: line.content, tokens: runsToTokens(line) }; + return { type: line.type, content: line.content }; + }), + })) + : []; + const file: ParsedDiffFile = { + path: entry.path, + isNew: entry.change === "added", + isDeleted: entry.change === "deleted", + additions: entry.additions, + deletions: entry.deletions, + hunks, + status, + }; + if (entry.oldPath !== null) file.oldPath = entry.oldPath; + return file; +} diff --git a/packages/protocol/src/binary-frames/index.ts b/packages/protocol/src/binary-frames/index.ts index bb2e7e002d..946a1c7051 100644 --- a/packages/protocol/src/binary-frames/index.ts +++ b/packages/protocol/src/binary-frames/index.ts @@ -3,3 +3,4 @@ export * from "./file-transfer.js"; export * from "./terminal.js"; export * from "./host-route.js"; export * from "./plugin.js"; +export * from "./diff.js"; diff --git a/packages/protocol/src/messages.diff.test.ts b/packages/protocol/src/messages.diff.test.ts new file mode 100644 index 0000000000..f2831ab147 --- /dev/null +++ b/packages/protocol/src/messages.diff.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; +import { DiffSubscribeRequestSchema, DiffSubscribeResponseSchema } from "./messages.js"; + +test("accepts explicit endpoints without changing legacy schemas", () => { + const request = { + type: "diff.subscribe.request", + requestId: "r", + cwd: "/repo", + base: { kind: "rev", rev: "v1" }, + head: { kind: "worktree" }, + mergeBase: true, + highlight: false, + }; + expect(DiffSubscribeRequestSchema.parse(request)).toEqual(request); + expect( + DiffSubscribeRequestSchema.safeParse({ ...request, base: { kind: "unknown" } }).success, + ).toBe(false); + expect( + DiffSubscribeResponseSchema.safeParse({ + type: "diff.subscribe.response", + payload: { requestId: "r", subscriptionId: "s", slot: 256, error: null }, + }).success, + ).toBe(false); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 64f37064e2..05cfb8a3be 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2288,6 +2288,51 @@ const CheckoutDiffCompareSchema = z.object({ ignoreWhitespace: z.boolean().optional(), }); +export const DiffEndpointSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("rev"), rev: z.string() }), + z.object({ kind: z.literal("index") }), + z.object({ kind: z.literal("worktree") }), + z.object({ kind: z.literal("empty") }), +]); +export const DiffErrorSchema = z.object({ + code: z.enum([ + "NOT_GIT_REPO", + "REF_NOT_FOUND", + "INVALID_COMPARE", + "TOO_MANY_SUBSCRIPTIONS", + "UNKNOWN", + ]), + message: z.string(), +}); +export const DiffSubscribeRequestSchema = z.object({ + type: z.literal("diff.subscribe.request"), + requestId: z.string(), + cwd: z.string(), + base: DiffEndpointSchema, + head: DiffEndpointSchema, + mergeBase: z.boolean().optional(), + ignoreWhitespace: z.boolean().optional(), + highlight: z.boolean().optional(), +}); +// Unsubscribe is fire-and-forget; release has no correlated response. +export const DiffUnsubscribeRequestSchema = z.object({ + type: z.literal("diff.unsubscribe.request"), + subscriptionId: z.string(), +}); +export const DiffSubscribeResponseSchema = z.object({ + type: z.literal("diff.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string(), + slot: z.number().int().min(0).max(255), + error: DiffErrorSchema.nullable(), + }), +}); +export type DiffEndpoint = z.infer; +export type DiffError = z.infer; +export type DiffSubscribeRequest = z.infer; +export type DiffSubscribeResponse = z.infer; + export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), cwd: z.string(), @@ -3428,6 +3473,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, + DiffSubscribeRequestSchema, + DiffUnsubscribeRequestSchema, CheckoutStatusRequestSchema, CheckoutDiffGetRequestSchema, SubscribeCheckoutDiffRequestSchema, @@ -3887,6 +3934,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + diffSubscription: z.boolean().optional(), binaryChunkingV1: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(), @@ -5428,6 +5476,7 @@ const CheckoutStatusCommonSchema = z.object({ error: CheckoutErrorSchema.nullable(), requestId: z.string(), baseRefExact: z.string().nullable().optional(), + comparisonBaseRef: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7235,6 +7284,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentDeletedMessageSchema, AgentArchivedMessageSchema, CloseItemsResponseSchema, + DiffSubscribeResponseSchema, CheckoutStatusResponseSchema, CheckoutStatusUpdateSchema, CheckoutDiffGetResponseSchema, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 8cc9d6d1a0..248b525c16 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -194,6 +194,8 @@ const INBOUND_PERMISSION = { stash_pop_request: "workspace.write", stash_save_request: "workspace.write", "checkout.diff.get.request": "workspace.read", + "diff.subscribe.request": "workspace.read", + "diff.unsubscribe.request": "workspace.read", subscribe_checkout_diff_request: "workspace.read", subscribe_terminal_request: "workspace.read", subscribe_terminals_request: "workspace.read", @@ -437,6 +439,7 @@ const OUTBOUND_PERMISSION = { stash_save_response: "workspace.read", status: "daemon.read", "checkout.diff.get.response": "workspace.read", + "diff.subscribe.response": "workspace.read", subscribe_checkout_diff_response: "workspace.read", subscribe_terminal_response: "workspace.read", "terminal.rename.response": "workspace.write", diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 90c900435c..93242cb941 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -62,6 +62,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -83,6 +84,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, baseRefExact: snapshot.git.baseRefExact, + comparisonBaseRef: snapshot.git.comparisonBaseRef, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3dec72f45f..3e041b9b0d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3437,7 +3437,9 @@ export class Session { await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source); return; } - this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + if (binaryFrame.kind === "terminal") { + this.terminalController.handleBinaryFrame(binaryFrame.frame, source); + } } private async handleRestartServerRequest( diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index ea91186e9b..00c7084c7b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -161,6 +161,7 @@ export interface WorkspaceGitRuntimeSnapshot { isDirty: boolean | null; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -3626,6 +3627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, baseRefExact: checkoutStatus.baseRefExact, + comparisonBaseRef: checkoutStatus.comparisonBaseRef, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts index 7baac91c23..1f82fe73b0 100644 --- a/packages/server/src/utils/checkout-git.commits.test.ts +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -3,7 +3,12 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { + getCommitFileDiff, + listCheckoutCommits, + getCheckoutStatus, + getCheckoutDiff, +} from "./checkout-git.js"; import { writePaseoWorktreeMetadata } from "./worktree-metadata.js"; const tempDirs: string[] = []; @@ -393,3 +398,29 @@ describe("listCheckoutCommits", () => { ]); }); }); + +it("compares tag-based worktrees in status, history and legacy diff modes", async () => { + const { repoDir: mainRepoDir, tempDir } = initRepoOnMain(); + const worktreesRoot = join(tempDir, "worktrees"); + const repoDir = join(worktreesRoot, "repo-hash", "tag-feature"); + mkdirSync(join(worktreesRoot, "repo-hash"), { recursive: true }); + const context = { worktreesRoot }; + git(["tag", "v1"], mainRepoDir); + git(["worktree", "add", "-b", "tag-feature", repoDir, "v1"], mainRepoDir); + await writePaseoWorktreeMetadata(repoDir, { baseRefName: "refs/tags/v1" }); + commitFile(repoDir, "tag.txt", "committed\n", "tag feature"); + writeFileSync(join(repoDir, "tag.txt"), "edited\n"); + const status = await getCheckoutStatus(repoDir, context); + expect(status).toMatchObject({ + isGit: true, + comparisonBaseRef: "refs/tags/v1", + aheadBehind: { ahead: 1, behind: 0 }, + }); + const history = await listCheckoutCommits({ cwd: repoDir, context }); + expect(history.baseRef).toBe("refs/tags/v1"); + expect(history.commits[0].subject).toBe("tag feature"); + const committed = await getCheckoutDiff(repoDir, { mode: "base", baseRef: "refs/tags/v1" }); + expect(committed.diff).toContain("+committed"); + const all = await getCheckoutDiff(repoDir, { mode: "all", baseRef: "refs/tags/v1" }); + expect(all.diff).toContain("+edited"); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index d793e2c683..422ca2b0ee 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -906,6 +906,7 @@ export interface CheckoutStatusGitNonPaseo { isDirty: boolean; baseRef: string | null; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -926,6 +927,7 @@ export interface CheckoutStatusGitPaseo { isDirty: boolean; baseRef: string; baseRefExact?: string | null; + comparisonBaseRef?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1748,6 +1750,24 @@ function isQualifiedBranchRef(ref: string): boolean { return ref.startsWith("refs/heads/") || ref.startsWith("refs/remotes/"); } +async function resolveCommitComparisonRef( + cwd: string, + name: string, + context?: CheckoutContext, +): Promise { + await runGitCommand(["rev-parse", "--verify", `${name}^{commit}`], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + const result = await runGitCommand(["rev-parse", "--symbolic-full-name", name], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + return result.stdout.trim() || name; +} + async function resolveBestComparisonBaseRef( cwd: string, baseRef: string, @@ -1774,11 +1794,7 @@ async function resolveBestComparisonBaseRef( return normalized.localName; } - const refName = - baseRef.startsWith("origin/") || baseRef.startsWith("refs/remotes/origin/") - ? normalized.originRef - : normalized.localName; - throw new Error(`Base branch not found locally or on origin: ${refName}`); + return resolveCommitComparisonRef(cwd, baseRef, context); } async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise { @@ -1801,7 +1817,7 @@ async function resolveMostAheadBaseRef(cwd: string, baseRef: string): Promise