@xia-sc/dsh-git 0.5.1 → 0.5.2
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/README.en.md +284 -284
- package/README.md +225 -225
- package/cordis.patch.yml +15 -15
- package/lib/client.js +2286 -2286
- package/lib/index.js +1272 -1272
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,1272 +1,1272 @@
|
|
|
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
|
+
/** 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 };
|