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.
- package/dist/bundle/wave.mjs +1275 -895
- package/package.json +4 -2
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ConfirmationDetails.tsx +17 -3
- package/src/components/InputBox.tsx +2 -0
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +359 -127
- package/src/daemon/commands.ts +13 -4
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +4 -0
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +1 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/stdio/agentBridge.ts +608 -74
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +11 -2
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- 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
|
+
}
|