wave-code 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/bundle/wave.mjs +1275 -895
  2. package/package.json +4 -2
  3. package/src/commands/update.ts +62 -22
  4. package/src/components/BackgroundTaskManager.tsx +76 -54
  5. package/src/components/ConfirmationDetails.tsx +17 -3
  6. package/src/components/InputBox.tsx +2 -0
  7. package/src/components/MessageBlockItem.tsx +0 -6
  8. package/src/components/WorkflowManager.tsx +38 -138
  9. package/src/components/WorkflowRunDetail.tsx +193 -0
  10. package/src/constants/commands.ts +6 -0
  11. package/src/contexts/useChat.tsx +359 -127
  12. package/src/daemon/commands.ts +13 -4
  13. package/src/daemon-cli.ts +6 -1
  14. package/src/hooks/useInputManager.ts +4 -0
  15. package/src/hooks/useRemoteControl.ts +324 -0
  16. package/src/index.ts +35 -0
  17. package/src/managers/inputHandlers.ts +2 -0
  18. package/src/managers/inputReducer.ts +1 -0
  19. package/src/print-cli.ts +5 -0
  20. package/src/reducers/workflowManagerReducer.ts +21 -2
  21. package/src/remote/frameBudget.ts +45 -0
  22. package/src/remote/hostIdentity.ts +128 -0
  23. package/src/remote/messageWindow.ts +97 -0
  24. package/src/remote/notificationCoalescer.ts +174 -0
  25. package/src/remote/relayClient.ts +668 -0
  26. package/src/remote/remoteControlCliHost.ts +391 -0
  27. package/src/remote/remoteControlCommands.ts +197 -0
  28. package/src/remote/remoteControlConfig.ts +334 -0
  29. package/src/remote/remoteControlService.ts +799 -0
  30. package/src/remote/remoteControlSink.ts +77 -0
  31. package/src/remote/remoteNotifications.ts +194 -0
  32. package/src/stdio/agentBridge.ts +608 -74
  33. package/src/stdio/daemonServer.ts +30 -0
  34. package/src/stdio/jsonRpcConnection.ts +11 -2
  35. package/src/stdio/protocol.ts +14 -115
  36. package/src/stdio/stdioServer.ts +33 -2
  37. package/src/stdio-cli.ts +11 -2
  38. package/src/utils/gitCredentialHelper.ts +106 -0
  39. package/src/utils/logger.ts +5 -2
  40. package/src/utils/rewindCheckpoints.ts +2 -4
  41. package/src/utils/workspaceDiff.ts +620 -0
  42. package/src/utils/worktree.ts +97 -0
