@xia-sc/dsh-git 0.5.1 → 0.5.3

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/lib/index.js CHANGED
@@ -1,1272 +1,1292 @@
1
- /**
2
- * @xia-sc/dsh-git — host half.
3
- *
4
- * Mounts the `/dsh-git-rpc` Connection RPC channel on the web server. The
5
- * browser half (lib/client.js) calls the endpoints through
6
- * `connection.rpc.call("/dsh-git-rpc", endpoint, { args })` with the current
7
- * session's workspace directory as `args.cwd`:
8
- *
9
- * - `status` → repo facts: current branch (or detached HEAD), dirty file
10
- * count, ahead/behind vs upstream, uncommitted file list.
11
- * - `branches` → local and remote branch lists.
12
- * - `checkout` → switch branches (git switch --guess; DWIM, never detaches).
13
- * - `createBranch` → create a new branch from a base branch and switch to
14
- * it (IDE "new branch from…" semantics).
15
- * - `fetch` → git fetch (optionally a specific remote).
16
- * - `pull` → git pull --ff-only (fast-forward only; conflicts surface
17
- * as errors instead of surprise merges).
18
- * - `stage` → git add --all, so the next commit has something to record.
19
- * - `diff` → one changed path's unified diff, read from both sides of the
20
- * index (staged / unstaged) so the panel can show either;
21
- * untracked paths are diffed against the empty blob.
22
- * - `commit` → git commit (message on stdin via `--file=-`) with
23
- * author-config check up front.
24
- * - `push` → git push (current branch's upstream).
25
- * - `log` → recent commit summary lines.
26
- * - `generateMessage` → draft a commit message from the working tree through
27
- * the shared LLM service (see the endpoint's own docs).
28
- *
29
- * Every git run goes through execFile with a fixed argument array (no shell),
30
- * a timeout, and strict input validation.
31
- *
32
- * dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
33
- *
34
- * `connection.rpc.handle()` cannot be used by an outside plugin in this
35
- * version. `HostConnectionService.rpc` closes over `this.ctx` — the connection
36
- * plugin's OWN Context, whose inject is only `["credentials"]` — and registers
37
- * through it (`owner.effect(() => owner.webServer.register(route))`, see
38
- * dsh-client-connection lib/index.js), while that plugin reaches `webServer`
39
- * through an inner `ctx.inject(["webServer"], …)` scope. The owner fiber
40
- * therefore never resolves `webServer`, and the call throws
41
- * `cannot get property "webServer" without inject` no matter what the caller
42
- * injects. We register the channel on `webServer` ourselves and speak the same
43
- * Connection RPC wire protocol the browser's `connection.rpc.call` sends
44
- * (`{type:"client-request",rpcId,method,payload}` in,
45
- * `{type:"server-response",rpcId,result}` out).
46
- *
47
- * The channel keeps the connection service's own Host/Origin + browser-cookie
48
- * fence (`connection.requestRejection`), so it is exactly as trusted as the
49
- * `/api` transport — loopback or a configured trusted authority, same-origin.
50
- */
51
- import { execFile } from "node:child_process";
52
- import { randomUUID } from "node:crypto";
53
- import { isAbsolute } from "node:path";
54
-
55
- /** Stable Cordis plugin name. */
56
- const name = "dsh-git";
57
- /**
58
- * Services required before this plugin can mount its channel: `webServer`
59
- * owns the route, `connection` supplies the request fence, and `llm` backs the
60
- * optional commit-message generation endpoint.
61
- */
62
- const inject = ["webServer", "connection", "llm"];
63
-
64
- /** The Connection RPC channel this plugin serves. */
65
- const RPC_CHANNEL = "/dsh-git-rpc";
66
- /** One endpoint segment, mirroring the client's ENDPOINT_SEGMENT_PATTERN. */
67
- const RPC_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
68
- /** Request-body cap: git RPC payloads are small (a commit message at most). */
69
- const RPC_MAX_BODY_BYTES = 1024 * 1024;
70
-
71
- /** Per-invocation git timeout for fast local operations. */
72
- const GIT_TIMEOUT_MS = 30000;
73
- /** Longer timeout for network operations (fetch/pull/push). */
74
- const GIT_NET_TIMEOUT_MS = 120000;
75
- /** Capture bound for git output (large repos / long pushes). */
76
- const MAX_BUFFER = 32 * 1024 * 1024;
77
-
78
- /**
79
- * Ceiling for one commit-message generation. The browser's own abort still
80
- * applies; this only bounds a request whose client went away without hanging up.
81
- */
82
- const LLM_TIMEOUT_MS = 120000;
83
- /** Diff characters sent to the model (a diff has no natural size bound). */
84
- const LLM_DIFF_MAX_CHARS = 12000;
85
- /** Diff-stat characters sent to the model. */
86
- const LLM_STAT_MAX_CHARS = 2000;
87
- /** Output cap for one generated commit message. */
88
- const LLM_MAX_TOKENS = 256;
89
- /** Accepted `generateMessage` modes. */
90
- const GENERATE_MODES = ["staged", "unstaged", "all"];
91
- /** Default mode: the only one whose content is what `commit` will actually record. */
92
- const GENERATE_MODE_DEFAULT = "staged";
93
-
94
- /** A successful RPC result. */
95
- function ok(value) {
96
- return { ok: true, value };
97
- }
98
- /** A failed RPC result in the Connection transport's `RpcResult` error shape. */
99
- function fail(code, message, details = {}) {
100
- // The wire requires `error.code` to be a string and `error.details` to be a
101
- // plain object (see dsh-client-connection rpcErrorSchema / the browser's
102
- // parseConnectionResponse). `code: "internal"` keeps the envelope acceptable
103
- // to every 0.1.x host, while the plugin's own diagnostic code rides in
104
- // `details.code` and `message` stays the human-readable git text the panel
105
- // shows.
106
- return { ok: false, error: { code: "internal", message, details: Object.assign({}, details, { code }) } };
107
- }
108
-
109
- /**
110
- * The child's numeric exit status, from whichever field this Node build used.
111
- * A spawn failure (ENOENT) carries a string errno instead, so it never matches.
112
- * @param error - an execFile failure.
113
- * @returns the status, or undefined when the child never exited.
114
- */
115
- function exitStatusOf(error) {
116
- for (const candidate of [error.code, error.status, error.exitCode]) {
117
- if (typeof candidate === "number") return candidate;
118
- }
119
- return undefined;
120
- }
121
-
122
- /**
123
- * Run one git invocation without a shell.
124
- * @param cwd - working directory (the session workspace).
125
- * @param args - git arguments (never user-joined into a string; no shell).
126
- * @param signal - optional transport cancellation (kills the child on abort).
127
- * @param timeoutMs - per-call timeout.
128
- * @param stdin - optional text written to the child's stdin (`--file=-`).
129
- * @param allowExit - exit codes this call reads as success. `git diff --no-index`
130
- * reports "the two sides differ" as exit 1, which is exactly the answer a viewer
131
- * asked for, so that endpoint lists `[1]` instead of treating it as a failure.
132
- * @returns resolved stdout/stderr, or rejects with the exec error augmented
133
- * with `stdout`/`stderr` text.
134
- */
135
- function runGit(cwd, args, signal, timeoutMs = GIT_TIMEOUT_MS, stdin = undefined, allowExit = undefined) {
136
- return new Promise((resolve, reject) => {
137
- const child = execFile("git", args, {
138
- cwd,
139
- timeout: timeoutMs,
140
- maxBuffer: MAX_BUFFER,
141
- windowsHide: true,
142
- encoding: "utf8",
143
- signal
144
- }, (error, stdout, stderr) => {
145
- if (error === null) {
146
- resolve({ stdout, stderr });
147
- return;
148
- }
149
- if (Array.isArray(allowExit) && allowExit.includes(exitStatusOf(error))) {
150
- // Resolve with whatever the child did print: the caller has declared
151
- // this code to be an answer rather than a failure.
152
- resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
153
- return;
154
- }
155
- const wrapped = error instanceof Error ? error : new Error(String(error));
156
- wrapped.stdout = String(stdout ?? "");
157
- wrapped.stderr = String(stderr ?? "");
158
- reject(wrapped);
159
- });
160
- if (stdin === undefined) return;
161
- // A message body is written as UTF-8 and the stream is always closed, so
162
- // git sees a complete `--file=-` input even when the child never reads it.
163
- child.stdin.on("error", () => {});
164
- child.stdin.end(stdin, "utf8");
165
- });
166
- }
167
-
168
- /** Whether a git exec failure means the cwd is outside any work tree. */
169
- function isNotARepo(error) {
170
- return /not a git repository/i.test(error.stderr ?? "");
171
- }
172
-
173
- /** Extract a bounded, display-safe diagnostic from a git failure. */
174
- function cleanMessage(error) {
175
- const text = String(error.stderr ?? error.message ?? "").trim().replace(/\s+/g, " ");
176
- return text.length > 0 ? text.slice(0, 800) : "git failed";
177
- }
178
-
179
- /** Map an exec failure to an RPC error result. */
180
- function gitError(error) {
181
- if (error.code === "ENOENT") {
182
- return fail("git-not-found", "git executable not found on PATH", {});
183
- }
184
- if (error.killed === true) {
185
- return fail("timeout", "git did not finish within the time limit", {});
186
- }
187
- if (error.name === "AbortError" || error.signal !== undefined && error.signal !== null) {
188
- return fail("cancelled", "git operation was cancelled", {});
189
- }
190
- return fail("git-error", cleanMessage(error), {});
191
- }
192
-
193
- /** Validate a caller-supplied working directory. */
194
- function validCwd(value) {
195
- return typeof value === "string" && value.length > 0 && isAbsolute(value);
196
- }
197
-
198
- /**
199
- * Validate a caller-supplied remote name (origin, upstream, …).
200
- * Plain segment: letters/digits/._- only.
201
- */
202
- function validRemote(value) {
203
- if (value === undefined) return true;
204
- return typeof value === "string" && value.length > 0 && value.length <= 100 && !value.startsWith("-") && !value.includes("..") && /^[A-Za-z0-9._-]+$/.test(value);
205
- }
206
-
207
- /** Longest accepted repository-relative path (a git pathspec). */
208
- const PATH_MAX = 4096;
209
-
210
- /**
211
- * Normalize a caller-supplied path into a repository-relative git pathspec.
212
- *
213
- * The diff viewer names one file from the status list, so the accepted shape is
214
- * deliberately narrow: a relative path with no traversal, no option-looking
215
- * leading `-`, no absolute or drive-qualified form, and no control characters or
216
- * surrounding whitespace.
217
- * A trailing slash (how git prints an untracked directory) is kept — the pathspec
218
- * then matches the whole subtree, which is what the user clicked.
219
- * @param value - caller-supplied path.
220
- * @returns the slash-normalized path, or null when it is unusable.
221
- */
222
- function normalizedPath(value) {
223
- if (typeof value !== "string") return null;
224
- if (value.length === 0 || value.length > PATH_MAX) return null;
225
- // Control characters (NUL, CR, LF, tab, DEL) and surrounding whitespace are
226
- // never part of a name this endpoint is handed: git quotes such a path in
227
- // the status output, so it could not have arrived intact anyway.
228
- if (/[\u0000-\u001f\u007f]/.test(value)) return null;
229
- if (value !== value.trim()) return null;
230
- const file = value.replace(/\\/g, "/");
231
- if (file.startsWith("-") || file.startsWith("/")) return null;
232
- if (/^[A-Za-z]:/.test(file)) return null;
233
- if (file.split("/").some((segment) => segment === "." || segment === "..")) return null;
234
- return file;
235
- }
236
-
237
- /** Longest accepted commit message (characters). */
238
- const MESSAGE_MAX = 10000;
239
- /**
240
- * Characters a commit message may never contain: NUL, every other C0 control
241
- * except the line feed and the tab, and DEL. A line feed is the message's own
242
- * line separator and a tab is ordinary indentation — both are legal in a real
243
- * commit message, and rejecting them here silently broke every drafted (or
244
- * pasted) message that carried a subject plus a body.
245
- */
246
- const MESSAGE_FORBIDDEN = /[\u0000-\u0008\u000b-\u001f\u007f]/;
247
-
248
- /**
249
- * Normalize a caller-supplied commit message into the exact text git records.
250
- * Accepts a real commit message shape one subject line plus an optional
251
- * multi-line body so newline separated drafts work, while still refusing a
252
- * message git must not be handed:
253
- *
254
- * - CRLF and lone CR collapse to LF, so a Windows draft survives a clipboard
255
- * round trip instead of turning into one unreadable line;
256
- * - trailing whitespace is stripped per line, leading and trailing blank
257
- * lines are dropped, and runs of blank lines collapse to one this is
258
- * `git commit --cleanup=whitespace` applied here too, and it deliberately
259
- * *keeps* the single blank line that separates subject from body;
260
- * - empty, whitespace-only, and over-long messages are rejected;
261
- * - NUL, other non-whitespace control characters, and DEL are rejected.
262
- *
263
- * @param value - caller-supplied message.
264
- * @returns the normalized message, or null when the value is unusable.
265
- */
266
- function normalizeCommitMessage(value) {
267
- if (typeof value !== "string") return null;
268
- const trimmed = value.trim();
269
- if (trimmed.length === 0 || trimmed.length > MESSAGE_MAX) return null;
270
- const lines = trimmed.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[ \t]+$/, ""));
271
- const collapsed = [];
272
- for (const line of lines) {
273
- if (line === "" && (collapsed.length === 0 || collapsed[collapsed.length - 1] === "")) continue;
274
- collapsed.push(line);
275
- }
276
- const normalized = collapsed.join("\n");
277
- if (normalized.length === 0 || MESSAGE_FORBIDDEN.test(normalized)) return null;
278
- return normalized;
279
- }
280
-
281
- /** Normalize a caller-supplied branch name to a safe git argument. */
282
- function normalizeBranch(raw) {
283
- if (typeof raw !== "string") return null;
284
- let value = raw.trim();
285
- if (value.startsWith("remotes/")) value = value.slice("remotes/".length);
286
- if (value.length === 0 || value.length > 255) return null;
287
- if (value.startsWith("-")) return null;
288
- if (/[\\\s"'`\u0000-\u001f]/.test(value)) return null;
289
- if (value.includes("..") || value.includes("@{") || value.includes(":") || value.includes("~") || value.includes("^")) return null;
290
- if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value)) return null;
291
- return value;
292
- }
293
-
294
- /** Human label for one porcelain-v2 XY status pair (first char wins for display). */
295
- function labelOf(xy) {
296
- if (xy === undefined || xy === null) return "?";
297
- const code = String(xy);
298
- const first = code[0];
299
- const second = code[1];
300
- if (first === "?" || second === "?") return "untracked";
301
- if (first === "!" || second === "!") return "ignored";
302
- if (first === "u" || second === "u" || code === "UU") return "conflict";
303
- switch (first) {
304
- case "M": return "modified";
305
- case "A": return "added";
306
- case "D": return "deleted";
307
- case "R": return "renamed";
308
- case "C": return "copied";
309
- case "T": return "type-changed";
310
- default: return "changed";
311
- }
312
- }
313
-
314
- /**
315
- * One entry of the status list.
316
- *
317
- * `path` is what the panel prints (a rename reads `old → new`), while `file` /
318
- * `origFile` are the pathspec the diff endpoint has to be given. `index` and
319
- * `worktree` are the two porcelain-v2 letters, so the viewer knows which side of
320
- * the index an entry actually lives on instead of guessing from the label.
321
- * @param xy - the two-letter XY status.
322
- * @param path - the path git reported (already display-shaped for a rename).
323
- * @param options - `file` (pathspec path), `origFile` (pre-rename path), and
324
- * `status` when the record's own label would mislead (an unmerged pair is a
325
- * conflict whatever its two letters say).
326
- */
327
- function changeEntry(xy, path, options = {}) {
328
- const code = typeof xy === "string" ? xy : "";
329
- return {
330
- status: options.status !== undefined ? options.status : labelOf(code),
331
- path,
332
- index: code[0] !== undefined ? code[0] : " ",
333
- worktree: code[1] !== undefined ? code[1] : " ",
334
- file: options.file !== undefined ? options.file : path,
335
- origFile: options.origFile !== undefined ? options.origFile : null
336
- };
337
- }
338
-
339
- /**
340
- * `status` endpoint: repo facts + uncommitted file list for one directory.
341
- * @param rawCwd - session workspace directory.
342
- * @param signal - transport cancellation.
343
- */
344
- async function gitStatus(rawCwd, signal) {
345
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
346
- let out;
347
- try {
348
- out = await runGit(rawCwd, ["status", "--porcelain=v2", "--branch"], signal);
349
- } catch (error) {
350
- if (isNotARepo(error)) return ok({ repo: false });
351
- return gitError(error);
352
- }
353
- let branch = null;
354
- let detached = false;
355
- let oid = null;
356
- let upstream = null;
357
- let ahead = 0;
358
- let behind = 0;
359
- let dirty = 0;
360
- const changes = [];
361
- for (const line of out.stdout.split(/\r?\n/)) {
362
- if (line.startsWith("# branch.head ")) {
363
- const head = line.slice("# branch.head ".length).trim();
364
- detached = head === "(detached)";
365
- if (!detached) branch = head;
366
- } else if (line.startsWith("# branch.oid ")) {
367
- oid = line.slice("# branch.oid ".length).trim();
368
- } else if (line.startsWith("# branch.upstream ")) {
369
- upstream = line.slice("# branch.upstream ".length).trim();
370
- } else if (line.startsWith("# branch.ab ")) {
371
- const match = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
372
- if (match !== null) {
373
- ahead = Number(match[1]);
374
- behind = Number(match[2]);
375
- }
376
- } else if (line.length > 0) {
377
- dirty += 1;
378
- const parts = line.split(" ");
379
- if (parts.length >= 2 && parts[0] !== "!") {
380
- if (parts[0] === "?") {
381
- // `? <path>` — untracked; git collapses an untracked directory
382
- // into its `dir/` entry, which the diff endpoint expands.
383
- changes.push(changeEntry("??", parts.slice(1).join(" ")));
384
- } else if (parts[0] === "1") {
385
- // `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>`
386
- changes.push(changeEntry(parts[1], parts.slice(8).join(" ")));
387
- } else if (parts[0] === "2") {
388
- // `2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\t<origPath>`
389
- // The last two fields are ONE space-separated token (a tab
390
- // separates them), which is why the score column at index 8 is
391
- // not the path.
392
- const pair = parts.slice(9).join(" ").split("\t");
393
- const path = pair[0] !== undefined ? pair[0] : "";
394
- const orig = pair[1] !== undefined && pair[1] !== "" ? pair[1] : null;
395
- changes.push(changeEntry(parts[1], orig !== null ? `${orig} ${path}` : path, { file: path, origFile: orig }));
396
- } else if (parts[0] === "u") {
397
- // `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>`
398
- const path = parts.slice(10).join(" ");
399
- changes.push(changeEntry(parts[1] !== undefined ? parts[1] : "uu", path, { file: path, status: "conflict" }));
400
- }
401
- }
402
- }
403
- }
404
- return ok({ repo: true, branch, detached, oid, upstream, ahead, behind, dirty, changes });
405
- }
406
-
407
- /**
408
- * `branches` endpoint: local and remote branch lists for one directory.
409
- * @param rawCwd - session workspace directory.
410
- * @param signal - transport cancellation.
411
- */
412
- async function gitBranches(rawCwd, signal) {
413
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
414
- let heads;
415
- let remotes;
416
- try {
417
- [heads, remotes] = await Promise.all([
418
- runGit(rawCwd, [
419
- "for-each-ref",
420
- "--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(objectname:short)",
421
- "refs/heads"
422
- ], signal),
423
- runGit(rawCwd, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"], signal)
424
- ]);
425
- } catch (error) {
426
- if (isNotARepo(error)) return ok({ repo: false, current: null, local: [], remote: [] });
427
- return gitError(error);
428
- }
429
- const local = heads.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
430
- const parts = line.split("\0");
431
- return {
432
- name: parts[0],
433
- current: parts[1] === "*",
434
- upstream: parts[2] !== undefined && parts[2] !== "" ? parts[2] : null,
435
- sha: parts[3] !== undefined && parts[3] !== "" ? parts[3] : null
436
- };
437
- });
438
- const remote = remotes.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
439
- // refname:short for a remote-tracking ref is "<remote>/<branch...>".
440
- // The DWIM-able branch name is the path after the remote segment.
441
- const stripped = line.replace(/^remotes\//, "");
442
- const slash = stripped.indexOf("/");
443
- const short = slash === -1 ? stripped : stripped.slice(slash + 1);
444
- return { name: line, short };
445
- });
446
- const current = local.find((entry) => entry.current)?.name ?? null;
447
- return ok({ repo: true, current, local, remote });
448
- }
449
-
450
- /**
451
- * `checkout` endpoint: switch the repo at `cwd` to `branch`.
452
- * @param rawCwd - session workspace directory.
453
- * @param rawBranch - branch name to switch to.
454
- * @param signal - transport cancellation.
455
- */
456
- async function gitCheckout(rawCwd, rawBranch, signal) {
457
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
458
- const branch = normalizeBranch(rawBranch);
459
- if (branch === null) return fail("invalid-branch", "invalid branch name");
460
- let out;
461
- try {
462
- out = await runGit(rawCwd, ["switch", "--guess", branch], signal);
463
- } catch (error) {
464
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
465
- const detail = cleanMessage(error);
466
- // The common refusal: local changes would be overwritten by the switch.
467
- // Keep git's own words but add context so the panel message is readable.
468
- if (/local changes to the following files would be overwritten|your local changes would be overwritten/i.test(detail)) {
469
- return fail("checkout-failed", "本地有未提交的修改会被切换覆盖,git 已拒绝:" + detail, {});
470
- }
471
- return fail("checkout-failed", detail, {});
472
- }
473
- const status = await gitStatus(rawCwd, signal);
474
- if (status.ok === true && status.value.repo === true) {
475
- return ok({
476
- branch: status.value.branch,
477
- detached: status.value.detached,
478
- oid: status.value.oid,
479
- message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null
480
- });
481
- }
482
- return ok({ branch, detached: false, oid: null, message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null });
483
- }
484
-
485
- /**
486
- * `createBranch` endpoint: create a new branch from a base branch and switch
487
- * to it (IDE "new branch from…" semantics). The base may be a local branch
488
- * name or a remote-tracking ref (e.g. `origin/feature/x`); an omitted base
489
- * means HEAD (the current state).
490
- * @param rawCwd - session workspace directory.
491
- * @param rawBranch - new branch name (validated; `HEAD` is rejected).
492
- * @param rawBase - base branch/ref, or undefined/null for HEAD.
493
- * @param signal - transport cancellation.
494
- */
495
- async function gitCreateBranch(rawCwd, rawBranch, rawBase, signal) {
496
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
497
- const branch = normalizeBranch(rawBranch);
498
- if (branch === null || branch.toLowerCase() === "head") return fail("invalid-branch", "invalid branch name");
499
- const base = rawBase === undefined || rawBase === null ? "HEAD" : normalizeBranch(rawBase);
500
- if (base === null) return fail("invalid-branch", "invalid base branch");
501
- let out;
502
- try {
503
- out = await runGit(rawCwd, ["switch", "--create", branch, base], signal);
504
- } catch (error) {
505
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
506
- return fail("create-branch-failed", cleanMessage(error), {});
507
- }
508
- const status = await gitStatus(rawCwd, signal);
509
- if (status.ok === true && status.value.repo === true) {
510
- return ok({
511
- branch: status.value.branch,
512
- detached: status.value.detached,
513
- oid: status.value.oid,
514
- message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null
515
- });
516
- }
517
- return ok({ branch, detached: false, oid: null, message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null });
518
- }
519
-
520
- /**
521
- * `fetch` endpoint: download remote refs (optionally one remote).
522
- * @param rawCwd - session workspace directory.
523
- * @param rawRemote - optional remote name (validated).
524
- * @param signal - transport cancellation.
525
- */
526
- async function gitFetch(rawCwd, rawRemote, signal) {
527
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
528
- if (!validRemote(rawRemote)) return fail("invalid-remote", "invalid remote name");
529
- const args = rawRemote === undefined ? ["fetch"] : ["fetch", rawRemote];
530
- let out;
531
- try {
532
- out = await runGit(rawCwd, args, signal, GIT_NET_TIMEOUT_MS);
533
- } catch (error) {
534
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
535
- return fail("fetch-failed", cleanMessage(error), {});
536
- }
537
- const detail = [out.stdout, out.stderr].join("\n").trim();
538
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "fetch complete" });
539
- }
540
-
541
- /**
542
- * `pull` endpoint: fast-forward-only pull of the current branch's upstream.
543
- * Never merges implicitly; a non-fast-forward or dirty-tree case surfaces as
544
- * an error the user resolves in their own tooling.
545
- * @param rawCwd - session workspace directory.
546
- * @param signal - transport cancellation.
547
- */
548
- async function gitPull(rawCwd, signal) {
549
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
550
- let out;
551
- try {
552
- out = await runGit(rawCwd, ["pull", "--ff-only"], signal, GIT_NET_TIMEOUT_MS);
553
- } catch (error) {
554
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
555
- return fail("pull-failed", cleanMessage(error), {});
556
- }
557
- const detail = [out.stdout, out.stderr].join("\n").trim();
558
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "pull complete" });
559
- }
560
-
561
- /** Read the repo's configured author (user.name / user.email) or null. */
562
- async function readAuthor(cwd) {
563
- const read = async (key) => {
564
- try {
565
- const out = await runGit(cwd, ["config", key]);
566
- return out.stdout.trim();
567
- } catch {
568
- return "";
569
- }
570
- };
571
- const [name, email] = await Promise.all([read("user.name"), read("user.email")]);
572
- return name !== "" || email !== "" ? { name, email } : null;
573
- }
574
-
575
- /**
576
- * `commit` endpoint: commit staged + message (no implicit add — the user
577
- * stages explicitly with `git add` in their own tooling; we commit what is
578
- * staged). Checks author config up front with a clear error.
579
- *
580
- * The message reaches git on **stdin** through `--file=-`, never as an
581
- * `--message=<msg>` argument: a subject-plus-body message contains spaces and
582
- * line feeds, and Windows argument quoting is exactly where such a message
583
- * would be mangled or truncated. `--cleanup=whitespace` is pinned explicitly so
584
- * a user's `commit.cleanup=verbatim` (or `scissors`) configuration cannot
585
- * truncate or reshape the message behind the panel's back.
586
- *
587
- * @param rawCwd - session workspace directory.
588
- * @param rawMessage - commit message (normalized and validated).
589
- * @param signal - transport cancellation.
590
- */
591
- async function gitCommit(rawCwd, rawMessage, signal) {
592
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
593
- const message = normalizeCommitMessage(rawMessage);
594
- if (message === null) {
595
- return fail("invalid-message", "a commit message is required (non-empty, at most " + MESSAGE_MAX + " characters, and free of control characters)");
596
- }
597
- const author = await readAuthor(rawCwd);
598
- if (author === null) {
599
- return fail("missing-author", "git user.name / user.email are not configured; set them first (e.g. `git config --global user.name \"you\"` and `git config --global user.email you@example.com`)", {});
600
- }
601
- let out;
602
- try {
603
- out = await runGit(rawCwd, ["commit", "--cleanup=whitespace", "--file=-"], signal, GIT_TIMEOUT_MS, `${message}\n`);
604
- } catch (error) {
605
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
606
- return fail("commit-failed", cleanMessage(error), {});
607
- }
608
- const summary = out.stdout.trim() !== "" ? out.stdout.trim().slice(0, 500) : out.stderr.trim().slice(0, 500);
609
- return ok({ message: summary });
610
- }
611
-
612
- /**
613
- * `push` endpoint: push the current branch to its upstream.
614
- * @param rawCwd - session workspace directory.
615
- * @param signal - transport cancellation.
616
- */
617
- async function gitPush(rawCwd, signal) {
618
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
619
- let out;
620
- try {
621
- out = await runGit(rawCwd, ["push"], signal, GIT_NET_TIMEOUT_MS);
622
- } catch (error) {
623
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
624
- return fail("push-failed", cleanMessage(error), {});
625
- }
626
- const detail = [out.stdout, out.stderr].join("\n").trim();
627
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "push complete" });
628
- }
629
-
630
- /**
631
- * `log` endpoint: recent commit summary lines (default 10).
632
- * @param rawCwd - session workspace directory.
633
- * @param rawCount - number of commits to list (clamped 1..50).
634
- * @param signal - transport cancellation.
635
- */
636
- async function gitLog(rawCwd, rawCount, signal) {
637
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
638
- const count = Number.isInteger(rawCount) ? Math.min(50, Math.max(1, rawCount)) : 10;
639
- let out;
640
- try {
641
- out = await runGit(rawCwd, ["log", `--max-count=${count}`, "--format=%h%x00%an%x00%s%x00%D"], signal);
642
- } catch (error) {
643
- if (isNotARepo(error)) return ok({ repo: false, commits: [] });
644
- return gitError(error);
645
- }
646
- const commits = out.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
647
- const [shortSha, author, subject, refs] = line.split("\0");
648
- return {
649
- sha: shortSha ?? "",
650
- author: author ?? "",
651
- subject: subject ?? "",
652
- refs: refs !== undefined && refs !== "" ? refs : null
653
- };
654
- });
655
- return ok({ repo: true, commits });
656
- }
657
-
658
- /**
659
- * Extract the endpoint from a request pathname (`/dsh-git-rpc/<endpoint>`).
660
- * Returns undefined when the path is outside the channel or a segment is not a
661
- * plain single segment (empty, `.`, `..`, or outside the client's own
662
- * segment pattern).
663
- * @param pathname - URL pathname of the request.
664
- */
665
- function endpointFromPath(pathname) {
666
- if (typeof pathname !== "string" || !pathname.startsWith(`${RPC_CHANNEL}/`)) return undefined;
667
- const query = pathname.indexOf("?");
668
- const endpoint = pathname.slice(RPC_CHANNEL.length + 1, query === -1 ? undefined : query);
669
- if (endpoint === "") return undefined;
670
- for (const segment of endpoint.split("/")) {
671
- if (segment === "" || segment === "." || segment === ".." || !RPC_SEGMENT.test(segment)) return undefined;
672
- }
673
- return endpoint;
674
- }
675
-
676
- /**
677
- * Read a request body with a hard byte cap.
678
- * Uses the classic data/end/error events rather than async iteration: the
679
- * IncomingMessage async iterator is not a stable path in this runtime.
680
- * @param req - node:http request.
681
- * @param maxBytes - cap; exceeding it resolves to undefined.
682
- * @returns the utf8 body, or undefined on overrun/error/abort.
683
- */
684
- function readBoundedBody(req, maxBytes) {
685
- return new Promise((resolve) => {
686
- const chunks = [];
687
- let size = 0;
688
- let settled = false;
689
- const finish = (value) => {
690
- if (settled) return;
691
- settled = true;
692
- cleanup();
693
- resolve(value);
694
- };
695
- function onData(chunk) {
696
- size += chunk.length;
697
- if (size > maxBytes) {
698
- try { req.resume(); } catch { /* the socket is already gone */ }
699
- finish(undefined);
700
- return;
701
- }
702
- chunks.push(chunk);
703
- }
704
- function onEnd() { finish(Buffer.concat(chunks, size).toString("utf8")); }
705
- function onError() { finish(undefined); }
706
- function onAborted() { finish(undefined); }
707
- function cleanup() {
708
- try {
709
- req.removeListener("data", onData);
710
- req.removeListener("end", onEnd);
711
- req.removeListener("error", onError);
712
- req.removeListener("aborted", onAborted);
713
- } catch { /* listeners already removed */ }
714
- }
715
- req.on("data", onData);
716
- req.on("end", onEnd);
717
- req.on("error", onError);
718
- req.on("aborted", onAborted);
719
- });
720
- }
721
-
722
- /** Write one JSON envelope (never throws into the request handler). */
723
- function sendEnvelope(res, status, payload) {
724
- try {
725
- if (res.writableEnded === true) return;
726
- res.statusCode = status;
727
- res.setHeader("content-type", "application/json; charset=utf-8");
728
- res.setHeader("cache-control", "no-store");
729
- res.end(JSON.stringify(payload));
730
- } catch {
731
- try { res.end(); } catch { /* response already finished */ }
732
- }
733
- }
734
-
735
- /** A server-response envelope with a result (the browser's `connection.rpc.call` reply). */
736
- function rpcFull(rpcId, result) {
737
- return { type: "server-response", rpcId, result };
738
- }
739
-
740
- /** A server-response envelope carrying a failure result. */
741
- function rpcError(rpcId, code, message, details) {
742
- return rpcFull(rpcId, { ok: false, error: { code, message, details } });
743
- }
744
-
745
- /**
746
- * `stage` endpoint: stage every working-tree change (`git add --all`), including
747
- * deletions and untracked files, so the following `commit` has something to
748
- * record. The commit path itself still never stages implicitly.
749
- * @param rawCwd - session workspace directory.
750
- * @param signal - transport cancellation.
751
- */
752
- async function gitStage(rawCwd, signal) {
753
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
754
- let out;
755
- try {
756
- out = await runGit(rawCwd, ["add", "--all"], signal);
757
- } catch (error) {
758
- if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
759
- return fail("stage-failed", cleanMessage(error), {});
760
- }
761
- const detail = [out.stdout, out.stderr].join("\n").trim();
762
- return ok({ message: detail !== "" ? detail.slice(0, 800) : "all changes staged" });
763
- }
764
-
765
- /**
766
- * Read one side of the working tree as `{ stat, diff }`.
767
- * `--no-ext-diff` keeps a configured external diff program out of the path:
768
- * this is a background read, and it must never open a GUI or block.
769
- * @param rawCwd - session workspace directory.
770
- * @param cached - true reads the index (staged), false the working tree (unstaged).
771
- * @param signal - transport cancellation.
772
- * @returns `{ repo, stat, diff }`; `repo: false` outside a work tree.
773
- */
774
- async function readDiff(rawCwd, cached, signal) {
775
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
776
- const scope = cached === true ? ["--cached"] : [];
777
- try {
778
- const [statOut, diffOut] = await Promise.all([
779
- runGit(rawCwd, ["diff", ...scope, "--stat", "--no-ext-diff"], signal),
780
- runGit(rawCwd, ["diff", ...scope, "--no-ext-diff"], signal)
781
- ]);
782
- return ok({ repo: true, stat: statOut.stdout.trim(), diff: diffOut.stdout.trim() });
783
- } catch (error) {
784
- if (isNotARepo(error)) return ok({ repo: false, stat: "", diff: "" });
785
- return gitError(error);
786
- }
787
- }
788
-
789
- /** Per-side cap for one diff sent to the viewer (the panel renders a slice of it). */
790
- const DIFF_MAX_CHARS = 400000;
791
- /** Untracked files expanded for one directory selection; the rest are counted. */
792
- const DIFF_MAX_UNTRACKED_FILES = 50;
793
-
794
- /**
795
- * Bound one side of a diff at a line boundary, reporting the truncation rather
796
- * than silently dropping the tail. CRLF is normalized here because the browser
797
- * renders the text as lines.
798
- * @param text - raw git output.
799
- * @returns `{ diff, truncated }`.
800
- */
801
- function clampDiffText(text) {
802
- const cleaned = String(text ?? "").replace(/\r\n/g, "\n");
803
- if (cleaned.length <= DIFF_MAX_CHARS) return { diff: cleaned, truncated: false };
804
- const cut = cleaned.lastIndexOf("\n", DIFF_MAX_CHARS);
805
- return { diff: cleaned.slice(0, cut === -1 ? DIFF_MAX_CHARS : cut), truncated: true };
806
- }
807
-
808
- /** Whether one side's text is git's placeholder for a binary difference. */
809
- function isBinaryDiff(text) {
810
- return /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text);
811
- }
812
-
813
- /**
814
- * Read one side of the index for one pathspec, without a `--stat` pass: the
815
- * viewer computes its own `+N −M` counts from the hunks it renders, so a second
816
- * git invocation per side would only add latency.
817
- * @param rawCwd - session workspace directory.
818
- * @param paths - pathspec entries (a rename's old and new path both, so git can pair them).
819
- * @param cached - true reads the index (staged), false the working tree (unstaged).
820
- * @param signal - transport cancellation.
821
- * @returns git's stdout.
822
- */
823
- async function readPathDiff(rawCwd, paths, cached, signal) {
824
- const scope = cached === true ? ["--cached"] : [];
825
- const out = await runGit(rawCwd, ["diff", ...scope, "--no-ext-diff", "--no-color", "--", ...paths], signal);
826
- return out.stdout;
827
- }
828
-
829
- /**
830
- * List the untracked files one pathspec names (an untracked directory is
831
- * reported by git as a single `dir/` entry, and this is how its files are found).
832
- * @param rawCwd - session workspace directory.
833
- * @param file - repository-relative pathspec.
834
- * @param signal - transport cancellation.
835
- * @returns the paths git reports, NUL-separated output split apart.
836
- */
837
- async function untrackedFiles(rawCwd, file, signal) {
838
- const out = await runGit(rawCwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", file], signal);
839
- return out.stdout.split("\u0000").filter((entry) => entry !== "");
840
- }
841
-
842
- /**
843
- * Diff one untracked file against the empty blob.
844
- *
845
- * `git diff --no-index` has no index to compare with, so it is handed
846
- * `/dev/null` as the missing side (git itself resolves that path on Windows);
847
- * it reports "the sides differ" as exit 1, which `runGit` accepts for this call.
848
- * A run that failed for a real reason produced no diff header, so the fallback
849
- * keeps the error rather than reporting an empty file as unchanged.
850
- * @param rawCwd - session workspace directory.
851
- * @param file - repository-relative path of an untracked file.
852
- * @param signal - transport cancellation.
853
- * @returns the diff text.
854
- */
855
- async function readUntrackedDiff(rawCwd, file, signal) {
856
- try {
857
- const out = await runGit(rawCwd, ["diff", "--no-index", "--no-ext-diff", "--no-color", "--", "/dev/null", file], signal, GIT_TIMEOUT_MS, undefined, [1]);
858
- return out.stdout;
859
- } catch (error) {
860
- const text = String(error.stdout ?? "");
861
- if (text.includes("diff --git")) return text;
862
- throw error;
863
- }
864
- }
865
-
866
- /**
867
- * `diff` endpoint: one changed path's unified diff, split by which side of the
868
- * index it lives on.
869
- *
870
- * Both sides are read in one round trip because the viewer shows them as two
871
- * tabs of the same page (the panel answers "what will the commit record?" from
872
- * the index side and "what is not staged yet?" from the worktree side). A path
873
- * that is untracked on both sides falls back to `--no-index` against the empty
874
- * blob; an untracked *directory* is expanded file by file, capped, with the
875
- * remainder counted so the viewer can say so instead of showing a partial tree
876
- * silently.
877
- *
878
- * Every command here is read-only: this endpoint never writes the index, the
879
- * working tree, or any git configuration.
880
- * @param rawCwd - session workspace directory.
881
- * @param rawPath - repository-relative path (from the status list).
882
- * @param rawOrigPath - the pre-rename path when the status record carries one.
883
- * @param signal - transport cancellation.
884
- */
885
- async function gitFileDiff(rawCwd, rawPath, rawOrigPath, signal) {
886
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
887
- const file = normalizedPath(rawPath);
888
- if (file === null) return fail("invalid-path", "a repository-relative path is required");
889
- let orig = null;
890
- if (rawOrigPath !== undefined && rawOrigPath !== null) {
891
- orig = normalizedPath(rawOrigPath);
892
- if (orig === null) return fail("invalid-path", "a repository-relative path is required");
893
- }
894
- // A rename is only pairable when both of its names are in the pathspec:
895
- // naming the new path alone makes git report the whole file as an addition.
896
- const paths = orig !== null && orig !== file ? [orig, file] : [file];
897
- try {
898
- let worktreeText = await readPathDiff(rawCwd, paths, false, signal);
899
- let indexText = await readPathDiff(rawCwd, paths, true, signal);
900
- let untracked = false;
901
- let skipped = 0;
902
- if (orig === null && worktreeText.trim() === "" && indexText.trim() === "") {
903
- // Nothing on either side of the index. Either the path is untracked
904
- // git has no base to diff against or it is genuinely unchanged; the
905
- // question is settled by asking git which untracked files it knows.
906
- const files = await untrackedFiles(rawCwd, file, signal);
907
- if (files.length > 0) {
908
- untracked = true;
909
- const expanded = files.slice(0, DIFF_MAX_UNTRACKED_FILES);
910
- skipped = files.length - expanded.length;
911
- const parts = [];
912
- for (const one of expanded) parts.push(await readUntrackedDiff(rawCwd, one, signal));
913
- worktreeText = parts.filter((text) => text !== "").join("\n");
914
- }
915
- }
916
- const worktree = clampDiffText(worktreeText);
917
- const index = clampDiffText(indexText);
918
- return ok({
919
- repo: true,
920
- path: file,
921
- origPath: orig,
922
- untracked,
923
- skipped,
924
- worktree: { diff: worktree.diff, binary: isBinaryDiff(worktree.diff), truncated: worktree.truncated },
925
- index: { diff: index.diff, binary: isBinaryDiff(index.diff), truncated: index.truncated }
926
- });
927
- } catch (error) {
928
- if (isNotARepo(error)) return ok({ repo: false });
929
- return gitError(error);
930
- }
931
- }
932
-
933
-
934
- /** Bound one model-facing text block, marking the truncation. */
935
- function clampForModel(text, max) {
936
- if (text.length <= max) return text;
937
- return `${text.slice(0, max)}\n… (truncated at ${max} characters)`;
938
- }
939
-
940
- /**
941
- * Read the change set one generation mode describes.
942
- * @param rawCwd - session workspace directory.
943
- * @param mode - `staged`, `unstaged`, or `all`.
944
- * @param signal - transport cancellation.
945
- * @returns `{ stat, diff }` with `repo` false outside a work tree, or an RPC failure.
946
- */
947
- async function readChangesForMode(rawCwd, mode, signal) {
948
- if (mode === "all") {
949
- // Combining the two sides is exact and works in a repository with no
950
- // commits yet, where `git diff HEAD` has no HEAD to name.
951
- const [staged, unstaged] = await Promise.all([
952
- readDiff(rawCwd, true, signal),
953
- readDiff(rawCwd, false, signal)
954
- ]);
955
- if (staged.ok !== true) return staged;
956
- if (unstaged.ok !== true) return unstaged;
957
- return ok({
958
- repo: staged.value.repo === true || unstaged.value.repo === true,
959
- stat: [staged.value.stat, unstaged.value.stat].filter(Boolean).join("\n"),
960
- diff: [staged.value.diff, unstaged.value.diff].filter(Boolean).join("\n\n")
961
- });
962
- }
963
- return readDiff(rawCwd, mode === "staged", signal);
964
- }
965
-
966
- /**
967
- * Frame one commit-message request. Kept language-neutral: the model is told to
968
- * match the codebase rather than to prefer either of this panel's locales.
969
- * @param stat - diffstat text (may be empty).
970
- * @param diff - unified diff text.
971
- */
972
- function generationPrompt(stat, diff) {
973
- const system = [
974
- "You write one git commit message for the change set below.",
975
- "Rules:",
976
- "- Reply with the commit message only: no preamble, no quotes, no Markdown fences.",
977
- "- One imperative subject line under 72 characters; add a short body only when the change needs it.",
978
- "- Describe what changed and why, not how.",
979
- "- Write in the language already used by the codebase's comments and existing commit subjects."
980
- ].join("\n");
981
- const body = [
982
- "## Diffstat",
983
- stat !== "" ? stat : "(unavailable)",
984
- "",
985
- "## Diff",
986
- diff !== "" ? diff : "(empty)"
987
- ].join("\n");
988
- return { system, body };
989
- }
990
-
991
- /**
992
- * Resolve the provider/model route for one generation call.
993
- * The caller names its session's route; anything unconfirmed is checked against
994
- * the adapters' advertised catalog before use.
995
- * @param ctx - context exposing the `llm` service.
996
- * @param rawProvider - caller-supplied provider route (optional).
997
- * @param rawModel - caller-supplied model id (optional).
998
- * @returns `{ provider, model }`.
999
- * @throws when no usable route exists.
1000
- */
1001
- async function resolveLlmRoute(ctx, rawProvider, rawModel) {
1002
- const providers = ctx.llm.listProviders();
1003
- if (!Array.isArray(providers) || providers.length === 0) {
1004
- throw Object.assign(new Error("no LLM provider is configured; add one in Settings > Models"), { pluginCode: "no-provider" });
1005
- }
1006
- const named = typeof rawProvider === "string" && rawProvider !== "" ? providers.find((entry) => entry.id === rawProvider) : undefined;
1007
- const provider = (named ?? providers[0]).id;
1008
- let models = [];
1009
- try {
1010
- models = await ctx.llm.listModels(provider);
1011
- } catch {
1012
- // An adapter that cannot enumerate may still serve an explicitly named model.
1013
- models = [];
1014
- }
1015
- const advertised = Array.isArray(models) ? models : [];
1016
- const wantsNamed = typeof rawModel === "string" && rawModel !== "" && provider === rawProvider;
1017
- let model;
1018
- if (wantsNamed && (advertised.length === 0 || advertised.some((entry) => entry.id === rawModel))) model = rawModel;
1019
- else if (advertised.length > 0) model = advertised[0].id;
1020
- else if (typeof rawModel === "string" && rawModel !== "") model = rawModel;
1021
- if (model === undefined) {
1022
- throw Object.assign(new Error(`provider ${JSON.stringify(provider)} advertises no model to use`), { pluginCode: "no-model" });
1023
- }
1024
- return { provider, model };
1025
- }
1026
-
1027
- /**
1028
- * Build the one-shot user message for a generation call.
1029
- * Hand-built rather than imported from `@deepseek-ai/dsh-llm`: this package
1030
- * deliberately declares no `@deepseek-ai/*` runtime imports, because a plugin
1031
- * installed with pnpm `link:` resolves them from its own real source path,
1032
- * where no host tree exists. The shape must stay exactly this: a fresh id, the
1033
- * `user` role, one text block, and a plugin source.
1034
- * @param text - model-facing prompt body.
1035
- */
1036
- function generationMessage(text) {
1037
- return Object.freeze({
1038
- id: randomUUID(),
1039
- role: "user",
1040
- content: Object.freeze([Object.freeze({ type: "text", text })]),
1041
- source: Object.freeze({ kind: "plugin", plugin: name })
1042
- });
1043
- }
1044
-
1045
- /**
1046
- * Generate a commit message for a change set through the shared LLM service.
1047
- * @param ctx - context exposing the `llm` service.
1048
- * @param prompt - `{ system, body }` from {@link generationPrompt}.
1049
- * @param route - `{ provider, model }` from {@link resolveLlmRoute}.
1050
- * @param signal - cancellation (browser abort plus the server ceiling).
1051
- * @returns the trimmed message.
1052
- * @throws on a terminal stream failure or empty output.
1053
- */
1054
- async function requestCommitMessage(ctx, prompt, route, signal) {
1055
- const options = {
1056
- provider: route.provider,
1057
- model: route.model,
1058
- messages: [generationMessage(prompt.body)],
1059
- system: prompt.system,
1060
- maxTokens: LLM_MAX_TOKENS,
1061
- signal
1062
- };
1063
- // Final text comes from the assembled blocks; the delta map is a fallback for
1064
- // an adapter that emits text without a closing `block-end`.
1065
- const blocks = new Map();
1066
- const deltas = new Map();
1067
- let finish;
1068
- for await (const chunk of ctx.llm.stream(options)) {
1069
- if (chunk.type === "text-delta") deltas.set(chunk.index, `${deltas.get(chunk.index) ?? ""}${chunk.text}`);
1070
- else if (chunk.type === "block-end" && chunk.block.type === "text") blocks.set(chunk.index, chunk.block.text);
1071
- else if (chunk.type === "finish") finish = chunk.reason;
1072
- }
1073
- if (finish !== undefined && (finish.kind === "error" || finish.kind === "aborted")) {
1074
- throw Object.assign(new Error(finish.failure.message), { pluginCode: finish.kind === "aborted" ? "cancelled" : "llm-failed" });
1075
- }
1076
- const ordered = (map) => [...map.entries()].sort((left, right) => left[0] - right[0]).map((entry) => entry[1]);
1077
- const message = (blocks.size > 0 ? ordered(blocks) : ordered(deltas)).join("").trim();
1078
- if (message === "") {
1079
- throw Object.assign(new Error("the model returned no commit message"), { pluginCode: "llm-empty" });
1080
- }
1081
- return message;
1082
- }
1083
-
1084
- /**
1085
- * `generateMessage` endpoint: draft a commit message from the working tree.
1086
- * @param ctx - context exposing the `llm` service.
1087
- * @param rawCwd - session workspace directory.
1088
- * @param rawMode - `staged`, `unstaged`, or `all`.
1089
- * @param rawProvider - caller's provider route (optional).
1090
- * @param rawModel - caller's model id (optional).
1091
- * @param signal - transport cancellation.
1092
- */
1093
- async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, signal) {
1094
- if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
1095
- if (rawMode !== undefined && rawMode !== null && !GENERATE_MODES.includes(rawMode)) {
1096
- return fail("invalid-mode", `mode must be one of ${GENERATE_MODES.join(", ")}`);
1097
- }
1098
- const mode = rawMode ?? GENERATE_MODE_DEFAULT;
1099
- const changes = await readChangesForMode(rawCwd, mode, signal);
1100
- if (changes.ok !== true) return changes;
1101
- if (changes.value.repo !== true) return fail("not-a-repo", "not a git repository", {});
1102
- if (changes.value.diff === "") {
1103
- return fail("no-changes", generationEmptyHint(mode), { mode });
1104
- }
1105
- let route;
1106
- try {
1107
- route = await resolveLlmRoute(ctx, rawProvider, rawModel);
1108
- } catch (error) {
1109
- return fail(error.pluginCode ?? "llm-failed", error.message, { mode });
1110
- }
1111
- const prompt = generationPrompt(
1112
- clampForModel(changes.value.stat, LLM_STAT_MAX_CHARS),
1113
- clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS)
1114
- );
1115
- // Bound the call even if the browser goes away without hanging up; the
1116
- // turn's own signal still cancels immediately.
1117
- const timeout = AbortSignal.timeout(LLM_TIMEOUT_MS);
1118
- const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
1119
- if (combined.aborted) return fail("cancelled", "generation was cancelled", { mode });
1120
- try {
1121
- const message = await requestCommitMessage(ctx, prompt, route, combined);
1122
- return ok({ message, mode, provider: route.provider, model: route.model });
1123
- } catch (error) {
1124
- return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), { mode });
1125
- }
1126
- }
1127
-
1128
- /** The actionable, mode-aware wording for an empty change set. */
1129
- function generationEmptyHint(mode) {
1130
- if (mode === "staged") return "no staged changes; stage them first (or generate from unstaged/all changes)";
1131
- if (mode === "unstaged") return "no unstaged changes";
1132
- return "no changes in the working tree";
1133
- }
1134
-
1135
- /**
1136
- * Dispatch one endpoint call.
1137
- * @param ctx - plugin context exposing `llm` (declared above).
1138
- * @param endpoint - endpoint name (already path-validated).
1139
- * @param payload - the client-request payload (`{ args }`).
1140
- * @param signal - abort signal cancelling the git child or the LLM stream.
1141
- */
1142
- async function dispatch(ctx, endpoint, payload, signal) {
1143
- const args = payload !== null && typeof payload === "object" && payload.args !== null && typeof payload.args === "object"
1144
- ? payload.args
1145
- : {};
1146
- switch (endpoint) {
1147
- case "status":
1148
- return gitStatus(args.cwd, signal);
1149
- case "branches":
1150
- return gitBranches(args.cwd, signal);
1151
- case "checkout":
1152
- return gitCheckout(args.cwd, args.branch, signal);
1153
- case "createBranch":
1154
- return gitCreateBranch(args.cwd, args.branch, args.base, signal);
1155
- case "fetch":
1156
- return gitFetch(args.cwd, args.remote, signal);
1157
- case "pull":
1158
- return gitPull(args.cwd, signal);
1159
- case "stage":
1160
- return gitStage(args.cwd, signal);
1161
- case "diff":
1162
- return gitFileDiff(args.cwd, args.path, args.origPath, signal);
1163
- case "commit":
1164
- return gitCommit(args.cwd, args.message, signal);
1165
- case "push":
1166
- return gitPush(args.cwd, signal);
1167
- case "log":
1168
- return gitLog(args.cwd, args.count, signal);
1169
- case "generateMessage":
1170
- return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, signal);
1171
- default:
1172
- return fail("unknown-endpoint", `unknown git endpoint ${JSON.stringify(endpoint)}`);
1173
- }
1174
- }
1175
-
1176
- /**
1177
- * Plugin body: mount the `/dsh-git-rpc` channel on the web server and speak the
1178
- * Connection RPC wire protocol (see the file header for why the route is
1179
- * self-owned on dsh >= 0.1.5-rc.1).
1180
- * @param ctx - plugin context with `webServer`, `connection`, and `llm` (declared above).
1181
- */
1182
- function apply(ctx) {
1183
- // The channel runs git against caller-supplied absolute paths, so it must
1184
- // never mount unfenced: the connection service's Host/Origin + browser-cookie
1185
- // check is the only gate. `requestRejection` is the >= 0.1.5-rc.1 form of that
1186
- // check; an older host without it fails loudly instead of serving an open
1187
- // channel.
1188
- const connection = ctx.get("connection");
1189
- if (connection === undefined || typeof connection.requestRejection !== "function") {
1190
- throw new Error(`${name}: this plugin requires dsh >= 0.1.5-rc.1 (connection.requestRejection is unavailable, so /dsh-git-rpc could not be fenced)`);
1191
- }
1192
- ctx.effect(() => ctx.webServer.register({
1193
- kind: "prefix",
1194
- path: RPC_CHANNEL,
1195
- handler: async (req, res) => {
1196
- const pathname = String(req.url ?? "/").split("?")[0];
1197
- const endpoint = endpointFromPath(pathname);
1198
- if (endpoint === undefined) {
1199
- sendEnvelope(res, 404, rpcError("invalid-request", "not-found", "not found", {}));
1200
- return;
1201
- }
1202
- // The connection service owns the Host/Origin fence and the browser
1203
- // session cookie; a plugin channel must not bypass it.
1204
- const rejection = connection.requestRejection(req);
1205
- if (rejection !== undefined) {
1206
- res.statusCode = rejection;
1207
- res.end(rejection === 401 ? "unauthorized" : "forbidden");
1208
- return;
1209
- }
1210
- if (req.method !== "POST") {
1211
- res.statusCode = 405;
1212
- res.setHeader("allow", "POST");
1213
- res.end();
1214
- return;
1215
- }
1216
- const contentType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
1217
- if (contentType !== "application/json") {
1218
- sendEnvelope(res, 415, rpcError("invalid-request", "gateway/bad-request", "content type must be application/json", {}));
1219
- return;
1220
- }
1221
- let text;
1222
- try {
1223
- text = await readBoundedBody(req, RPC_MAX_BODY_BYTES);
1224
- } catch {
1225
- sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "body read failed", {}));
1226
- return;
1227
- }
1228
- if (text === undefined) {
1229
- sendEnvelope(res, 413, rpcError("invalid-request", "gateway/bad-request", "request body too large or unreadable", {}));
1230
- return;
1231
- }
1232
- let message;
1233
- try {
1234
- message = JSON.parse(text);
1235
- } catch {
1236
- sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "body is not JSON", {}));
1237
- return;
1238
- }
1239
- // Same outer-envelope contract as client-connection clientRequestSchema.
1240
- if (message === null || typeof message !== "object" || Array.isArray(message) || message.type !== "client-request"
1241
- || typeof message.rpcId !== "string" || typeof message.method !== "string" || !("payload" in message)) {
1242
- sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "invalid client-request message", {}));
1243
- return;
1244
- }
1245
- if (message.method !== endpoint) {
1246
- sendEnvelope(res, 200, rpcError(message.rpcId, "gateway/bad-request",
1247
- `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, {}));
1248
- return;
1249
- }
1250
- // Cancel the git child when the browser drops the request.
1251
- const controller = new AbortController();
1252
- const abort = () => { controller.abort(); };
1253
- req.on("aborted", abort);
1254
- res.on("close", () => { if (res.writableEnded !== true) abort(); });
1255
- let result;
1256
- try {
1257
- result = await dispatch(ctx, endpoint, message.payload, controller.signal);
1258
- } catch (error) {
1259
- sendEnvelope(res, 200, rpcFull(message.rpcId, fail("internal-error", error instanceof Error ? error.message : String(error))));
1260
- return;
1261
- }
1262
- sendEnvelope(res, 200, rpcFull(message.rpcId, result));
1263
- }
1264
- }), `dsh-git: POST ${RPC_CHANNEL}/*`);
1265
- }
1266
-
1267
- // `apply`/`inject`/`name` are the Cordis plugin face. The remaining exports are
1268
- // the units worth testing directly: the generation units (a route path reads a
1269
- // diff through git first, so `generateMessage` needs a real repository to reach
1270
- // them) and the commit-message normalizer (pure, and the exact place the
1271
- // subject+body regression lived).
1272
- export { apply, clampForModel, generationPrompt, inject, name, normalizeCommitMessage, requestCommitMessage, resolveLlmRoute };
1
+ /**
2
+ * @xia-sc/dsh-git — host half.
3
+ *
4
+ * Mounts the `/dsh-git-rpc` Connection RPC channel on the web server. The
5
+ * browser half (lib/client.js) calls the endpoints through
6
+ * `connection.rpc.call("/dsh-git-rpc", endpoint, { args })` with the current
7
+ * session's workspace directory as `args.cwd`:
8
+ *
9
+ * - `status` → repo facts: current branch (or detached HEAD), dirty file
10
+ * count, ahead/behind vs upstream, uncommitted file list.
11
+ * - `branches` → local and remote branch lists.
12
+ * - `checkout` → switch branches (git switch --guess; DWIM, never detaches).
13
+ * - `createBranch` → create a new branch from a base branch and switch to
14
+ * it (IDE "new branch from…" semantics).
15
+ * - `fetch` → git fetch (optionally a specific remote).
16
+ * - `pull` → git pull --ff-only (fast-forward only; conflicts surface
17
+ * as errors instead of surprise merges).
18
+ * - `stage` → git add --all, so the next commit has something to record.
19
+ * - `diff` → one changed path's unified diff, read from both sides of the
20
+ * index (staged / unstaged) so the panel can show either;
21
+ * untracked paths are diffed against the empty blob.
22
+ * - `commit` → git commit (message on stdin via `--file=-`) with
23
+ * author-config check up front.
24
+ * - `push` → git push (current branch's upstream).
25
+ * - `log` → recent commit summary lines.
26
+ * - `generateMessage` → draft a commit message from the working tree through
27
+ * the shared LLM service (see the endpoint's own docs).
28
+ *
29
+ * Every git run goes through execFile with a fixed argument array (no shell),
30
+ * a timeout, and strict input validation.
31
+ *
32
+ * dsh >= 0.1.5-rc.1: this package owns its HTTP route outright.
33
+ *
34
+ * `connection.rpc.handle()` cannot be used by an outside plugin in this
35
+ * version. `HostConnectionService.rpc` closes over `this.ctx` — the connection
36
+ * plugin's OWN Context, whose inject is only `["credentials"]` — and registers
37
+ * through it (`owner.effect(() => owner.webServer.register(route))`, see
38
+ * dsh-client-connection lib/index.js), while that plugin reaches `webServer`
39
+ * through an inner `ctx.inject(["webServer"], …)` scope. The owner fiber
40
+ * therefore never resolves `webServer`, and the call throws
41
+ * `cannot get property "webServer" without inject` no matter what the caller
42
+ * injects. We register the channel on `webServer` ourselves and speak the same
43
+ * Connection RPC wire protocol the browser's `connection.rpc.call` sends
44
+ * (`{type:"client-request",rpcId,method,payload}` in,
45
+ * `{type:"server-response",rpcId,result}` out).
46
+ *
47
+ * The channel keeps the connection service's own Host/Origin + browser-cookie
48
+ * fence (`connection.requestRejection`), so it is exactly as trusted as the
49
+ * `/api` transport — loopback or a configured trusted authority, same-origin.
50
+ */
51
+ import { execFile } from "node:child_process";
52
+ import { randomUUID } from "node:crypto";
53
+ import { isAbsolute } from "node:path";
54
+
55
+ /** Stable Cordis plugin name. */
56
+ const name = "dsh-git";
57
+ /**
58
+ * Services required before this plugin can mount its channel: `webServer`
59
+ * owns the route, `connection` supplies the request fence, and `llm` backs the
60
+ * optional commit-message generation endpoint.
61
+ */
62
+ const inject = ["webServer", "connection", "llm"];
63
+
64
+ /** The Connection RPC channel this plugin serves. */
65
+ const RPC_CHANNEL = "/dsh-git-rpc";
66
+ /** One endpoint segment, mirroring the client's ENDPOINT_SEGMENT_PATTERN. */
67
+ const RPC_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
68
+ /** Request-body cap: git RPC payloads are small (a commit message at most). */
69
+ const RPC_MAX_BODY_BYTES = 1024 * 1024;
70
+
71
+ /** Per-invocation git timeout for fast local operations. */
72
+ const GIT_TIMEOUT_MS = 30000;
73
+ /** Longer timeout for network operations (fetch/pull/push). */
74
+ const GIT_NET_TIMEOUT_MS = 120000;
75
+ /** Capture bound for git output (large repos / long pushes). */
76
+ const MAX_BUFFER = 32 * 1024 * 1024;
77
+
78
+ /**
79
+ * Ceiling for one commit-message generation. The browser's own abort still
80
+ * applies; this only bounds a request whose client went away without hanging up.
81
+ */
82
+ const LLM_TIMEOUT_MS = 120000;
83
+ /** Diff characters sent to the model (a diff has no natural size bound). */
84
+ const LLM_DIFF_MAX_CHARS = 12000;
85
+ /** Diff-stat characters sent to the model. */
86
+ const LLM_STAT_MAX_CHARS = 2000;
87
+ /**
88
+ * Output cap for one generated commit message. A reasoning model spends this
89
+ * budget on its thinking BEFORE any text exists and reports the two together as
90
+ * `completion_tokens`, so a cap sized for the message alone truncates every
91
+ * call that thinks a little. 8192 leaves room for a long think plus a
92
+ * subject+body; the model still stops on its own well before it (an unset cap
93
+ * would fall back to the adapter's own default, which is larger still).
94
+ */
95
+ const LLM_MAX_TOKENS = 8192;
96
+ /** Accepted `generateMessage` modes. */
97
+ const GENERATE_MODES = ["staged", "unstaged", "all"];
98
+ /** Default mode: the only one whose content is what `commit` will actually record. */
99
+ const GENERATE_MODE_DEFAULT = "staged";
100
+
101
+ /** A successful RPC result. */
102
+ function ok(value) {
103
+ return { ok: true, value };
104
+ }
105
+ /** A failed RPC result in the Connection transport's `RpcResult` error shape. */
106
+ function fail(code, message, details = {}) {
107
+ // The wire requires `error.code` to be a string and `error.details` to be a
108
+ // plain object (see dsh-client-connection rpcErrorSchema / the browser's
109
+ // parseConnectionResponse). `code: "internal"` keeps the envelope acceptable
110
+ // to every 0.1.x host, while the plugin's own diagnostic code rides in
111
+ // `details.code` and `message` stays the human-readable git text the panel
112
+ // shows.
113
+ return { ok: false, error: { code: "internal", message, details: Object.assign({}, details, { code }) } };
114
+ }
115
+
116
+ /**
117
+ * The child's numeric exit status, from whichever field this Node build used.
118
+ * A spawn failure (ENOENT) carries a string errno instead, so it never matches.
119
+ * @param error - an execFile failure.
120
+ * @returns the status, or undefined when the child never exited.
121
+ */
122
+ function exitStatusOf(error) {
123
+ for (const candidate of [error.code, error.status, error.exitCode]) {
124
+ if (typeof candidate === "number") return candidate;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * Run one git invocation without a shell.
131
+ * @param cwd - working directory (the session workspace).
132
+ * @param args - git arguments (never user-joined into a string; no shell).
133
+ * @param signal - optional transport cancellation (kills the child on abort).
134
+ * @param timeoutMs - per-call timeout.
135
+ * @param stdin - optional text written to the child's stdin (`--file=-`).
136
+ * @param allowExit - exit codes this call reads as success. `git diff --no-index`
137
+ * reports "the two sides differ" as exit 1, which is exactly the answer a viewer
138
+ * asked for, so that endpoint lists `[1]` instead of treating it as a failure.
139
+ * @returns resolved stdout/stderr, or rejects with the exec error augmented
140
+ * with `stdout`/`stderr` text.
141
+ */
142
+ function runGit(cwd, args, signal, timeoutMs = GIT_TIMEOUT_MS, stdin = undefined, allowExit = undefined) {
143
+ return new Promise((resolve, reject) => {
144
+ const child = execFile("git", args, {
145
+ cwd,
146
+ timeout: timeoutMs,
147
+ maxBuffer: MAX_BUFFER,
148
+ windowsHide: true,
149
+ encoding: "utf8",
150
+ signal
151
+ }, (error, stdout, stderr) => {
152
+ if (error === null) {
153
+ resolve({ stdout, stderr });
154
+ return;
155
+ }
156
+ if (Array.isArray(allowExit) && allowExit.includes(exitStatusOf(error))) {
157
+ // Resolve with whatever the child did print: the caller has declared
158
+ // this code to be an answer rather than a failure.
159
+ resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
160
+ return;
161
+ }
162
+ const wrapped = error instanceof Error ? error : new Error(String(error));
163
+ wrapped.stdout = String(stdout ?? "");
164
+ wrapped.stderr = String(stderr ?? "");
165
+ reject(wrapped);
166
+ });
167
+ if (stdin === undefined) return;
168
+ // A message body is written as UTF-8 and the stream is always closed, so
169
+ // git sees a complete `--file=-` input even when the child never reads it.
170
+ child.stdin.on("error", () => {});
171
+ child.stdin.end(stdin, "utf8");
172
+ });
173
+ }
174
+
175
+ /** Whether a git exec failure means the cwd is outside any work tree. */
176
+ function isNotARepo(error) {
177
+ return /not a git repository/i.test(error.stderr ?? "");
178
+ }
179
+
180
+ /** Extract a bounded, display-safe diagnostic from a git failure. */
181
+ function cleanMessage(error) {
182
+ const text = String(error.stderr ?? error.message ?? "").trim().replace(/\s+/g, " ");
183
+ return text.length > 0 ? text.slice(0, 800) : "git failed";
184
+ }
185
+
186
+ /** Map an exec failure to an RPC error result. */
187
+ function gitError(error) {
188
+ if (error.code === "ENOENT") {
189
+ return fail("git-not-found", "git executable not found on PATH", {});
190
+ }
191
+ if (error.killed === true) {
192
+ return fail("timeout", "git did not finish within the time limit", {});
193
+ }
194
+ if (error.name === "AbortError" || error.signal !== undefined && error.signal !== null) {
195
+ return fail("cancelled", "git operation was cancelled", {});
196
+ }
197
+ return fail("git-error", cleanMessage(error), {});
198
+ }
199
+
200
+ /** Validate a caller-supplied working directory. */
201
+ function validCwd(value) {
202
+ return typeof value === "string" && value.length > 0 && isAbsolute(value);
203
+ }
204
+
205
+ /**
206
+ * Validate a caller-supplied remote name (origin, upstream, …).
207
+ * Plain segment: letters/digits/._- only.
208
+ */
209
+ function validRemote(value) {
210
+ if (value === undefined) return true;
211
+ return typeof value === "string" && value.length > 0 && value.length <= 100 && !value.startsWith("-") && !value.includes("..") && /^[A-Za-z0-9._-]+$/.test(value);
212
+ }
213
+
214
+ /** Longest accepted repository-relative path (a git pathspec). */
215
+ const PATH_MAX = 4096;
216
+
217
+ /**
218
+ * Normalize a caller-supplied path into a repository-relative git pathspec.
219
+ *
220
+ * The diff viewer names one file from the status list, so the accepted shape is
221
+ * deliberately narrow: a relative path with no traversal, no option-looking
222
+ * leading `-`, no absolute or drive-qualified form, and no control characters or
223
+ * surrounding whitespace.
224
+ * A trailing slash (how git prints an untracked directory) is kept — the pathspec
225
+ * then matches the whole subtree, which is what the user clicked.
226
+ * @param value - caller-supplied path.
227
+ * @returns the slash-normalized path, or null when it is unusable.
228
+ */
229
+ function normalizedPath(value) {
230
+ if (typeof value !== "string") return null;
231
+ if (value.length === 0 || value.length > PATH_MAX) return null;
232
+ // Control characters (NUL, CR, LF, tab, DEL) and surrounding whitespace are
233
+ // never part of a name this endpoint is handed: git quotes such a path in
234
+ // the status output, so it could not have arrived intact anyway.
235
+ if (/[\u0000-\u001f\u007f]/.test(value)) return null;
236
+ if (value !== value.trim()) return null;
237
+ const file = value.replace(/\\/g, "/");
238
+ if (file.startsWith("-") || file.startsWith("/")) return null;
239
+ if (/^[A-Za-z]:/.test(file)) return null;
240
+ if (file.split("/").some((segment) => segment === "." || segment === "..")) return null;
241
+ return file;
242
+ }
243
+
244
+ /** Longest accepted commit message (characters). */
245
+ const MESSAGE_MAX = 10000;
246
+ /**
247
+ * Characters a commit message may never contain: NUL, every other C0 control
248
+ * except the line feed and the tab, and DEL. A line feed is the message's own
249
+ * line separator and a tab is ordinary indentation both are legal in a real
250
+ * commit message, and rejecting them here silently broke every drafted (or
251
+ * pasted) message that carried a subject plus a body.
252
+ */
253
+ const MESSAGE_FORBIDDEN = /[\u0000-\u0008\u000b-\u001f\u007f]/;
254
+
255
+ /**
256
+ * Normalize a caller-supplied commit message into the exact text git records.
257
+ * Accepts a real commit message shape one subject line plus an optional
258
+ * multi-line body so newline separated drafts work, while still refusing a
259
+ * message git must not be handed:
260
+ *
261
+ * - CRLF and lone CR collapse to LF, so a Windows draft survives a clipboard
262
+ * round trip instead of turning into one unreadable line;
263
+ * - trailing whitespace is stripped per line, leading and trailing blank
264
+ * lines are dropped, and runs of blank lines collapse to one — this is
265
+ * `git commit --cleanup=whitespace` applied here too, and it deliberately
266
+ * *keeps* the single blank line that separates subject from body;
267
+ * - empty, whitespace-only, and over-long messages are rejected;
268
+ * - NUL, other non-whitespace control characters, and DEL are rejected.
269
+ *
270
+ * @param value - caller-supplied message.
271
+ * @returns the normalized message, or null when the value is unusable.
272
+ */
273
+ function normalizeCommitMessage(value) {
274
+ if (typeof value !== "string") return null;
275
+ const trimmed = value.trim();
276
+ if (trimmed.length === 0 || trimmed.length > MESSAGE_MAX) return null;
277
+ const lines = trimmed.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/[ \t]+$/, ""));
278
+ const collapsed = [];
279
+ for (const line of lines) {
280
+ if (line === "" && (collapsed.length === 0 || collapsed[collapsed.length - 1] === "")) continue;
281
+ collapsed.push(line);
282
+ }
283
+ const normalized = collapsed.join("\n");
284
+ if (normalized.length === 0 || MESSAGE_FORBIDDEN.test(normalized)) return null;
285
+ return normalized;
286
+ }
287
+
288
+ /** Normalize a caller-supplied branch name to a safe git argument. */
289
+ function normalizeBranch(raw) {
290
+ if (typeof raw !== "string") return null;
291
+ let value = raw.trim();
292
+ if (value.startsWith("remotes/")) value = value.slice("remotes/".length);
293
+ if (value.length === 0 || value.length > 255) return null;
294
+ if (value.startsWith("-")) return null;
295
+ if (/[\\\s"'`\u0000-\u001f]/.test(value)) return null;
296
+ if (value.includes("..") || value.includes("@{") || value.includes(":") || value.includes("~") || value.includes("^")) return null;
297
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value)) return null;
298
+ return value;
299
+ }
300
+
301
+ /** Human label for one porcelain-v2 XY status pair (first char wins for display). */
302
+ function labelOf(xy) {
303
+ if (xy === undefined || xy === null) return "?";
304
+ const code = String(xy);
305
+ const first = code[0];
306
+ const second = code[1];
307
+ if (first === "?" || second === "?") return "untracked";
308
+ if (first === "!" || second === "!") return "ignored";
309
+ if (first === "u" || second === "u" || code === "UU") return "conflict";
310
+ switch (first) {
311
+ case "M": return "modified";
312
+ case "A": return "added";
313
+ case "D": return "deleted";
314
+ case "R": return "renamed";
315
+ case "C": return "copied";
316
+ case "T": return "type-changed";
317
+ default: return "changed";
318
+ }
319
+ }
320
+
321
+ /**
322
+ * One entry of the status list.
323
+ *
324
+ * `path` is what the panel prints (a rename reads `old new`), while `file` /
325
+ * `origFile` are the pathspec the diff endpoint has to be given. `index` and
326
+ * `worktree` are the two porcelain-v2 letters, so the viewer knows which side of
327
+ * the index an entry actually lives on instead of guessing from the label.
328
+ * @param xy - the two-letter XY status.
329
+ * @param path - the path git reported (already display-shaped for a rename).
330
+ * @param options - `file` (pathspec path), `origFile` (pre-rename path), and
331
+ * `status` when the record's own label would mislead (an unmerged pair is a
332
+ * conflict whatever its two letters say).
333
+ */
334
+ function changeEntry(xy, path, options = {}) {
335
+ const code = typeof xy === "string" ? xy : "";
336
+ return {
337
+ status: options.status !== undefined ? options.status : labelOf(code),
338
+ path,
339
+ index: code[0] !== undefined ? code[0] : " ",
340
+ worktree: code[1] !== undefined ? code[1] : " ",
341
+ file: options.file !== undefined ? options.file : path,
342
+ origFile: options.origFile !== undefined ? options.origFile : null
343
+ };
344
+ }
345
+
346
+ /**
347
+ * `status` endpoint: repo facts + uncommitted file list for one directory.
348
+ * @param rawCwd - session workspace directory.
349
+ * @param signal - transport cancellation.
350
+ */
351
+ async function gitStatus(rawCwd, signal) {
352
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
353
+ let out;
354
+ try {
355
+ out = await runGit(rawCwd, ["status", "--porcelain=v2", "--branch"], signal);
356
+ } catch (error) {
357
+ if (isNotARepo(error)) return ok({ repo: false });
358
+ return gitError(error);
359
+ }
360
+ let branch = null;
361
+ let detached = false;
362
+ let oid = null;
363
+ let upstream = null;
364
+ let ahead = 0;
365
+ let behind = 0;
366
+ let dirty = 0;
367
+ const changes = [];
368
+ for (const line of out.stdout.split(/\r?\n/)) {
369
+ if (line.startsWith("# branch.head ")) {
370
+ const head = line.slice("# branch.head ".length).trim();
371
+ detached = head === "(detached)";
372
+ if (!detached) branch = head;
373
+ } else if (line.startsWith("# branch.oid ")) {
374
+ oid = line.slice("# branch.oid ".length).trim();
375
+ } else if (line.startsWith("# branch.upstream ")) {
376
+ upstream = line.slice("# branch.upstream ".length).trim();
377
+ } else if (line.startsWith("# branch.ab ")) {
378
+ const match = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
379
+ if (match !== null) {
380
+ ahead = Number(match[1]);
381
+ behind = Number(match[2]);
382
+ }
383
+ } else if (line.length > 0) {
384
+ dirty += 1;
385
+ const parts = line.split(" ");
386
+ if (parts.length >= 2 && parts[0] !== "!") {
387
+ if (parts[0] === "?") {
388
+ // `? <path>` untracked; git collapses an untracked directory
389
+ // into its `dir/` entry, which the diff endpoint expands.
390
+ changes.push(changeEntry("??", parts.slice(1).join(" ")));
391
+ } else if (parts[0] === "1") {
392
+ // `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>`
393
+ changes.push(changeEntry(parts[1], parts.slice(8).join(" ")));
394
+ } else if (parts[0] === "2") {
395
+ // `2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>\t<origPath>`
396
+ // The last two fields are ONE space-separated token (a tab
397
+ // separates them), which is why the score column at index 8 is
398
+ // not the path.
399
+ const pair = parts.slice(9).join(" ").split("\t");
400
+ const path = pair[0] !== undefined ? pair[0] : "";
401
+ const orig = pair[1] !== undefined && pair[1] !== "" ? pair[1] : null;
402
+ changes.push(changeEntry(parts[1], orig !== null ? `${orig} → ${path}` : path, { file: path, origFile: orig }));
403
+ } else if (parts[0] === "u") {
404
+ // `u <XY> <sub> <m1> <m2> <m3> <mW> <h1> <h2> <h3> <path>`
405
+ const path = parts.slice(10).join(" ");
406
+ changes.push(changeEntry(parts[1] !== undefined ? parts[1] : "uu", path, { file: path, status: "conflict" }));
407
+ }
408
+ }
409
+ }
410
+ }
411
+ return ok({ repo: true, branch, detached, oid, upstream, ahead, behind, dirty, changes });
412
+ }
413
+
414
+ /**
415
+ * `branches` endpoint: local and remote branch lists for one directory.
416
+ * @param rawCwd - session workspace directory.
417
+ * @param signal - transport cancellation.
418
+ */
419
+ async function gitBranches(rawCwd, signal) {
420
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
421
+ let heads;
422
+ let remotes;
423
+ try {
424
+ [heads, remotes] = await Promise.all([
425
+ runGit(rawCwd, [
426
+ "for-each-ref",
427
+ "--format=%(refname:short)%00%(HEAD)%00%(upstream:short)%00%(objectname:short)",
428
+ "refs/heads"
429
+ ], signal),
430
+ runGit(rawCwd, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"], signal)
431
+ ]);
432
+ } catch (error) {
433
+ if (isNotARepo(error)) return ok({ repo: false, current: null, local: [], remote: [] });
434
+ return gitError(error);
435
+ }
436
+ const local = heads.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
437
+ const parts = line.split("\0");
438
+ return {
439
+ name: parts[0],
440
+ current: parts[1] === "*",
441
+ upstream: parts[2] !== undefined && parts[2] !== "" ? parts[2] : null,
442
+ sha: parts[3] !== undefined && parts[3] !== "" ? parts[3] : null
443
+ };
444
+ });
445
+ const remote = remotes.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
446
+ // refname:short for a remote-tracking ref is "<remote>/<branch...>".
447
+ // The DWIM-able branch name is the path after the remote segment.
448
+ const stripped = line.replace(/^remotes\//, "");
449
+ const slash = stripped.indexOf("/");
450
+ const short = slash === -1 ? stripped : stripped.slice(slash + 1);
451
+ return { name: line, short };
452
+ });
453
+ const current = local.find((entry) => entry.current)?.name ?? null;
454
+ return ok({ repo: true, current, local, remote });
455
+ }
456
+
457
+ /**
458
+ * `checkout` endpoint: switch the repo at `cwd` to `branch`.
459
+ * @param rawCwd - session workspace directory.
460
+ * @param rawBranch - branch name to switch to.
461
+ * @param signal - transport cancellation.
462
+ */
463
+ async function gitCheckout(rawCwd, rawBranch, signal) {
464
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
465
+ const branch = normalizeBranch(rawBranch);
466
+ if (branch === null) return fail("invalid-branch", "invalid branch name");
467
+ let out;
468
+ try {
469
+ out = await runGit(rawCwd, ["switch", "--guess", branch], signal);
470
+ } catch (error) {
471
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
472
+ const detail = cleanMessage(error);
473
+ // The common refusal: local changes would be overwritten by the switch.
474
+ // Keep git's own words but add context so the panel message is readable.
475
+ if (/local changes to the following files would be overwritten|your local changes would be overwritten/i.test(detail)) {
476
+ return fail("checkout-failed", "本地有未提交的修改会被切换覆盖,git 已拒绝:" + detail, {});
477
+ }
478
+ return fail("checkout-failed", detail, {});
479
+ }
480
+ const status = await gitStatus(rawCwd, signal);
481
+ if (status.ok === true && status.value.repo === true) {
482
+ return ok({
483
+ branch: status.value.branch,
484
+ detached: status.value.detached,
485
+ oid: status.value.oid,
486
+ message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null
487
+ });
488
+ }
489
+ return ok({ branch, detached: false, oid: null, message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null });
490
+ }
491
+
492
+ /**
493
+ * `createBranch` endpoint: create a new branch from a base branch and switch
494
+ * to it (IDE "new branch from…" semantics). The base may be a local branch
495
+ * name or a remote-tracking ref (e.g. `origin/feature/x`); an omitted base
496
+ * means HEAD (the current state).
497
+ * @param rawCwd - session workspace directory.
498
+ * @param rawBranch - new branch name (validated; `HEAD` is rejected).
499
+ * @param rawBase - base branch/ref, or undefined/null for HEAD.
500
+ * @param signal - transport cancellation.
501
+ */
502
+ async function gitCreateBranch(rawCwd, rawBranch, rawBase, signal) {
503
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
504
+ const branch = normalizeBranch(rawBranch);
505
+ if (branch === null || branch.toLowerCase() === "head") return fail("invalid-branch", "invalid branch name");
506
+ const base = rawBase === undefined || rawBase === null ? "HEAD" : normalizeBranch(rawBase);
507
+ if (base === null) return fail("invalid-branch", "invalid base branch");
508
+ let out;
509
+ try {
510
+ out = await runGit(rawCwd, ["switch", "--create", branch, base], signal);
511
+ } catch (error) {
512
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
513
+ return fail("create-branch-failed", cleanMessage(error), {});
514
+ }
515
+ const status = await gitStatus(rawCwd, signal);
516
+ if (status.ok === true && status.value.repo === true) {
517
+ return ok({
518
+ branch: status.value.branch,
519
+ detached: status.value.detached,
520
+ oid: status.value.oid,
521
+ message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null
522
+ });
523
+ }
524
+ return ok({ branch, detached: false, oid: null, message: out.stderr.trim() !== "" ? out.stderr.trim().slice(0, 500) : null });
525
+ }
526
+
527
+ /**
528
+ * `fetch` endpoint: download remote refs (optionally one remote).
529
+ * @param rawCwd - session workspace directory.
530
+ * @param rawRemote - optional remote name (validated).
531
+ * @param signal - transport cancellation.
532
+ */
533
+ async function gitFetch(rawCwd, rawRemote, signal) {
534
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
535
+ if (!validRemote(rawRemote)) return fail("invalid-remote", "invalid remote name");
536
+ const args = rawRemote === undefined ? ["fetch"] : ["fetch", rawRemote];
537
+ let out;
538
+ try {
539
+ out = await runGit(rawCwd, args, signal, GIT_NET_TIMEOUT_MS);
540
+ } catch (error) {
541
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
542
+ return fail("fetch-failed", cleanMessage(error), {});
543
+ }
544
+ const detail = [out.stdout, out.stderr].join("\n").trim();
545
+ return ok({ message: detail.slice(0, 800) });
546
+ }
547
+
548
+ /**
549
+ * `pull` endpoint: fast-forward-only pull of the current branch's upstream.
550
+ * Never merges implicitly; a non-fast-forward or dirty-tree case surfaces as
551
+ * an error the user resolves in their own tooling.
552
+ * @param rawCwd - session workspace directory.
553
+ * @param signal - transport cancellation.
554
+ */
555
+ async function gitPull(rawCwd, signal) {
556
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
557
+ let out;
558
+ try {
559
+ out = await runGit(rawCwd, ["pull", "--ff-only"], signal, GIT_NET_TIMEOUT_MS);
560
+ } catch (error) {
561
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
562
+ return fail("pull-failed", cleanMessage(error), {});
563
+ }
564
+ const detail = [out.stdout, out.stderr].join("\n").trim();
565
+ return ok({ message: detail.slice(0, 800) });
566
+ }
567
+
568
+ /** Read the repo's configured author (user.name / user.email) or null. */
569
+ async function readAuthor(cwd) {
570
+ const read = async (key) => {
571
+ try {
572
+ const out = await runGit(cwd, ["config", key]);
573
+ return out.stdout.trim();
574
+ } catch {
575
+ return "";
576
+ }
577
+ };
578
+ const [name, email] = await Promise.all([read("user.name"), read("user.email")]);
579
+ return name !== "" || email !== "" ? { name, email } : null;
580
+ }
581
+
582
+ /**
583
+ * `commit` endpoint: commit staged + message (no implicit add — the user
584
+ * stages explicitly with `git add` in their own tooling; we commit what is
585
+ * staged). Checks author config up front with a clear error.
586
+ *
587
+ * The message reaches git on **stdin** through `--file=-`, never as an
588
+ * `--message=<msg>` argument: a subject-plus-body message contains spaces and
589
+ * line feeds, and Windows argument quoting is exactly where such a message
590
+ * would be mangled or truncated. `--cleanup=whitespace` is pinned explicitly so
591
+ * a user's `commit.cleanup=verbatim` (or `scissors`) configuration cannot
592
+ * truncate or reshape the message behind the panel's back.
593
+ *
594
+ * @param rawCwd - session workspace directory.
595
+ * @param rawMessage - commit message (normalized and validated).
596
+ * @param signal - transport cancellation.
597
+ */
598
+ async function gitCommit(rawCwd, rawMessage, signal) {
599
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
600
+ const message = normalizeCommitMessage(rawMessage);
601
+ if (message === null) {
602
+ return fail("invalid-message", "a commit message is required (non-empty, at most " + MESSAGE_MAX + " characters, and free of control characters)");
603
+ }
604
+ const author = await readAuthor(rawCwd);
605
+ if (author === null) {
606
+ return fail("missing-author", "git user.name / user.email are not configured; set them first (e.g. `git config --global user.name \"you\"` and `git config --global user.email you@example.com`)", {});
607
+ }
608
+ let out;
609
+ try {
610
+ out = await runGit(rawCwd, ["commit", "--cleanup=whitespace", "--file=-"], signal, GIT_TIMEOUT_MS, `${message}\n`);
611
+ } catch (error) {
612
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
613
+ return fail("commit-failed", cleanMessage(error), {});
614
+ }
615
+ const summary = out.stdout.trim() !== "" ? out.stdout.trim().slice(0, 500) : out.stderr.trim().slice(0, 500);
616
+ return ok({ message: summary });
617
+ }
618
+
619
+ /**
620
+ * `push` endpoint: push the current branch to its upstream.
621
+ * @param rawCwd - session workspace directory.
622
+ * @param signal - transport cancellation.
623
+ */
624
+ async function gitPush(rawCwd, signal) {
625
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
626
+ let out;
627
+ try {
628
+ out = await runGit(rawCwd, ["push"], signal, GIT_NET_TIMEOUT_MS);
629
+ } catch (error) {
630
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
631
+ return fail("push-failed", cleanMessage(error), {});
632
+ }
633
+ const detail = [out.stdout, out.stderr].join("\n").trim();
634
+ return ok({ message: detail.slice(0, 800) });
635
+ }
636
+
637
+ /**
638
+ * `log` endpoint: recent commit summary lines (default 10).
639
+ * @param rawCwd - session workspace directory.
640
+ * @param rawCount - number of commits to list (clamped 1..50).
641
+ * @param signal - transport cancellation.
642
+ */
643
+ async function gitLog(rawCwd, rawCount, signal) {
644
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
645
+ const count = Number.isInteger(rawCount) ? Math.min(50, Math.max(1, rawCount)) : 10;
646
+ let out;
647
+ try {
648
+ out = await runGit(rawCwd, ["log", `--max-count=${count}`, "--format=%h%x00%an%x00%s%x00%D"], signal);
649
+ } catch (error) {
650
+ if (isNotARepo(error)) return ok({ repo: false, commits: [] });
651
+ return gitError(error);
652
+ }
653
+ const commits = out.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
654
+ const [shortSha, author, subject, refs] = line.split("\0");
655
+ return {
656
+ sha: shortSha ?? "",
657
+ author: author ?? "",
658
+ subject: subject ?? "",
659
+ refs: refs !== undefined && refs !== "" ? refs : null
660
+ };
661
+ });
662
+ return ok({ repo: true, commits });
663
+ }
664
+
665
+ /**
666
+ * Extract the endpoint from a request pathname (`/dsh-git-rpc/<endpoint>`).
667
+ * Returns undefined when the path is outside the channel or a segment is not a
668
+ * plain single segment (empty, `.`, `..`, or outside the client's own
669
+ * segment pattern).
670
+ * @param pathname - URL pathname of the request.
671
+ */
672
+ function endpointFromPath(pathname) {
673
+ if (typeof pathname !== "string" || !pathname.startsWith(`${RPC_CHANNEL}/`)) return undefined;
674
+ const query = pathname.indexOf("?");
675
+ const endpoint = pathname.slice(RPC_CHANNEL.length + 1, query === -1 ? undefined : query);
676
+ if (endpoint === "") return undefined;
677
+ for (const segment of endpoint.split("/")) {
678
+ if (segment === "" || segment === "." || segment === ".." || !RPC_SEGMENT.test(segment)) return undefined;
679
+ }
680
+ return endpoint;
681
+ }
682
+
683
+ /**
684
+ * Read a request body with a hard byte cap.
685
+ * Uses the classic data/end/error events rather than async iteration: the
686
+ * IncomingMessage async iterator is not a stable path in this runtime.
687
+ * @param req - node:http request.
688
+ * @param maxBytes - cap; exceeding it resolves to undefined.
689
+ * @returns the utf8 body, or undefined on overrun/error/abort.
690
+ */
691
+ function readBoundedBody(req, maxBytes) {
692
+ return new Promise((resolve) => {
693
+ const chunks = [];
694
+ let size = 0;
695
+ let settled = false;
696
+ const finish = (value) => {
697
+ if (settled) return;
698
+ settled = true;
699
+ cleanup();
700
+ resolve(value);
701
+ };
702
+ function onData(chunk) {
703
+ size += chunk.length;
704
+ if (size > maxBytes) {
705
+ try { req.resume(); } catch { /* the socket is already gone */ }
706
+ finish(undefined);
707
+ return;
708
+ }
709
+ chunks.push(chunk);
710
+ }
711
+ function onEnd() { finish(Buffer.concat(chunks, size).toString("utf8")); }
712
+ function onError() { finish(undefined); }
713
+ function onAborted() { finish(undefined); }
714
+ function cleanup() {
715
+ try {
716
+ req.removeListener("data", onData);
717
+ req.removeListener("end", onEnd);
718
+ req.removeListener("error", onError);
719
+ req.removeListener("aborted", onAborted);
720
+ } catch { /* listeners already removed */ }
721
+ }
722
+ req.on("data", onData);
723
+ req.on("end", onEnd);
724
+ req.on("error", onError);
725
+ req.on("aborted", onAborted);
726
+ });
727
+ }
728
+
729
+ /** Write one JSON envelope (never throws into the request handler). */
730
+ function sendEnvelope(res, status, payload) {
731
+ try {
732
+ if (res.writableEnded === true) return;
733
+ res.statusCode = status;
734
+ res.setHeader("content-type", "application/json; charset=utf-8");
735
+ res.setHeader("cache-control", "no-store");
736
+ res.end(JSON.stringify(payload));
737
+ } catch {
738
+ try { res.end(); } catch { /* response already finished */ }
739
+ }
740
+ }
741
+
742
+ /** A server-response envelope with a result (the browser's `connection.rpc.call` reply). */
743
+ function rpcFull(rpcId, result) {
744
+ return { type: "server-response", rpcId, result };
745
+ }
746
+
747
+ /** A server-response envelope carrying a failure result. */
748
+ function rpcError(rpcId, code, message, details) {
749
+ return rpcFull(rpcId, { ok: false, error: { code, message, details } });
750
+ }
751
+
752
+ /**
753
+ * `stage` endpoint: stage every working-tree change (`git add --all`), including
754
+ * deletions and untracked files, so the following `commit` has something to
755
+ * record. The commit path itself still never stages implicitly.
756
+ * @param rawCwd - session workspace directory.
757
+ * @param signal - transport cancellation.
758
+ */
759
+ async function gitStage(rawCwd, signal) {
760
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
761
+ let out;
762
+ try {
763
+ out = await runGit(rawCwd, ["add", "--all"], signal);
764
+ } catch (error) {
765
+ if (isNotARepo(error)) return fail("not-a-repo", "not a git repository", {});
766
+ return fail("stage-failed", cleanMessage(error), {});
767
+ }
768
+ const detail = [out.stdout, out.stderr].join("\n").trim();
769
+ // `message` is git's own output and nothing else: an operation git says
770
+ // nothing about reports an empty string, and the panel words its own summary.
771
+ return ok({ message: detail.slice(0, 800) });
772
+ }
773
+
774
+ /**
775
+ * Read one side of the working tree as `{ stat, diff }`.
776
+ * `--no-ext-diff` keeps a configured external diff program out of the path:
777
+ * this is a background read, and it must never open a GUI or block.
778
+ * @param rawCwd - session workspace directory.
779
+ * @param cached - true reads the index (staged), false the working tree (unstaged).
780
+ * @param signal - transport cancellation.
781
+ * @returns `{ repo, stat, diff }`; `repo: false` outside a work tree.
782
+ */
783
+ async function readDiff(rawCwd, cached, signal) {
784
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
785
+ const scope = cached === true ? ["--cached"] : [];
786
+ try {
787
+ const [statOut, diffOut] = await Promise.all([
788
+ runGit(rawCwd, ["diff", ...scope, "--stat", "--no-ext-diff"], signal),
789
+ runGit(rawCwd, ["diff", ...scope, "--no-ext-diff"], signal)
790
+ ]);
791
+ return ok({ repo: true, stat: statOut.stdout.trim(), diff: diffOut.stdout.trim() });
792
+ } catch (error) {
793
+ if (isNotARepo(error)) return ok({ repo: false, stat: "", diff: "" });
794
+ return gitError(error);
795
+ }
796
+ }
797
+
798
+ /** Per-side cap for one diff sent to the viewer (the panel renders a slice of it). */
799
+ const DIFF_MAX_CHARS = 400000;
800
+ /** Untracked files expanded for one directory selection; the rest are counted. */
801
+ const DIFF_MAX_UNTRACKED_FILES = 50;
802
+
803
+ /**
804
+ * Bound one side of a diff at a line boundary, reporting the truncation rather
805
+ * than silently dropping the tail. CRLF is normalized here because the browser
806
+ * renders the text as lines.
807
+ * @param text - raw git output.
808
+ * @returns `{ diff, truncated }`.
809
+ */
810
+ function clampDiffText(text) {
811
+ const cleaned = String(text ?? "").replace(/\r\n/g, "\n");
812
+ if (cleaned.length <= DIFF_MAX_CHARS) return { diff: cleaned, truncated: false };
813
+ const cut = cleaned.lastIndexOf("\n", DIFF_MAX_CHARS);
814
+ return { diff: cleaned.slice(0, cut === -1 ? DIFF_MAX_CHARS : cut), truncated: true };
815
+ }
816
+
817
+ /** Whether one side's text is git's placeholder for a binary difference. */
818
+ function isBinaryDiff(text) {
819
+ return /^Binary files .* differ$/m.test(text) || /^GIT binary patch$/m.test(text);
820
+ }
821
+
822
+ /**
823
+ * Read one side of the index for one pathspec, without a `--stat` pass: the
824
+ * viewer computes its own `+N −M` counts from the hunks it renders, so a second
825
+ * git invocation per side would only add latency.
826
+ * @param rawCwd - session workspace directory.
827
+ * @param paths - pathspec entries (a rename's old and new path both, so git can pair them).
828
+ * @param cached - true reads the index (staged), false the working tree (unstaged).
829
+ * @param signal - transport cancellation.
830
+ * @returns git's stdout.
831
+ */
832
+ async function readPathDiff(rawCwd, paths, cached, signal) {
833
+ const scope = cached === true ? ["--cached"] : [];
834
+ const out = await runGit(rawCwd, ["diff", ...scope, "--no-ext-diff", "--no-color", "--", ...paths], signal);
835
+ return out.stdout;
836
+ }
837
+
838
+ /**
839
+ * List the untracked files one pathspec names (an untracked directory is
840
+ * reported by git as a single `dir/` entry, and this is how its files are found).
841
+ * @param rawCwd - session workspace directory.
842
+ * @param file - repository-relative pathspec.
843
+ * @param signal - transport cancellation.
844
+ * @returns the paths git reports, NUL-separated output split apart.
845
+ */
846
+ async function untrackedFiles(rawCwd, file, signal) {
847
+ const out = await runGit(rawCwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", file], signal);
848
+ return out.stdout.split("\u0000").filter((entry) => entry !== "");
849
+ }
850
+
851
+ /**
852
+ * Diff one untracked file against the empty blob.
853
+ *
854
+ * `git diff --no-index` has no index to compare with, so it is handed
855
+ * `/dev/null` as the missing side (git itself resolves that path on Windows);
856
+ * it reports "the sides differ" as exit 1, which `runGit` accepts for this call.
857
+ * A run that failed for a real reason produced no diff header, so the fallback
858
+ * keeps the error rather than reporting an empty file as unchanged.
859
+ * @param rawCwd - session workspace directory.
860
+ * @param file - repository-relative path of an untracked file.
861
+ * @param signal - transport cancellation.
862
+ * @returns the diff text.
863
+ */
864
+ async function readUntrackedDiff(rawCwd, file, signal) {
865
+ try {
866
+ const out = await runGit(rawCwd, ["diff", "--no-index", "--no-ext-diff", "--no-color", "--", "/dev/null", file], signal, GIT_TIMEOUT_MS, undefined, [1]);
867
+ return out.stdout;
868
+ } catch (error) {
869
+ const text = String(error.stdout ?? "");
870
+ if (text.includes("diff --git")) return text;
871
+ throw error;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * `diff` endpoint: one changed path's unified diff, split by which side of the
877
+ * index it lives on.
878
+ *
879
+ * Both sides are read in one round trip because the viewer shows them as two
880
+ * tabs of the same page (the panel answers "what will the commit record?" from
881
+ * the index side and "what is not staged yet?" from the worktree side). A path
882
+ * that is untracked on both sides falls back to `--no-index` against the empty
883
+ * blob; an untracked *directory* is expanded file by file, capped, with the
884
+ * remainder counted so the viewer can say so instead of showing a partial tree
885
+ * silently.
886
+ *
887
+ * Every command here is read-only: this endpoint never writes the index, the
888
+ * working tree, or any git configuration.
889
+ * @param rawCwd - session workspace directory.
890
+ * @param rawPath - repository-relative path (from the status list).
891
+ * @param rawOrigPath - the pre-rename path when the status record carries one.
892
+ * @param signal - transport cancellation.
893
+ */
894
+ async function gitFileDiff(rawCwd, rawPath, rawOrigPath, signal) {
895
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
896
+ const file = normalizedPath(rawPath);
897
+ if (file === null) return fail("invalid-path", "a repository-relative path is required");
898
+ let orig = null;
899
+ if (rawOrigPath !== undefined && rawOrigPath !== null) {
900
+ orig = normalizedPath(rawOrigPath);
901
+ if (orig === null) return fail("invalid-path", "a repository-relative path is required");
902
+ }
903
+ // A rename is only pairable when both of its names are in the pathspec:
904
+ // naming the new path alone makes git report the whole file as an addition.
905
+ const paths = orig !== null && orig !== file ? [orig, file] : [file];
906
+ try {
907
+ let worktreeText = await readPathDiff(rawCwd, paths, false, signal);
908
+ let indexText = await readPathDiff(rawCwd, paths, true, signal);
909
+ let untracked = false;
910
+ let skipped = 0;
911
+ if (orig === null && worktreeText.trim() === "" && indexText.trim() === "") {
912
+ // Nothing on either side of the index. Either the path is untracked —
913
+ // git has no base to diff against — or it is genuinely unchanged; the
914
+ // question is settled by asking git which untracked files it knows.
915
+ const files = await untrackedFiles(rawCwd, file, signal);
916
+ if (files.length > 0) {
917
+ untracked = true;
918
+ const expanded = files.slice(0, DIFF_MAX_UNTRACKED_FILES);
919
+ skipped = files.length - expanded.length;
920
+ const parts = [];
921
+ for (const one of expanded) parts.push(await readUntrackedDiff(rawCwd, one, signal));
922
+ worktreeText = parts.filter((text) => text !== "").join("\n");
923
+ }
924
+ }
925
+ const worktree = clampDiffText(worktreeText);
926
+ const index = clampDiffText(indexText);
927
+ return ok({
928
+ repo: true,
929
+ path: file,
930
+ origPath: orig,
931
+ untracked,
932
+ skipped,
933
+ worktree: { diff: worktree.diff, binary: isBinaryDiff(worktree.diff), truncated: worktree.truncated },
934
+ index: { diff: index.diff, binary: isBinaryDiff(index.diff), truncated: index.truncated }
935
+ });
936
+ } catch (error) {
937
+ if (isNotARepo(error)) return ok({ repo: false });
938
+ return gitError(error);
939
+ }
940
+ }
941
+
942
+
943
+ /** Bound one model-facing text block, marking the truncation. */
944
+ function clampForModel(text, max) {
945
+ if (text.length <= max) return text;
946
+ return `${text.slice(0, max)}\n… (truncated at ${max} characters)`;
947
+ }
948
+
949
+ /**
950
+ * Read the change set one generation mode describes.
951
+ * @param rawCwd - session workspace directory.
952
+ * @param mode - `staged`, `unstaged`, or `all`.
953
+ * @param signal - transport cancellation.
954
+ * @returns `{ stat, diff }` with `repo` false outside a work tree, or an RPC failure.
955
+ */
956
+ async function readChangesForMode(rawCwd, mode, signal) {
957
+ if (mode === "all") {
958
+ // Combining the two sides is exact and works in a repository with no
959
+ // commits yet, where `git diff HEAD` has no HEAD to name.
960
+ const [staged, unstaged] = await Promise.all([
961
+ readDiff(rawCwd, true, signal),
962
+ readDiff(rawCwd, false, signal)
963
+ ]);
964
+ if (staged.ok !== true) return staged;
965
+ if (unstaged.ok !== true) return unstaged;
966
+ return ok({
967
+ repo: staged.value.repo === true || unstaged.value.repo === true,
968
+ stat: [staged.value.stat, unstaged.value.stat].filter(Boolean).join("\n"),
969
+ diff: [staged.value.diff, unstaged.value.diff].filter(Boolean).join("\n\n")
970
+ });
971
+ }
972
+ return readDiff(rawCwd, mode === "staged", signal);
973
+ }
974
+
975
+ /**
976
+ * Frame one commit-message request. Kept language-neutral: the model is told to
977
+ * match the codebase rather than to prefer either of this panel's locales.
978
+ * @param stat - diffstat text (may be empty).
979
+ * @param diff - unified diff text.
980
+ */
981
+ function generationPrompt(stat, diff) {
982
+ const system = [
983
+ "You write one git commit message for the change set below.",
984
+ "Rules:",
985
+ "- Reply with the commit message only: no preamble, no quotes, no Markdown fences.",
986
+ "- One imperative subject line under 72 characters; add a short body only when the change needs it.",
987
+ "- Describe what changed and why, not how.",
988
+ "- Write in the language already used by the codebase's comments and existing commit subjects."
989
+ ].join("\n");
990
+ const body = [
991
+ "## Diffstat",
992
+ stat !== "" ? stat : "(unavailable)",
993
+ "",
994
+ "## Diff",
995
+ diff !== "" ? diff : "(empty)"
996
+ ].join("\n");
997
+ return { system, body };
998
+ }
999
+
1000
+ /**
1001
+ * Resolve the provider/model route for one generation call.
1002
+ * The caller names its session's route; anything unconfirmed is checked against
1003
+ * the adapters' advertised catalog before use.
1004
+ * @param ctx - context exposing the `llm` service.
1005
+ * @param rawProvider - caller-supplied provider route (optional).
1006
+ * @param rawModel - caller-supplied model id (optional).
1007
+ * @returns `{ provider, model }`.
1008
+ * @throws when no usable route exists.
1009
+ */
1010
+ async function resolveLlmRoute(ctx, rawProvider, rawModel) {
1011
+ const providers = ctx.llm.listProviders();
1012
+ if (!Array.isArray(providers) || providers.length === 0) {
1013
+ throw Object.assign(new Error("no LLM provider is configured; add one in Settings > Models"), { pluginCode: "no-provider" });
1014
+ }
1015
+ const named = typeof rawProvider === "string" && rawProvider !== "" ? providers.find((entry) => entry.id === rawProvider) : undefined;
1016
+ const provider = (named ?? providers[0]).id;
1017
+ let models = [];
1018
+ try {
1019
+ models = await ctx.llm.listModels(provider);
1020
+ } catch {
1021
+ // An adapter that cannot enumerate may still serve an explicitly named model.
1022
+ models = [];
1023
+ }
1024
+ const advertised = Array.isArray(models) ? models : [];
1025
+ const wantsNamed = typeof rawModel === "string" && rawModel !== "" && provider === rawProvider;
1026
+ let model;
1027
+ if (wantsNamed && (advertised.length === 0 || advertised.some((entry) => entry.id === rawModel))) model = rawModel;
1028
+ else if (advertised.length > 0) model = advertised[0].id;
1029
+ else if (typeof rawModel === "string" && rawModel !== "") model = rawModel;
1030
+ if (model === undefined) {
1031
+ throw Object.assign(new Error(`provider ${JSON.stringify(provider)} advertises no model to use`), { pluginCode: "no-model" });
1032
+ }
1033
+ return { provider, model };
1034
+ }
1035
+
1036
+ /**
1037
+ * Build the one-shot user message for a generation call.
1038
+ * Hand-built rather than imported from `@deepseek-ai/dsh-llm`: this package
1039
+ * deliberately declares no `@deepseek-ai/*` runtime imports, because a plugin
1040
+ * installed with pnpm `link:` resolves them from its own real source path,
1041
+ * where no host tree exists. The shape must stay exactly this: a fresh id, the
1042
+ * `user` role, one text block, and a plugin source.
1043
+ * @param text - model-facing prompt body.
1044
+ */
1045
+ function generationMessage(text) {
1046
+ return Object.freeze({
1047
+ id: randomUUID(),
1048
+ role: "user",
1049
+ content: Object.freeze([Object.freeze({ type: "text", text })]),
1050
+ source: Object.freeze({ kind: "plugin", plugin: name })
1051
+ });
1052
+ }
1053
+
1054
+ /**
1055
+ * Generate a commit message for a change set through the shared LLM service.
1056
+ * @param ctx - context exposing the `llm` service.
1057
+ * @param prompt - `{ system, body }` from {@link generationPrompt}.
1058
+ * @param route - `{ provider, model }` from {@link resolveLlmRoute}.
1059
+ * @param signal - cancellation (browser abort plus the server ceiling).
1060
+ * @returns the trimmed message.
1061
+ * @throws on a terminal stream failure, an output cap hit before any text
1062
+ * existed (`llm-truncated`), or genuinely empty output (`llm-empty`).
1063
+ */
1064
+ async function requestCommitMessage(ctx, prompt, route, signal) {
1065
+ const options = {
1066
+ provider: route.provider,
1067
+ model: route.model,
1068
+ messages: [generationMessage(prompt.body)],
1069
+ system: prompt.system,
1070
+ maxTokens: LLM_MAX_TOKENS,
1071
+ signal
1072
+ };
1073
+ // Final text comes from the assembled blocks; the delta map is a fallback for
1074
+ // an adapter that emits text without a closing `block-end`.
1075
+ const blocks = new Map();
1076
+ const deltas = new Map();
1077
+ let finish;
1078
+ for await (const chunk of ctx.llm.stream(options)) {
1079
+ if (chunk.type === "text-delta") deltas.set(chunk.index, `${deltas.get(chunk.index) ?? ""}${chunk.text}`);
1080
+ else if (chunk.type === "block-end" && chunk.block.type === "text") blocks.set(chunk.index, chunk.block.text);
1081
+ else if (chunk.type === "finish") finish = chunk.reason;
1082
+ }
1083
+ if (finish !== undefined && (finish.kind === "error" || finish.kind === "aborted")) {
1084
+ throw Object.assign(new Error(finish.failure.message), { pluginCode: finish.kind === "aborted" ? "cancelled" : "llm-failed" });
1085
+ }
1086
+ const ordered = (map) => [...map.entries()].sort((left, right) => left[0] - right[0]).map((entry) => entry[1]);
1087
+ const message = (blocks.size > 0 ? ordered(blocks) : ordered(deltas)).join("").trim();
1088
+ if (message === "") {
1089
+ // "Nothing came back" and "the budget ran out before any text did" are
1090
+ // different failures with different fixes; a reasoning model hits the
1091
+ // second one routinely (its thinking shares `completion_tokens` with the
1092
+ // message), so report the cap instead of a misleading generic empty.
1093
+ if (finish !== undefined && finish.kind === "max-tokens") {
1094
+ throw Object.assign(
1095
+ new Error(`the model hit the ${LLM_MAX_TOKENS}-token output cap before writing any commit message (a reasoning model spends that budget on its thinking first)`),
1096
+ { pluginCode: "llm-truncated" }
1097
+ );
1098
+ }
1099
+ throw Object.assign(new Error("the model returned no commit message"), { pluginCode: "llm-empty" });
1100
+ }
1101
+ return message;
1102
+ }
1103
+
1104
+ /**
1105
+ * `generateMessage` endpoint: draft a commit message from the working tree.
1106
+ * @param ctx - context exposing the `llm` service.
1107
+ * @param rawCwd - session workspace directory.
1108
+ * @param rawMode - `staged`, `unstaged`, or `all`.
1109
+ * @param rawProvider - caller's provider route (optional).
1110
+ * @param rawModel - caller's model id (optional).
1111
+ * @param signal - transport cancellation.
1112
+ */
1113
+ async function gitGenerateMessage(ctx, rawCwd, rawMode, rawProvider, rawModel, signal) {
1114
+ if (!validCwd(rawCwd)) return fail("invalid-cwd", "a valid absolute working directory is required");
1115
+ if (rawMode !== undefined && rawMode !== null && !GENERATE_MODES.includes(rawMode)) {
1116
+ return fail("invalid-mode", `mode must be one of ${GENERATE_MODES.join(", ")}`);
1117
+ }
1118
+ const mode = rawMode ?? GENERATE_MODE_DEFAULT;
1119
+ const changes = await readChangesForMode(rawCwd, mode, signal);
1120
+ if (changes.ok !== true) return changes;
1121
+ if (changes.value.repo !== true) return fail("not-a-repo", "not a git repository", {});
1122
+ if (changes.value.diff === "") {
1123
+ return fail("no-changes", generationEmptyHint(mode), { mode });
1124
+ }
1125
+ let route;
1126
+ try {
1127
+ route = await resolveLlmRoute(ctx, rawProvider, rawModel);
1128
+ } catch (error) {
1129
+ return fail(error.pluginCode ?? "llm-failed", error.message, { mode });
1130
+ }
1131
+ const prompt = generationPrompt(
1132
+ clampForModel(changes.value.stat, LLM_STAT_MAX_CHARS),
1133
+ clampForModel(changes.value.diff, LLM_DIFF_MAX_CHARS)
1134
+ );
1135
+ // Bound the call even if the browser goes away without hanging up; the
1136
+ // turn's own signal still cancels immediately.
1137
+ const timeout = AbortSignal.timeout(LLM_TIMEOUT_MS);
1138
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
1139
+ if (combined.aborted) return fail("cancelled", "generation was cancelled", { mode });
1140
+ try {
1141
+ const message = await requestCommitMessage(ctx, prompt, route, combined);
1142
+ return ok({ message, mode, provider: route.provider, model: route.model });
1143
+ } catch (error) {
1144
+ return fail(error.pluginCode ?? "llm-failed", error instanceof Error ? error.message : String(error), { mode });
1145
+ }
1146
+ }
1147
+
1148
+ /** The actionable, mode-aware wording for an empty change set. */
1149
+ function generationEmptyHint(mode) {
1150
+ if (mode === "staged") return "no staged changes; stage them first (or generate from unstaged/all changes)";
1151
+ if (mode === "unstaged") return "no unstaged changes";
1152
+ return "no changes in the working tree";
1153
+ }
1154
+
1155
+ /**
1156
+ * Dispatch one endpoint call.
1157
+ * @param ctx - plugin context exposing `llm` (declared above).
1158
+ * @param endpoint - endpoint name (already path-validated).
1159
+ * @param payload - the client-request payload (`{ args }`).
1160
+ * @param signal - abort signal cancelling the git child or the LLM stream.
1161
+ */
1162
+ async function dispatch(ctx, endpoint, payload, signal) {
1163
+ const args = payload !== null && typeof payload === "object" && payload.args !== null && typeof payload.args === "object"
1164
+ ? payload.args
1165
+ : {};
1166
+ switch (endpoint) {
1167
+ case "status":
1168
+ return gitStatus(args.cwd, signal);
1169
+ case "branches":
1170
+ return gitBranches(args.cwd, signal);
1171
+ case "checkout":
1172
+ return gitCheckout(args.cwd, args.branch, signal);
1173
+ case "createBranch":
1174
+ return gitCreateBranch(args.cwd, args.branch, args.base, signal);
1175
+ case "fetch":
1176
+ return gitFetch(args.cwd, args.remote, signal);
1177
+ case "pull":
1178
+ return gitPull(args.cwd, signal);
1179
+ case "stage":
1180
+ return gitStage(args.cwd, signal);
1181
+ case "diff":
1182
+ return gitFileDiff(args.cwd, args.path, args.origPath, signal);
1183
+ case "commit":
1184
+ return gitCommit(args.cwd, args.message, signal);
1185
+ case "push":
1186
+ return gitPush(args.cwd, signal);
1187
+ case "log":
1188
+ return gitLog(args.cwd, args.count, signal);
1189
+ case "generateMessage":
1190
+ return gitGenerateMessage(ctx, args.cwd, args.mode, args.provider, args.model, signal);
1191
+ default:
1192
+ return fail("unknown-endpoint", `unknown git endpoint ${JSON.stringify(endpoint)}`);
1193
+ }
1194
+ }
1195
+
1196
+ /**
1197
+ * Plugin body: mount the `/dsh-git-rpc` channel on the web server and speak the
1198
+ * Connection RPC wire protocol (see the file header for why the route is
1199
+ * self-owned on dsh >= 0.1.5-rc.1).
1200
+ * @param ctx - plugin context with `webServer`, `connection`, and `llm` (declared above).
1201
+ */
1202
+ function apply(ctx) {
1203
+ // The channel runs git against caller-supplied absolute paths, so it must
1204
+ // never mount unfenced: the connection service's Host/Origin + browser-cookie
1205
+ // check is the only gate. `requestRejection` is the >= 0.1.5-rc.1 form of that
1206
+ // check; an older host without it fails loudly instead of serving an open
1207
+ // channel.
1208
+ const connection = ctx.get("connection");
1209
+ if (connection === undefined || typeof connection.requestRejection !== "function") {
1210
+ throw new Error(`${name}: this plugin requires dsh >= 0.1.5-rc.1 (connection.requestRejection is unavailable, so /dsh-git-rpc could not be fenced)`);
1211
+ }
1212
+ ctx.effect(() => ctx.webServer.register({
1213
+ kind: "prefix",
1214
+ path: RPC_CHANNEL,
1215
+ handler: async (req, res) => {
1216
+ const pathname = String(req.url ?? "/").split("?")[0];
1217
+ const endpoint = endpointFromPath(pathname);
1218
+ if (endpoint === undefined) {
1219
+ sendEnvelope(res, 404, rpcError("invalid-request", "not-found", "not found", {}));
1220
+ return;
1221
+ }
1222
+ // The connection service owns the Host/Origin fence and the browser
1223
+ // session cookie; a plugin channel must not bypass it.
1224
+ const rejection = connection.requestRejection(req);
1225
+ if (rejection !== undefined) {
1226
+ res.statusCode = rejection;
1227
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
1228
+ return;
1229
+ }
1230
+ if (req.method !== "POST") {
1231
+ res.statusCode = 405;
1232
+ res.setHeader("allow", "POST");
1233
+ res.end();
1234
+ return;
1235
+ }
1236
+ const contentType = String(req.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
1237
+ if (contentType !== "application/json") {
1238
+ sendEnvelope(res, 415, rpcError("invalid-request", "gateway/bad-request", "content type must be application/json", {}));
1239
+ return;
1240
+ }
1241
+ let text;
1242
+ try {
1243
+ text = await readBoundedBody(req, RPC_MAX_BODY_BYTES);
1244
+ } catch {
1245
+ sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "body read failed", {}));
1246
+ return;
1247
+ }
1248
+ if (text === undefined) {
1249
+ sendEnvelope(res, 413, rpcError("invalid-request", "gateway/bad-request", "request body too large or unreadable", {}));
1250
+ return;
1251
+ }
1252
+ let message;
1253
+ try {
1254
+ message = JSON.parse(text);
1255
+ } catch {
1256
+ sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "body is not JSON", {}));
1257
+ return;
1258
+ }
1259
+ // Same outer-envelope contract as client-connection clientRequestSchema.
1260
+ if (message === null || typeof message !== "object" || Array.isArray(message) || message.type !== "client-request"
1261
+ || typeof message.rpcId !== "string" || typeof message.method !== "string" || !("payload" in message)) {
1262
+ sendEnvelope(res, 400, rpcError("invalid-request", "gateway/bad-request", "invalid client-request message", {}));
1263
+ return;
1264
+ }
1265
+ if (message.method !== endpoint) {
1266
+ sendEnvelope(res, 200, rpcError(message.rpcId, "gateway/bad-request",
1267
+ `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, {}));
1268
+ return;
1269
+ }
1270
+ // Cancel the git child when the browser drops the request.
1271
+ const controller = new AbortController();
1272
+ const abort = () => { controller.abort(); };
1273
+ req.on("aborted", abort);
1274
+ res.on("close", () => { if (res.writableEnded !== true) abort(); });
1275
+ let result;
1276
+ try {
1277
+ result = await dispatch(ctx, endpoint, message.payload, controller.signal);
1278
+ } catch (error) {
1279
+ sendEnvelope(res, 200, rpcFull(message.rpcId, fail("internal-error", error instanceof Error ? error.message : String(error))));
1280
+ return;
1281
+ }
1282
+ sendEnvelope(res, 200, rpcFull(message.rpcId, result));
1283
+ }
1284
+ }), `dsh-git: POST ${RPC_CHANNEL}/*`);
1285
+ }
1286
+
1287
+ // `apply`/`inject`/`name` are the Cordis plugin face. The remaining exports are
1288
+ // the units worth testing directly: the generation units (a route path reads a
1289
+ // diff through git first, so `generateMessage` needs a real repository to reach
1290
+ // them) and the commit-message normalizer (pure, and the exact place the
1291
+ // subject+body regression lived).
1292
+ export { apply, clampForModel, generationPrompt, inject, name, normalizeCommitMessage, requestCommitMessage, resolveLlmRoute };