@klarkxy/dsh-git-commit 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,846 @@
1
+ import { DEFAULT_MAX_OUTPUT_TOKENS, MAX_MANIFEST_CHARS, MAX_UNTRACKED_PREVIEW_CHARS, PLUGIN_NAME, PROMPT_VERSION, PURPOSE_ID, RPC_CHANNEL, SCHEMA_VERSION } from "./contracts.js";
2
+ import { registerHostRpc } from "@klarkxy/dsh-ai-services/host-rpc";
3
+ import { execFile } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import { constants } from "node:fs";
6
+ import { copyFile, lstat, mkdtemp, open, readFile, readlink, realpath, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { isAbsolute, join, relative, resolve } from "node:path";
9
+ //#region src/git.ts
10
+ var GitError = class extends Error {
11
+ stderr;
12
+ constructor(message, stderr = "") {
13
+ super(message);
14
+ this.name = "GitError";
15
+ this.stderr = stderr;
16
+ }
17
+ };
18
+ /** NUL separator used by `git status -z` without embedding a control byte in this source file. */
19
+ const NUL = String.fromCharCode(0);
20
+ /** Spawn git without a shell; arguments are passed literally. */
21
+ const execGit = (args, options) => new Promise((resolve, reject) => {
22
+ try {
23
+ execFile("git", [...args], {
24
+ cwd: options.cwd,
25
+ encoding: "utf8",
26
+ maxBuffer: 33554432,
27
+ timeout: options.timeoutMs ?? 3e4,
28
+ signal: options.signal,
29
+ windowsHide: true,
30
+ env: {
31
+ ...process.env,
32
+ GIT_TERMINAL_PROMPT: "0",
33
+ GIT_LITERAL_PATHSPECS: "1"
34
+ }
35
+ }, (error, stdout, stderr) => {
36
+ if (error) {
37
+ if (options.signal?.aborted) {
38
+ reject(Object.assign(/* @__PURE__ */ new Error("dsh-git-commit: git command cancelled"), { code: "cancelled" }));
39
+ return;
40
+ }
41
+ reject(Object.assign(new GitError(stderr.trim() || error.message, stderr), { code: error.code }));
42
+ return;
43
+ }
44
+ resolve(stdout);
45
+ });
46
+ } catch (error) {
47
+ reject(error);
48
+ }
49
+ });
50
+ function isGitMissing(error) {
51
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
52
+ }
53
+ function statusLetter(kind) {
54
+ switch (kind) {
55
+ case "added": return "A";
56
+ case "deleted": return "D";
57
+ case "renamed": return "R";
58
+ case "copied": return "C";
59
+ case "untracked": return "?";
60
+ default: return "M";
61
+ }
62
+ }
63
+ /**
64
+ * Parse `git status --porcelain=v1 -z --untracked-files=all` output.
65
+ * In -z mode rename/copy entries put the destination path first, then a NUL, then the origin path.
66
+ */
67
+ function parsePorcelain(output) {
68
+ const tokens = output.split(NUL);
69
+ const files = [];
70
+ let index = 0;
71
+ while (index < tokens.length) {
72
+ const token = tokens[index];
73
+ index += 1;
74
+ if (!token) continue;
75
+ if (token.length < 4) continue;
76
+ const x = token[0] ?? " ";
77
+ const y = token[1] ?? " ";
78
+ const path = token.slice(3);
79
+ if (x === "!" && y === "!") continue;
80
+ if (x === "?" && y === "?") {
81
+ files.push({
82
+ path,
83
+ status: "untracked",
84
+ staged: false
85
+ });
86
+ continue;
87
+ }
88
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
89
+ const origin = tokens[index];
90
+ index += 1;
91
+ if (!origin) throw new GitError("dsh-git-commit: incomplete rename status");
92
+ files.push({
93
+ path,
94
+ origin,
95
+ status: x === "C" || y === "C" ? "copied" : "renamed",
96
+ staged: x !== " " && x !== "?"
97
+ });
98
+ continue;
99
+ }
100
+ if (x === "U" || y === "U" || x === "A" && y === "A" || x === "D" && y === "D") throw new GitError(`dsh-git-commit: resolve merge conflicts before committing (${path})`);
101
+ const status = x === "D" || y === "D" ? "deleted" : x === "A" || y === "A" ? "added" : "modified";
102
+ files.push({
103
+ path,
104
+ status,
105
+ staged: x !== " " && x !== "?"
106
+ });
107
+ }
108
+ return files;
109
+ }
110
+ async function repoInfo(run, cwd, signal) {
111
+ const root = (await run(["rev-parse", "--show-toplevel"], {
112
+ cwd,
113
+ signal
114
+ })).trim();
115
+ let branch = (await run([
116
+ "symbolic-ref",
117
+ "--quiet",
118
+ "--short",
119
+ "HEAD"
120
+ ], {
121
+ cwd: root,
122
+ signal
123
+ }).catch(() => "")).trim();
124
+ if (!branch) branch = (await run([
125
+ "rev-parse",
126
+ "--short",
127
+ "HEAD"
128
+ ], {
129
+ cwd: root,
130
+ signal
131
+ }).catch(() => "")).trim();
132
+ return {
133
+ root,
134
+ branch: branch || "HEAD"
135
+ };
136
+ }
137
+ async function workingTreeChanges(run, root, signal) {
138
+ return parsePorcelain(await run([
139
+ "status",
140
+ "--porcelain=v1",
141
+ "-z",
142
+ "--untracked-files=all"
143
+ ], {
144
+ cwd: root,
145
+ signal
146
+ }));
147
+ }
148
+ /** Exact index/worktree content fingerprint, including untracked file bytes. */
149
+ async function workspaceSnapshot(run, root, signal) {
150
+ const status = await run([
151
+ "status",
152
+ "--porcelain=v1",
153
+ "-z",
154
+ "--untracked-files=all"
155
+ ], {
156
+ cwd: root,
157
+ signal
158
+ });
159
+ const files = parsePorcelain(status);
160
+ const hash = createHash("sha256").update(status);
161
+ hash.update(await run([
162
+ "symbolic-ref",
163
+ "--quiet",
164
+ "HEAD"
165
+ ], {
166
+ cwd: root,
167
+ signal
168
+ }).catch(() => "DETACHED"));
169
+ hash.update(await run(["rev-parse", "HEAD"], {
170
+ cwd: root,
171
+ signal
172
+ }).catch(() => "unborn"));
173
+ hash.update(await run([
174
+ "diff",
175
+ "--cached",
176
+ "--binary",
177
+ "--no-ext-diff"
178
+ ], {
179
+ cwd: root,
180
+ signal
181
+ }));
182
+ hash.update(await run([
183
+ "diff",
184
+ "--binary",
185
+ "--no-ext-diff"
186
+ ], {
187
+ cwd: root,
188
+ signal
189
+ }));
190
+ for (const file of files) {
191
+ if (file.status !== "untracked") continue;
192
+ hash.update(file.path);
193
+ hash.update(await worktreePathDigest(run, root, file.path, signal));
194
+ }
195
+ return {
196
+ files,
197
+ fingerprint: hash.digest("hex")
198
+ };
199
+ }
200
+ /** A worktree content check for groups waiting behind earlier commits. */
201
+ async function pathVersions(run, root, paths, signal) {
202
+ const hash = createHash("sha256");
203
+ for (const path of paths) {
204
+ hash.update(path);
205
+ hash.update(await worktreePathDigest(run, root, path, signal));
206
+ }
207
+ return hash.digest("hex");
208
+ }
209
+ async function worktreePathDigest(run, root, path, signal) {
210
+ const absolute = resolve(root, path);
211
+ let stat;
212
+ try {
213
+ stat = await lstat(absolute);
214
+ } catch (error) {
215
+ if (error.code === "ENOENT") return "missing";
216
+ throw error;
217
+ }
218
+ const hash = createHash("sha256").update(String(stat.mode));
219
+ if (stat.isSymbolicLink()) hash.update(await readlink(absolute));
220
+ else if (stat.isFile()) {
221
+ const handle = await open(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
222
+ try {
223
+ if (!(await handle.stat()).isFile() || !(await lstat(absolute)).isFile()) throw new GitError(`dsh-git-commit: file changed while reading (${path})`);
224
+ for await (const chunk of handle.createReadStream({ autoClose: false })) {
225
+ signal?.throwIfAborted();
226
+ hash.update(chunk);
227
+ }
228
+ if (!(await lstat(absolute)).isFile()) throw new GitError(`dsh-git-commit: file changed while reading (${path})`);
229
+ } finally {
230
+ await handle.close();
231
+ }
232
+ } else if (stat.isDirectory()) {
233
+ hash.update(await run([
234
+ "-C",
235
+ absolute,
236
+ "rev-parse",
237
+ "HEAD"
238
+ ], {
239
+ cwd: root,
240
+ signal
241
+ }));
242
+ hash.update(await run([
243
+ "-C",
244
+ absolute,
245
+ "status",
246
+ "--porcelain=v1",
247
+ "-z"
248
+ ], {
249
+ cwd: root,
250
+ signal
251
+ }));
252
+ }
253
+ return hash.digest("hex");
254
+ }
255
+ async function recentSubjects(run, root, count, signal) {
256
+ return (await run([
257
+ "log",
258
+ `-${count}`,
259
+ "--pretty=%s"
260
+ ], {
261
+ cwd: root,
262
+ signal
263
+ }).catch(() => "")).split("\n").map((line) => line.trim()).filter(Boolean);
264
+ }
265
+ /** Combined staged+worktree stat and diff against HEAD for the given paths. */
266
+ async function diffAgainstHead(run, root, paths, signal) {
267
+ const args = paths.length > 0 ? ["--", ...paths] : [];
268
+ return {
269
+ stat: await run([
270
+ "diff",
271
+ "HEAD",
272
+ "--stat",
273
+ "--no-color",
274
+ ...args
275
+ ], {
276
+ cwd: root,
277
+ signal
278
+ }).catch(() => ""),
279
+ diff: await run([
280
+ "diff",
281
+ "HEAD",
282
+ "--no-color",
283
+ "--no-ext-diff",
284
+ ...args
285
+ ], {
286
+ cwd: root,
287
+ signal
288
+ }).catch(() => "")
289
+ };
290
+ }
291
+ async function untrackedPreview(root, path) {
292
+ try {
293
+ const absolute = resolve(root, path);
294
+ const rootReal = await realpath(root);
295
+ const withinRoot = async () => {
296
+ const target = await realpath(absolute);
297
+ const rel = relative(rootReal, target);
298
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${String.fromCharCode(47)}`) && !rel.startsWith(`..${String.fromCharCode(92)}`) && !isAbsolute(rel);
299
+ };
300
+ if (!await withinRoot()) return {
301
+ path,
302
+ preview: "",
303
+ binary: true
304
+ };
305
+ if (!(await lstat(absolute)).isFile()) return {
306
+ path,
307
+ preview: "",
308
+ binary: true
309
+ };
310
+ const handle = await open(absolute, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
311
+ let buffer;
312
+ try {
313
+ const bounded = Buffer.alloc(MAX_UNTRACKED_PREVIEW_CHARS * 4);
314
+ const { bytesRead } = await handle.read(bounded, 0, bounded.length, 0);
315
+ buffer = bounded.subarray(0, bytesRead);
316
+ } finally {
317
+ await handle.close();
318
+ }
319
+ if (!await withinRoot() || !(await lstat(absolute)).isFile()) return {
320
+ path,
321
+ preview: "",
322
+ binary: true
323
+ };
324
+ if (buffer.includes(0)) return {
325
+ path,
326
+ preview: "",
327
+ binary: true
328
+ };
329
+ return {
330
+ path,
331
+ preview: buffer.toString("utf8").slice(0, MAX_UNTRACKED_PREVIEW_CHARS),
332
+ binary: false
333
+ };
334
+ } catch {
335
+ return {
336
+ path,
337
+ preview: "",
338
+ binary: true
339
+ };
340
+ }
341
+ }
342
+ async function indexBytes(path) {
343
+ try {
344
+ return await readFile(path);
345
+ } catch (error) {
346
+ if (error.code === "ENOENT") return void 0;
347
+ throw error;
348
+ }
349
+ }
350
+ async function headOid(run, root) {
351
+ try {
352
+ return (await run([
353
+ "rev-parse",
354
+ "--verify",
355
+ "HEAD"
356
+ ], { cwd: root })).trim();
357
+ } catch (error) {
358
+ if (error instanceof GitError && /Needed a single revision|not a valid object name HEAD/u.test(error.stderr)) return "";
359
+ throw error;
360
+ }
361
+ }
362
+ /**
363
+ * Commit each group from the worktree. `--only` keeps other staged paths out of
364
+ * this commit; an exact index backup protects the current group on failure.
365
+ */
366
+ async function commitGroups(run, root, groups, selected, signal, verify) {
367
+ const commits = [];
368
+ for (const group of groups) {
369
+ if (group.files.length === 0) continue;
370
+ const paths = group.files.flatMap((path) => {
371
+ const file = selected.find((file) => file.path === path);
372
+ return file?.status === "renamed" && file.origin ? [path, file.origin] : [path];
373
+ });
374
+ await verify?.(paths);
375
+ const gitPath = (await run([
376
+ "rev-parse",
377
+ "--git-path",
378
+ "index"
379
+ ], {
380
+ cwd: root,
381
+ signal
382
+ })).trim();
383
+ const indexPath = isAbsolute(gitPath) ? gitPath : resolve(root, gitPath);
384
+ const backupDir = await mkdtemp(join(tmpdir(), "dsh-git-commit-"));
385
+ const backupPath = join(backupDir, "index");
386
+ let hadIndex = true;
387
+ try {
388
+ try {
389
+ await copyFile(indexPath, backupPath);
390
+ } catch (error) {
391
+ if (error.code !== "ENOENT") throw error;
392
+ hadIndex = false;
393
+ }
394
+ const head = await headOid(run, root);
395
+ const originalIndex = await indexBytes(indexPath);
396
+ if (hadIndex ? !originalIndex?.equals(await readFile(backupPath)) : originalIndex !== void 0) throw new GitError("dsh-git-commit: index changed before committing; retry");
397
+ let stagedIndex = originalIndex;
398
+ try {
399
+ await run([
400
+ "add",
401
+ "-A",
402
+ "--",
403
+ ...group.files
404
+ ], {
405
+ cwd: root,
406
+ signal
407
+ });
408
+ stagedIndex = await indexBytes(indexPath);
409
+ await run([
410
+ "commit",
411
+ "--quiet",
412
+ "--only",
413
+ "-m",
414
+ group.message,
415
+ "--",
416
+ ...paths
417
+ ], {
418
+ cwd: root,
419
+ signal
420
+ });
421
+ const hash = (await run([
422
+ "rev-parse",
423
+ "--short",
424
+ "HEAD"
425
+ ], {
426
+ cwd: root,
427
+ signal
428
+ })).trim();
429
+ commits.push({
430
+ hash,
431
+ skipped: false
432
+ });
433
+ } catch (error) {
434
+ const currentHead = await headOid(run, root);
435
+ const currentIndex = await indexBytes(indexPath);
436
+ const indexUntouched = currentIndex === void 0 ? stagedIndex === void 0 : currentIndex.equals(stagedIndex ?? Buffer.alloc(0));
437
+ if (currentHead === head && indexUntouched) {
438
+ if (hadIndex) await copyFile(backupPath, indexPath);
439
+ else await rm(indexPath, { force: true });
440
+ }
441
+ throw error;
442
+ }
443
+ } finally {
444
+ await rm(backupDir, {
445
+ recursive: true,
446
+ force: true
447
+ });
448
+ }
449
+ }
450
+ return commits;
451
+ }
452
+ /** One deterministic commit for every selected path; used when the model plan is unavailable. */
453
+ function fallbackGroup(files, localeHint) {
454
+ return {
455
+ message: localeHint === "zh" ? `chore: 提交 ${files.length} 个文件的修改` : `chore: commit changes to ${files.length} file${files.length === 1 ? "" : "s"}`,
456
+ files: files.map((file) => file.path)
457
+ };
458
+ }
459
+ //#endregion
460
+ //#region src/plan.ts
461
+ function planSystemPrompt() {
462
+ return [
463
+ "You split a git working-tree change set into a small number of coherent commits.",
464
+ "Return exactly one JSON object: {\"groups\":[{\"message\":\"<commit message>\",\"files\":[\"<path>\",...]}]}.",
465
+ "Rules:",
466
+ "- Every listed file must appear in exactly one group; do not invent paths.",
467
+ "- One to five groups. Group files that belong to the same logical change.",
468
+ "- Order groups so earlier commits do not depend on later ones.",
469
+ "- Each message is a single line of at most 72 characters, no trailing period.",
470
+ "- Match the language and style of the recent commit subjects when provided; otherwise use English conventional-commit style.",
471
+ "- Return no Markdown, code fence, or explanation."
472
+ ].join("\n");
473
+ }
474
+ function truncateMiddle(text, maxChars) {
475
+ if (text.length <= maxChars) return text;
476
+ const half = Math.floor((maxChars - 40) / 2);
477
+ return `${text.slice(0, half)}\n… [${text.length - maxChars} chars omitted] …\n${text.slice(-half)}`;
478
+ }
479
+ function buildPlanInput(input) {
480
+ const sections = [];
481
+ sections.push("Scope: all current workspace changes of the repository.");
482
+ if (input.recentSubjects.length > 0) sections.push(["Recent commit subjects (style reference):", ...input.recentSubjects.map((s) => `- ${s}`)].join("\n"));
483
+ const manifest = input.files.map((file) => `- ${statusLetter(file.status)} ${file.origin ? `${file.origin} ${file.status === "copied" ? "=>" : "->"} ` : ""}${file.path}`).join("\n");
484
+ sections.push(`Changed files:\n${manifest}`);
485
+ if (input.stat.trim()) sections.push(`Diff stat:\n${input.stat.trim()}`);
486
+ if (input.diff.trim()) sections.push(`Diff against HEAD:\n${input.diff}`);
487
+ for (const untracked of input.untracked) sections.push(untracked.binary ? `New binary file: ${untracked.path}` : `New file ${untracked.path}:\n${untracked.preview}`);
488
+ return truncateMiddle(sections.join("\n\n"), MAX_MANIFEST_CHARS);
489
+ }
490
+ function sanitizeMessage(raw) {
491
+ if (typeof raw !== "string") return void 0;
492
+ const line = raw.replace(/\s+/gu, " ").trim().replace(/\.+$/u, "");
493
+ if (!line) return void 0;
494
+ return line.length > 120 ? line.slice(0, 120).trim() : line;
495
+ }
496
+ /** Extract the first balanced top-level JSON object from model output. */
497
+ function extractJsonObject(text) {
498
+ const candidate = /```(?:json)?\s*([\s\S]*?)```/u.exec(text)?.[1] ?? text;
499
+ const start = candidate.indexOf("{");
500
+ if (start === -1) return void 0;
501
+ let depth = 0;
502
+ let inString = false;
503
+ let escaped = false;
504
+ for (let index = start; index < candidate.length; index += 1) {
505
+ const char = candidate[index];
506
+ if (inString) {
507
+ if (escaped) escaped = false;
508
+ else if (char === "\\") escaped = true;
509
+ else if (char === "\"") inString = false;
510
+ continue;
511
+ }
512
+ if (char === "\"") inString = true;
513
+ else if (char === "{") depth += 1;
514
+ else if (char === "}") {
515
+ depth -= 1;
516
+ if (depth === 0) return candidate.slice(start, index + 1);
517
+ }
518
+ }
519
+ }
520
+ /**
521
+ * Validate the model plan against the exact change set. Returns undefined when
522
+ * the output is malformed, covers the wrong files, or duplicates a file.
523
+ */
524
+ function parsePlan(text, files) {
525
+ const json = extractJsonObject(text);
526
+ if (!json) return void 0;
527
+ let parsed;
528
+ try {
529
+ parsed = JSON.parse(json);
530
+ } catch {
531
+ return;
532
+ }
533
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
534
+ const groups = parsed.groups;
535
+ if (!Array.isArray(groups) || groups.length === 0 || groups.length > 8) return void 0;
536
+ const allowed = new Set(files.map((file) => file.path));
537
+ const seen = /* @__PURE__ */ new Set();
538
+ const plan = [];
539
+ for (const group of groups) {
540
+ if (!group || typeof group !== "object") return void 0;
541
+ const message = sanitizeMessage(group.message);
542
+ const paths = group.files;
543
+ if (!message || !Array.isArray(paths) || paths.length === 0) return void 0;
544
+ const groupFiles = [];
545
+ for (const path of paths) {
546
+ if (typeof path !== "string" || !allowed.has(path) || seen.has(path)) return void 0;
547
+ seen.add(path);
548
+ groupFiles.push(path);
549
+ }
550
+ plan.push({
551
+ message,
552
+ files: groupFiles
553
+ });
554
+ }
555
+ if (seen.size !== allowed.size) return void 0;
556
+ return plan;
557
+ }
558
+ //#endregion
559
+ //#region src/service.ts
560
+ var CommitRunError = class extends Error {
561
+ code;
562
+ constructor(code, message) {
563
+ super(message);
564
+ this.name = "CommitRunError";
565
+ this.code = code;
566
+ }
567
+ };
568
+ function modelInfo(route) {
569
+ if (!route?.provider || !route.model) return void 0;
570
+ return {
571
+ provider: route.provider,
572
+ model: route.model,
573
+ source: route.source
574
+ };
575
+ }
576
+ function looksChinese(subjects) {
577
+ return subjects.some((subject) => /[一-鿿]/u.test(subject));
578
+ }
579
+ function normalizeCwd(cwd) {
580
+ if (typeof cwd !== "string") return void 0;
581
+ const normalized = cwd.trim().replaceAll("\\", "/");
582
+ if (!normalized) return void 0;
583
+ return normalized === "/" || /^[A-Za-z]:\/$/.test(normalized) ? normalized : normalized.replace(/\/+$/, "");
584
+ }
585
+ var GitCommitService = class {
586
+ deps;
587
+ run;
588
+ preview;
589
+ scope;
590
+ disposePurpose;
591
+ running;
592
+ constructor(deps) {
593
+ this.deps = deps;
594
+ this.run = deps.run ?? execGit;
595
+ this.preview = deps.preview ?? untrackedPreview;
596
+ }
597
+ start() {
598
+ this.scope = this.deps.ai.activate(this.deps.plugin);
599
+ this.disposePurpose = this.scope.registerPurpose({
600
+ id: PURPOSE_ID,
601
+ label: "Git 提交",
602
+ defaultTarget: {
603
+ kind: "role",
604
+ role: "weak"
605
+ },
606
+ maxOutputTokens: DEFAULT_MAX_OUTPUT_TOKENS
607
+ });
608
+ }
609
+ async dispose() {
610
+ this.disposePurpose?.();
611
+ this.scope?.dispose();
612
+ this.scope = void 0;
613
+ await this.running?.catch(() => {});
614
+ }
615
+ async resolveRepo(sessionId) {
616
+ const session = this.deps.sessions.get(sessionId);
617
+ if (!session) throw new CommitRunError("no-session", "dsh-git-commit: session is not live on this host");
618
+ const cwd = normalizeCwd(session.header?.cwd);
619
+ if (!cwd) throw new CommitRunError("no-cwd", "dsh-git-commit: session has no working directory");
620
+ try {
621
+ return await repoInfo(this.run, cwd);
622
+ } catch (error) {
623
+ if (isGitMissing(error)) throw new CommitRunError("no-git", "dsh-git-commit: git executable not found");
624
+ throw new CommitRunError("not-a-repo", "dsh-git-commit: session directory is not inside a git repository");
625
+ }
626
+ }
627
+ async status(sessionId) {
628
+ let resolved;
629
+ try {
630
+ resolved = await this.resolveRepo(sessionId);
631
+ } catch (error) {
632
+ if (error instanceof CommitRunError) return {
633
+ available: false,
634
+ reason: error.code,
635
+ detail: error.message,
636
+ workspace: { files: 0 }
637
+ };
638
+ throw error;
639
+ }
640
+ const changes = await workingTreeChanges(this.run, resolved.root);
641
+ let model;
642
+ if (this.scope?.active) model = await this.deps.ai.resolve(PURPOSE_ID, sessionId).then(modelInfo, () => void 0);
643
+ return {
644
+ available: true,
645
+ root: resolved.root,
646
+ branch: resolved.branch,
647
+ workspace: { files: changes.length },
648
+ ...model ? { model } : {}
649
+ };
650
+ }
651
+ async commit(sessionId, signal) {
652
+ if (this.running) throw new CommitRunError("busy", "dsh-git-commit: a commit run is already in progress");
653
+ const run = this.runCommit(sessionId, signal);
654
+ this.running = run;
655
+ try {
656
+ return await run;
657
+ } finally {
658
+ this.running = void 0;
659
+ }
660
+ }
661
+ async runCommit(sessionId, signal) {
662
+ const session = this.deps.sessions.get(sessionId);
663
+ const sessionCwd = normalizeCwd(session?.header?.cwd);
664
+ const resolved = await this.resolveRepo(sessionId);
665
+ const assertReady = () => {
666
+ signal?.throwIfAborted();
667
+ if (!this.scope?.active) throw new CommitRunError("cancelled", "dsh-git-commit: plugin is no longer active");
668
+ const currentSession = this.deps.sessions.get(sessionId);
669
+ if (currentSession !== session || normalizeCwd(currentSession?.header?.cwd) !== sessionCwd) throw new CommitRunError("session-changed", "dsh-git-commit: session workspace changed during planning");
670
+ if (this.deps.agents?.get(sessionId)?.status === "running") throw new CommitRunError("session-running", "dsh-git-commit: wait for the session turn to finish");
671
+ };
672
+ assertReady();
673
+ const initial = await workspaceSnapshot(this.run, resolved.root, signal);
674
+ const branchRef = await this.run([
675
+ "symbolic-ref",
676
+ "--quiet",
677
+ "HEAD"
678
+ ], {
679
+ cwd: resolved.root,
680
+ signal
681
+ }).catch(() => "DETACHED");
682
+ const selected = initial.files;
683
+ if (selected.length === 0) throw new CommitRunError("no-changes", "dsh-git-commit: working tree is clean");
684
+ const paths = selected.flatMap((file) => file.status === "renamed" && file.origin ? [file.path, file.origin] : [file.path]);
685
+ const originalVersions = await pathVersions(this.run, resolved.root, paths, signal);
686
+ const [{ stat, diff }, subjects] = await Promise.all([diffAgainstHead(this.run, resolved.root, paths, signal), recentSubjects(this.run, resolved.root, 10, signal)]);
687
+ const untracked = [];
688
+ for (const file of selected) if (file.status === "untracked") untracked.push(await this.preview(resolved.root, file.path));
689
+ let groups;
690
+ let model;
691
+ const scope = this.scope;
692
+ if (scope?.active) try {
693
+ const result = await scope.run({
694
+ purpose: PURPOSE_ID,
695
+ sessionId,
696
+ input: buildPlanInput({
697
+ files: selected,
698
+ recentSubjects: subjects,
699
+ stat,
700
+ diff,
701
+ untracked
702
+ }),
703
+ system: planSystemPrompt(),
704
+ sourceVersion: `${sessionId}:${initial.fingerprint}`,
705
+ contractVersion: 1,
706
+ promptVersion: PROMPT_VERSION,
707
+ schemaVersion: SCHEMA_VERSION,
708
+ signal,
709
+ priority: "interactive"
710
+ });
711
+ model = modelInfo(result.receipt.route);
712
+ if (result.receipt.status === "cancelled") throw new CommitRunError("cancelled", "dsh-git-commit: planning was cancelled");
713
+ groups = parsePlan(result.text, selected);
714
+ } catch (error) {
715
+ if (error instanceof CommitRunError) throw error;
716
+ groups = void 0;
717
+ }
718
+ assertReady();
719
+ if ((await workspaceSnapshot(this.run, resolved.root, signal)).fingerprint !== initial.fingerprint) throw new CommitRunError("workspace-changed", "dsh-git-commit: workspace changed during planning; retry the commit");
720
+ const effective = groups ?? [fallbackGroup(selected, looksChinese(subjects) ? "zh" : "en")];
721
+ const executed = await commitGroups(this.run, resolved.root, effective, selected, signal, async () => {
722
+ assertReady();
723
+ if (await this.run([
724
+ "symbolic-ref",
725
+ "--quiet",
726
+ "HEAD"
727
+ ], {
728
+ cwd: resolved.root,
729
+ signal
730
+ }).catch(() => "DETACHED") !== branchRef) throw new CommitRunError("workspace-changed", "dsh-git-commit: branch changed during the commit run");
731
+ if (await pathVersions(this.run, resolved.root, paths, signal) !== originalVersions) throw new CommitRunError("workspace-changed", "dsh-git-commit: selected file changed during the commit run");
732
+ await workingTreeChanges(this.run, resolved.root, signal);
733
+ });
734
+ const commits = [];
735
+ for (let index = 0; index < effective.length; index += 1) {
736
+ const result = executed[index];
737
+ const group = effective[index];
738
+ if (!result || !group || result.skipped) continue;
739
+ commits.push({
740
+ hash: result.hash,
741
+ message: group.message,
742
+ files: group.files
743
+ });
744
+ }
745
+ if (commits.length === 0) throw new CommitRunError("no-changes", "dsh-git-commit: nothing was committed");
746
+ return {
747
+ branch: resolved.branch,
748
+ commits,
749
+ fallback: groups === void 0,
750
+ ...model ? { model } : {}
751
+ };
752
+ }
753
+ };
754
+ //#endregion
755
+ //#region src/index.ts
756
+ const name = PLUGIN_NAME;
757
+ const inject = [
758
+ "aiServices",
759
+ "sessions",
760
+ "agents",
761
+ "connection",
762
+ "webServer"
763
+ ];
764
+ function stringField(payload, key) {
765
+ if (!payload || typeof payload !== "object") return void 0;
766
+ const value = payload[key];
767
+ return typeof value === "string" && value.trim() ? value : void 0;
768
+ }
769
+ function fail(error) {
770
+ if (error instanceof CommitRunError) return {
771
+ ok: false,
772
+ error: {
773
+ code: error.code,
774
+ message: error.message
775
+ }
776
+ };
777
+ return {
778
+ ok: false,
779
+ error: {
780
+ code: "internal",
781
+ message: error instanceof Error ? error.message : String(error)
782
+ }
783
+ };
784
+ }
785
+ async function handleRpc(service, endpoint, payload, signal) {
786
+ try {
787
+ switch (endpoint) {
788
+ case "status": {
789
+ const sessionId = stringField(payload, "sessionId");
790
+ if (!sessionId) return {
791
+ ok: false,
792
+ error: {
793
+ code: "bad-request",
794
+ message: "sessionId is required"
795
+ }
796
+ };
797
+ return {
798
+ ok: true,
799
+ value: await service.status(sessionId)
800
+ };
801
+ }
802
+ case "commit": {
803
+ const sessionId = stringField(payload, "sessionId");
804
+ if (!sessionId) return {
805
+ ok: false,
806
+ error: {
807
+ code: "bad-request",
808
+ message: "sessionId is required"
809
+ }
810
+ };
811
+ return {
812
+ ok: true,
813
+ value: await service.commit(sessionId, signal)
814
+ };
815
+ }
816
+ default: return {
817
+ ok: false,
818
+ error: {
819
+ code: "not-found",
820
+ message: `unknown endpoint: ${endpoint}`
821
+ }
822
+ };
823
+ }
824
+ } catch (error) {
825
+ return fail(error);
826
+ }
827
+ }
828
+ function apply(ctx) {
829
+ const host = ctx;
830
+ const service = new GitCommitService({
831
+ plugin: PLUGIN_NAME,
832
+ ai: host.aiServices,
833
+ sessions: host.sessions,
834
+ agents: host.agents
835
+ });
836
+ service.start();
837
+ ctx.provide("gitCommit", service);
838
+ ctx.effect(() => async () => {
839
+ await service.dispose();
840
+ }, "git-commit.lifecycle");
841
+ ctx.effect(() => registerHostRpc(host, RPC_CHANNEL, (endpoint, payload, signal) => handleRpc(service, endpoint, payload, signal)), "git-commit.rpc");
842
+ }
843
+ //#endregion
844
+ export { GitCommitService, apply, buildPlanInput, handleRpc, inject, name, parsePlan, parsePorcelain };
845
+
846
+ //# sourceMappingURL=index.js.map