@@ -0,0 +1,620 @@
1
+ /**
2
+ * Read-only workspace git-diff service for the desktop diff panel.
3
+ *
4
+ * It lives here rather than in the desktop main process because it must run on
5
+ * the machine that owns the repository: the desktop reaches it over the same
6
+ * CLI RPC every other git operation uses (`listGitBranches`, worktrees), so a
7
+ * remote session does the work inside its remote daemon. The desktop running
8
+ * it itself meant one `ssh` process per git command and per file — dozens of
9
+ * connection handshakes for a single panel refresh.
10
+ */
11
+
12
+ import { execFile } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { promisify } from "node:util";
16
+
17
+ // Never execFileSync: the shared `wave --stdio` process handles every desktop
18
+ // session, so a synchronous git call would freeze them all (see utils/worktree).
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ export type WorkspaceFileStatus =
22
+ | "added"
23
+ | "modified"
24
+ | "deleted"
25
+ | "renamed"
26
+ | "untracked";
27
+
28
+ export interface WorkspaceDiffFile {
29
+ /** Repo-relative path (new path for renames). */
30
+ path: string;
31
+ status: WorkspaceFileStatus;
32
+ /** Previous path, renames only. */
33
+ oldPath?: string;
34
+ additions: number;
35
+ deletions: number;
36
+ /** Unified-diff hunk lines (from the first @@ on), line-truncated. */
37
+ hunks: string;
38
+ truncated: boolean;
39
+ /** Binary or unreadably large — no textual hunks available. */
40
+ binary: boolean;
41
+ }
42
+
43
+ /** One commit of the session's own range, newest first. */
44
+ export interface WorkspaceDiffCommit {
45
+ sha: string;
46
+ /** 7-char prefix, for the picker label. */
47
+ shortSha: string;
48
+ subject: string;
49
+ }
50
+
51
+ /** What the whole file list is measured against. */
52
+ export interface WorkspaceDiffBase {
53
+ /** Display name: the default branch, the injected ref, "HEAD", or "索引". */
54
+ label: string;
55
+ /** Full sha of the base commit; null when the base is the index. */
56
+ sha: string | null;
57
+ kind: "default-branch" | "env" | "head" | "index";
58
+ /** Resolved ref of the default branch, null for the other kinds. */
59
+ ref: string | null;
60
+ }
61
+
62
+ /** Which range the file list covers. */
63
+ export type WorkspaceDiffScope =
64
+ | { kind: "all" }
65
+ | { kind: "uncommitted" }
66
+ | { kind: "commit"; sha: string; shortSha: string; subject: string };
67
+
68
+ /** A merge/rebase/cherry-pick/revert that has not finished yet. */
69
+ export type TransientGitOperation =
70
+ | "merge"
71
+ | "rebase"
72
+ | "cherry-pick"
73
+ | "revert";
74
+
75
+ export type WorkspaceDiffResult =
76
+ | { kind: "not-a-repo" }
77
+ | { kind: "transient"; operation: TransientGitOperation }
78
+ | {
79
+ kind: "ok";
80
+ base: WorkspaceDiffBase;
81
+ scope: WorkspaceDiffScope;
82
+ commits: WorkspaceDiffCommit[];
83
+ files: WorkspaceDiffFile[];
84
+ };
85
+
86
+ /** Per-file hunk line cap — beyond this the panel shows a truncation note. */
87
+ export const MAX_DIFF_LINES = 2000;
88
+ /**
89
+ * External base injection for the "all changes" range, mirroring Claude Code's
90
+ * `CLAUDE_CODE_BASE_REF`: a sandbox / managed container knows what the base
91
+ * should be and cannot rely on the local fork point being it.
92
+ */
93
+ const BASE_REF_ENV = "WAVE_DIFF_BASE_REF";
94
+ /** Untracked files larger than this are treated as binary (never read). */
95
+ const MAX_UNTRACKED_BYTES = 2 * 1024 * 1024;
96
+ const GIT_BUFFER = 16 * 1024 * 1024;
97
+
98
+ /** Run git in `cwd` and return stdout. */
99
+ async function git(cwd: string, args: string[]): Promise<string> {
100
+ const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
101
+ encoding: "utf-8" as const,
102
+ maxBuffer: GIT_BUFFER,
103
+ });
104
+ return stdout;
105
+ }
106
+
107
+ /** Options shared by every diff invocation: renames on, no user color/ext hooks. */
108
+ const DIFF_OPTIONS = ["-M", "--no-color", "--no-ext-diff"];
109
+
110
+ function diffArgs(
111
+ rev: string,
112
+ format: string[],
113
+ paths: string[] = [],
114
+ ): string[] {
115
+ return [
116
+ "diff",
117
+ rev,
118
+ ...DIFF_OPTIONS,
119
+ ...format,
120
+ ...(paths.length > 0 ? ["--", ...paths] : []),
121
+ ];
122
+ }
123
+
124
+ /** Non-empty NUL-separated records (all the -z outputs below use NUL). */
125
+ function parseZRecords(out: string): string[] {
126
+ return out.split("\0").filter((record) => record !== "");
127
+ }
128
+
129
+ interface DiffEntry {
130
+ path: string;
131
+ oldPath?: string;
132
+ status: WorkspaceFileStatus;
133
+ }
134
+
135
+ /** `--name-status` codes are a letter plus a similarity percentage (R100/C075). */
136
+ const NAME_STATUS_CODE = /^[A-Z]\d*$/;
137
+
138
+ /**
139
+ * Parse `git diff --name-status -z`. Records are `<code>\0<path>\0`, except
140
+ * renames/copies which are `<code>\0<old path>\0<new path>\0` — NOTE the order
141
+ * is the opposite of `status --porcelain -z` (which puts the new path first).
142
+ */
143
+ export function parseNameStatusZ(z: string): DiffEntry[] {
144
+ const parts = z.split("\0");
145
+ const entries: DiffEntry[] = [];
146
+ let i = 0;
147
+ while (i < parts.length) {
148
+ const code = parts[i++];
149
+ if (!NAME_STATUS_CODE.test(code)) continue;
150
+ if (code[0] === "R" || code[0] === "C") {
151
+ const oldPath = parts[i++] ?? "";
152
+ const newPath = parts[i++] ?? "";
153
+ if (newPath) entries.push({ path: newPath, oldPath, status: "renamed" });
154
+ continue;
155
+ }
156
+ const filePath = parts[i++] ?? "";
157
+ if (!filePath) continue;
158
+ const status: WorkspaceFileStatus =
159
+ code[0] === "A" ? "added" : code[0] === "D" ? "deleted" : "modified";
160
+ entries.push({ path: filePath, status });
161
+ }
162
+ return entries;
163
+ }
164
+
165
+ interface FileStats {
166
+ additions: number;
167
+ deletions: number;
168
+ binary: boolean;
169
+ }
170
+
171
+ const NUMSTAT_ROW = /^(\d+|-)\t(\d+|-)\t/;
172
+
173
+ /**
174
+ * Parse `git diff --numstat -z` into a map keyed by the NEW path. Rows are
175
+ * `add\tdel\t<path>\0` with `-` on both sides for binary files; a rename row
176
+ * has an EMPTY path field and the two paths follow as separate records
177
+ * (`add\tdel\t\0<old>\0<new>\0`), mirroring the name-status order.
178
+ */
179
+ export function parseNumstatZ(z: string): Map<string, FileStats> {
180
+ const parts = z.split("\0");
181
+ const stats = new Map<string, FileStats>();
182
+ let i = 0;
183
+ while (i < parts.length) {
184
+ const row = parts[i++];
185
+ const m = NUMSTAT_ROW.exec(row);
186
+ // A path record that is not a row belongs to a rename we already consumed.
187
+ if (!m) continue;
188
+ const value: FileStats = {
189
+ additions: m[1] === "-" ? 0 : Number.parseInt(m[1], 10),
190
+ deletions: m[2] === "-" ? 0 : Number.parseInt(m[2], 10),
191
+ binary: m[1] === "-",
192
+ };
193
+ // Everything after the two tab-delimited numbers is the path — taken from
194
+ // the row itself, so paths containing tabs survive.
195
+ const inlinePath = row.slice(m[0].length);
196
+ if (inlinePath) {
197
+ stats.set(inlinePath, value);
198
+ continue;
199
+ }
200
+ const newPath = parts[i + 1] ?? "";
201
+ i += 2;
202
+ if (newPath) stats.set(newPath, value);
203
+ }
204
+ return stats;
205
+ }
206
+
207
+ function truncateHunks(hunks: string): { hunks: string; truncated: boolean } {
208
+ const lines = hunks.split("\n");
209
+ if (lines.length <= MAX_DIFF_LINES) return { hunks, truncated: false };
210
+ return { hunks: lines.slice(0, MAX_DIFF_LINES).join("\n"), truncated: true };
211
+ }
212
+
213
+ interface ResolvedBase {
214
+ base: WorkspaceDiffBase;
215
+ /** Revision to diff against (`--cached` before the first commit). */
216
+ rev: string;
217
+ /** Range start for the commit list; null when there is nothing to list. */
218
+ commitsFrom: string | null;
219
+ }
220
+
221
+ /**
222
+ * The base is the point this session branched off the repository's default
223
+ * branch, so the panel shows the agent's commits AND the uncommitted worktree
224
+ * in one diff (which `HEAD` cannot — it hides everything already committed).
225
+ *
226
+ * `refs/remotes/origin/HEAD` names that branch; without it (a repo that never
227
+ * had a default branch set) fall back to a local `main`/`master`, and without
228
+ * either — or without a common ancestor — fall back to `HEAD`. An injected
229
+ * `WAVE_DIFF_BASE_REF` is tried before all of them and simply loses its turn
230
+ * when the ref it names does not resolve.
231
+ */
232
+ async function resolveDiffBase(
233
+ cwd: string,
234
+ headSha: string | null,
235
+ ): Promise<ResolvedBase> {
236
+ if (!headSha) return indexBase();
237
+
238
+ const injected = (process.env[BASE_REF_ENV] ?? "").trim();
239
+ const remoteHead = (
240
+ await git(cwd, [
241
+ "symbolic-ref",
242
+ "-q",
243
+ "--short",
244
+ "refs/remotes/origin/HEAD",
245
+ ]).catch(() => "")
246
+ ).trim();
247
+ const candidates: {
248
+ ref: string;
249
+ label: string;
250
+ kind: WorkspaceDiffBase["kind"];
251
+ }[] = [];
252
+ if (injected)
253
+ candidates.push({ ref: injected, label: injected, kind: "env" });
254
+ if (remoteHead)
255
+ candidates.push({
256
+ ref: `refs/remotes/${remoteHead}`,
257
+ label: remoteHead,
258
+ kind: "default-branch",
259
+ });
260
+ candidates.push({
261
+ ref: "refs/heads/main",
262
+ label: "main",
263
+ kind: "default-branch",
264
+ });
265
+ candidates.push({
266
+ ref: "refs/heads/master",
267
+ label: "master",
268
+ kind: "default-branch",
269
+ });
270
+
271
+ for (const candidate of candidates) {
272
+ const exists = await git(cwd, [
273
+ "rev-parse",
274
+ "--verify",
275
+ "--quiet",
276
+ `${candidate.ref}^{commit}`,
277
+ ]).then(
278
+ () => true,
279
+ () => false,
280
+ );
281
+ if (!exists) continue;
282
+ const mergeBase = (
283
+ await git(cwd, ["merge-base", "HEAD", candidate.ref]).catch(() => "")
284
+ ).trim();
285
+ // A default branch exists but has no fork point with HEAD (unrelated
286
+ // histories): the spec asks for the HEAD fallback, not for probing on.
287
+ if (!mergeBase) break;
288
+ return {
289
+ base: {
290
+ label: candidate.label,
291
+ sha: mergeBase,
292
+ kind: candidate.kind,
293
+ // The injected ref is whatever the caller wrote (possibly a sha), so
294
+ // only a real default branch contributes a ref to display.
295
+ ref: candidate.kind === "default-branch" ? candidate.ref : null,
296
+ },
297
+ rev: mergeBase,
298
+ commitsFrom: mergeBase,
299
+ };
300
+ }
301
+ return headBase(headSha);
302
+ }
303
+
304
+ /** `--cached` semantics: a repository that has no commit yet. */
305
+ function indexBase(): ResolvedBase {
306
+ return {
307
+ base: { label: "索引", sha: null, kind: "index", ref: null },
308
+ rev: "--cached",
309
+ commitsFrom: null,
310
+ };
311
+ }
312
+
313
+ /** `HEAD` semantics: the "未提交改动" range, and the no-default-branch fallback. */
314
+ function headBase(headSha: string): ResolvedBase {
315
+ return {
316
+ base: { label: "HEAD", sha: headSha, kind: "head", ref: null },
317
+ rev: "HEAD",
318
+ // HEAD..HEAD is empty by construction, so no log call is needed.
319
+ commitsFrom: null,
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The unfinished operation whose incoming changes are sitting in the worktree,
325
+ * or null. Same list, same reasoning and same `fs.access` probe as Claude
326
+ * Code's `isInTransientGitState()`: those changes were not made by this session
327
+ * on purpose, so presenting them as the user's own diff is wrong.
328
+ */
329
+ async function transientOperation(
330
+ root: string,
331
+ ): Promise<TransientGitOperation | null> {
332
+ // The marker files live in the git dir, which is NOT `<root>/.git` inside a
333
+ // worktree (it is `<common>/worktrees/<name>`) — resolve it instead of
334
+ // assuming the layout.
335
+ const gitDir = (
336
+ await git(root, ["rev-parse", "--absolute-git-dir"]).catch(() => "")
337
+ ).trim();
338
+ if (!gitDir) return null;
339
+ const markers: { file: string; operation: TransientGitOperation }[] = [
340
+ { file: "MERGE_HEAD", operation: "merge" },
341
+ { file: "REBASE_HEAD", operation: "rebase" },
342
+ { file: "CHERRY_PICK_HEAD", operation: "cherry-pick" },
343
+ { file: "REVERT_HEAD", operation: "revert" },
344
+ ];
345
+ for (const marker of markers) {
346
+ const present = await fs.promises
347
+ .access(path.join(gitDir, marker.file))
348
+ .then(
349
+ () => true,
350
+ () => false,
351
+ );
352
+ if (present) return marker.operation;
353
+ }
354
+ return null;
355
+ }
356
+
357
+ /** The session's own commits (merges excluded — they replay others' work). */
358
+ async function listCommits(
359
+ cwd: string,
360
+ from: string,
361
+ ): Promise<WorkspaceDiffCommit[]> {
362
+ const out = await git(cwd, [
363
+ "log",
364
+ "-z",
365
+ "--no-merges",
366
+ "--topo-order",
367
+ "--pretty=oneline",
368
+ `${from}..HEAD`,
369
+ ]).catch(() => "");
370
+ const commits: WorkspaceDiffCommit[] = [];
371
+ for (const record of parseZRecords(out)) {
372
+ // `--pretty=oneline` = "<full sha> <subject>".
373
+ const space = record.indexOf(" ");
374
+ if (space <= 0) continue;
375
+ const sha = record.slice(0, space);
376
+ commits.push({
377
+ sha,
378
+ shortSha: sha.slice(0, 7),
379
+ subject: record.slice(space + 1),
380
+ });
381
+ }
382
+ return commits;
383
+ }
384
+
385
+ /**
386
+ * The `diff --git` header git prints for `entry` — renames are `old → new`.
387
+ * The per-file lookup below uses it as a key, so it must match git byte for
388
+ * byte; a path git quotes (`core.quotePath`, embedded quotes) simply misses
389
+ * and is re-fetched on its own.
390
+ */
391
+ function headerFor(entry: DiffEntry): string {
392
+ return `diff --git a/${entry.oldPath ?? entry.path} b/${entry.path}`;
393
+ }
394
+
395
+ /**
396
+ * Fetch every changed file's patch with ONE `git diff <rev>` and index the
397
+ * result by header. The alternative — a `git diff <rev> -- <paths>` per file —
398
+ * costs one git process per file, which the original ssh-based caller paid as
399
+ * a full connection handshake each.
400
+ */
401
+ async function wholeTreePatches(
402
+ cwd: string,
403
+ rev: string,
404
+ ): Promise<Map<string, string>> {
405
+ const patch = await git(cwd, diffArgs(rev, [])).catch(() => "");
406
+ const blocks = new Map<string, string>();
407
+ // `diff --git ` cannot occur inside a hunk body: an added line is prefixed
408
+ // with `+`, so every occurrence starts a file block.
409
+ for (const block of patch.split(/^diff --git /m).slice(1)) {
410
+ const newline = block.indexOf("\n");
411
+ if (newline === -1) continue;
412
+ blocks.set(
413
+ `diff --git ${block.slice(0, newline)}`,
414
+ block.slice(newline + 1),
415
+ );
416
+ }
417
+ return blocks;
418
+ }
419
+
420
+ /** The hunk body of a file's patch — everything from its first `@@` on. */
421
+ function hunksFromPatch(patch: string): { hunks: string; truncated: boolean } {
422
+ const lines = patch.split("\n");
423
+ const at = lines.findIndex((line) => line.startsWith("@@"));
424
+ if (at === -1) return { hunks: "", truncated: false };
425
+ return truncateHunks(lines.slice(at).join("\n").trimEnd());
426
+ }
427
+
428
+ async function diffForTracked(
429
+ cwd: string,
430
+ rev: string,
431
+ entry: DiffEntry,
432
+ stats: FileStats | undefined,
433
+ patches: Map<string, string>,
434
+ ): Promise<WorkspaceDiffFile> {
435
+ const binary = stats?.binary ?? false;
436
+ const additions = stats?.additions ?? 0;
437
+ const deletions = stats?.deletions ?? 0;
438
+
439
+ let hunks = "";
440
+ let truncated = false;
441
+ // A binary file has no textual patch, and a file whose net change is empty
442
+ // (pure rename, mode-only change) has no hunks — skip the extra call.
443
+ if (!binary && additions + deletions > 0) {
444
+ let patch = patches.get(headerFor(entry));
445
+ if (patch === undefined) {
446
+ // Both paths must be passed: limiting the pathspec to the new path turns
447
+ // rename detection off, and the patch then reads as a full file rewrite.
448
+ const paths = entry.oldPath ? [entry.oldPath, entry.path] : [entry.path];
449
+ patch = await git(cwd, diffArgs(rev, [], paths)).catch(() => "");
450
+ }
451
+ ({ hunks, truncated } = hunksFromPatch(patch));
452
+ }
453
+ return {
454
+ path: entry.path,
455
+ status: entry.status,
456
+ oldPath: entry.oldPath,
457
+ additions,
458
+ deletions,
459
+ hunks,
460
+ truncated,
461
+ binary,
462
+ };
463
+ }
464
+
465
+ const UNREADABLE: Omit<WorkspaceDiffFile, "path" | "status"> = {
466
+ additions: 0,
467
+ deletions: 0,
468
+ hunks: "",
469
+ truncated: false,
470
+ binary: true,
471
+ };
472
+
473
+ /**
474
+ * Read an untracked file's content, or null when it is unreadable or
475
+ * oversized. This runs on the repository's own host, so a plain read is enough
476
+ * — the size check is for memory, not for avoiding a download.
477
+ */
478
+ async function readUntrackedFile(
479
+ absPath: string,
480
+ ): Promise<{ content: Buffer } | null> {
481
+ const st = await fs.promises.stat(absPath);
482
+ if (!st.isFile() || st.size > MAX_UNTRACKED_BYTES) return null;
483
+ return { content: await fs.promises.readFile(absPath) };
484
+ }
485
+
486
+ async function diffForUntracked(
487
+ repoRoot: string,
488
+ relPath: string,
489
+ ): Promise<WorkspaceDiffFile> {
490
+ const full = path.join(repoRoot, relPath);
491
+ try {
492
+ const file = await readUntrackedFile(full);
493
+ if (!file) {
494
+ return { path: relPath, status: "untracked", ...UNREADABLE };
495
+ }
496
+ const buf = file.content;
497
+ if (buf.subarray(0, 8192).includes(0)) {
498
+ return { path: relPath, status: "untracked", ...UNREADABLE };
499
+ }
500
+ const lines = buf.toString("utf-8").split("\n");
501
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
502
+ const truncated = lines.length > MAX_DIFF_LINES;
503
+ const shown = truncated ? lines.slice(0, MAX_DIFF_LINES) : lines;
504
+ return {
505
+ path: relPath,
506
+ status: "untracked",
507
+ additions: lines.length,
508
+ deletions: 0,
509
+ hunks: shown.map((l) => `+${l}`).join("\n"),
510
+ truncated,
511
+ binary: false,
512
+ };
513
+ } catch {
514
+ // Race: the file vanished between `ls-files` and the read — skip it.
515
+ return { path: relPath, status: "untracked", ...UNREADABLE };
516
+ }
517
+ }
518
+
519
+ /**
520
+ * @param options.commit Restrict the file list to one commit of the session's
521
+ * own range. Ignored when that commit is not in the range any more (a rebase
522
+ * leaves the old object dangling, so existence is not the test — membership
523
+ * is); the reply's `scope` then says which range was actually used.
524
+ * @param options.uncommitted Measure against `HEAD` instead of the fork point
525
+ * (the panel's "未提交改动" range). Ignored when `commit` selects a range.
526
+ */
527
+ export async function getWorkspaceDiff(
528
+ cwd: string,
529
+ options: { commit?: string; uncommitted?: boolean } = {},
530
+ ): Promise<WorkspaceDiffResult> {
531
+ try {
532
+ await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
533
+ } catch {
534
+ return { kind: "not-a-repo" };
535
+ }
536
+
537
+ // `cwd` may be a subdirectory, and git is cwd-sensitive in two ways that both
538
+ // corrupt the result: it resolves pathspecs relative to the cwd (so a
539
+ // root-relative diff path matches nothing) and `ls-files` both scopes and
540
+ // prints its output relative to it (so untracked files outside the cwd go
541
+ // missing and the rest come back without their prefix). Resolve the toplevel
542
+ // once and run everything from there; fall back to cwd if rev-parse is
543
+ // unavailable.
544
+ const root =
545
+ (await git(cwd, ["rev-parse", "--show-toplevel"]).catch(() => "")).trim() ||
546
+ cwd;
547
+
548
+ // A half-finished merge/rebase/cherry-pick/revert means the worktree holds
549
+ // incoming changes this session did not make — no range can describe that
550
+ // honestly, so the panel goes empty and says why (spec「会话变更差异面板」
551
+ // 场景 13).
552
+ const operation = await transientOperation(root);
553
+ if (operation) return { kind: "transient", operation };
554
+
555
+ const headSha =
556
+ (
557
+ await git(root, ["rev-parse", "--verify", "HEAD"]).catch(() => "")
558
+ ).trim() || null;
559
+ const resolved = await resolveDiffBase(root, headSha);
560
+
561
+ const commits = resolved.commitsFrom
562
+ ? await listCommits(root, resolved.commitsFrom)
563
+ : [];
564
+ const picked = options.commit
565
+ ? commits.find((commit) => commit.sha === options.commit)
566
+ : undefined;
567
+ const uncommitted = !picked && options.uncommitted === true;
568
+ const scope: WorkspaceDiffScope = picked
569
+ ? { kind: "commit", ...picked }
570
+ : uncommitted
571
+ ? { kind: "uncommitted" }
572
+ : { kind: "all" };
573
+ // `<sha>^!` is the commit's own diff (and prints the whole tree for a root
574
+ // commit, which `<sha>^ <sha>` cannot).
575
+ const rev = picked
576
+ ? `${picked.sha}^!`
577
+ : uncommitted
578
+ ? headSha
579
+ ? "HEAD"
580
+ : "--cached"
581
+ : resolved.rev;
582
+ const base = uncommitted
583
+ ? headSha
584
+ ? headBase(headSha).base
585
+ : indexBase().base
586
+ : resolved.base;
587
+
588
+ const entries = parseNameStatusZ(
589
+ await git(root, diffArgs(rev, ["--name-status", "-z"])).catch(() => ""),
590
+ );
591
+ const stats = parseNumstatZ(
592
+ await git(root, diffArgs(rev, ["--numstat", "-z"])).catch(() => ""),
593
+ );
594
+ // One whole-tree patch for every file, rather than one git process per file.
595
+ const patches =
596
+ entries.length > 0
597
+ ? await wholeTreePatches(root, rev)
598
+ : new Map<string, string>();
599
+
600
+ const files: WorkspaceDiffFile[] = [];
601
+ for (const entry of entries) {
602
+ files.push(
603
+ await diffForTracked(root, rev, entry, stats.get(entry.path), patches),
604
+ );
605
+ }
606
+ // Untracked files live in the worktree, so they belong to every range that
607
+ // includes it — a single commit cannot contain them.
608
+ if (scope.kind !== "commit") {
609
+ const untracked = await git(root, [
610
+ "ls-files",
611
+ "--others",
612
+ "--exclude-standard",
613
+ "-z",
614
+ ]).catch(() => "");
615
+ for (const relPath of parseZRecords(untracked)) {
616
+ files.push(await diffForUntracked(root, relPath));
617
+ }
618
+ }
619
+ return { kind: "ok", base, scope, commits, files };
620
+ }