@civilization/dsh-git-workbench 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1274 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { defineTool } from "@deepseek-ai/dsh-tools";
4
+ // src/git-executor.ts
5
+ const execFileAsync = promisify(execFile);
6
+ /** Timeout in milliseconds for git operations */
7
+ const GIT_TIMEOUT_MS = 15e3;
8
+ /** Maximum stdout buffer for git diff / log output (8MB) */
9
+ const GIT_MAX_BUFFER = 8388608;
10
+ /** Vibrant IDEA-grade color palette for DAG branches */
11
+ const LANE_PALETTE = [
12
+ "#388bfd",
13
+ "#2ea043",
14
+ "#f0883e",
15
+ "#a371f7",
16
+ "#db61a2",
17
+ "#39c5bb",
18
+ "#f9c513"
19
+ ];
20
+ /**
21
+ * Execute a git command in the target directory safely.
22
+ * @param cwd - Working directory for the git execution.
23
+ * @param args - Subcommand and arguments to pass to git.
24
+ * @returns Raw stdout trimmed, or undefined if the execution failed.
25
+ */
26
+ async function runGit(cwd, args) {
27
+ try {
28
+ const { stdout } = await execFileAsync("git", args, {
29
+ cwd,
30
+ timeout: GIT_TIMEOUT_MS,
31
+ maxBuffer: GIT_MAX_BUFFER,
32
+ windowsHide: true,
33
+ encoding: "utf8"
34
+ });
35
+ return stdout.trim();
36
+ } catch {
37
+ return;
38
+ }
39
+ }
40
+ /**
41
+ * Detailed execution result returning stdout, stderr, and success code.
42
+ * @param cwd - Working directory.
43
+ * @param args - Arguments.
44
+ * @returns Standard execution details.
45
+ */
46
+ async function runGitDetailed(cwd, args) {
47
+ try {
48
+ const { stdout, stderr } = await execFileAsync("git", args, {
49
+ cwd,
50
+ timeout: GIT_TIMEOUT_MS,
51
+ maxBuffer: GIT_MAX_BUFFER,
52
+ windowsHide: true,
53
+ encoding: "utf8"
54
+ });
55
+ return {
56
+ ok: true,
57
+ stdout: stdout.trim(),
58
+ stderr: stderr.trim()
59
+ };
60
+ } catch (err) {
61
+ const error = err;
62
+ return {
63
+ ok: false,
64
+ stdout: (error.stdout ?? "").trim(),
65
+ stderr: (error.stderr || error.message || String(err)).trim()
66
+ };
67
+ }
68
+ }
69
+ /**
70
+ * Check if the target directory is inside a valid git repository worktree.
71
+ * @param cwd - Target directory.
72
+ * @returns Whether cwd is a git repository.
73
+ */
74
+ async function checkIsGitRepo(cwd) {
75
+ return await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
76
+ }
77
+ /**
78
+ * Get the absolute top-level directory of the git repository.
79
+ * @param cwd - Target directory.
80
+ * @returns Top-level directory path, or undefined.
81
+ */
82
+ async function getGitRoot(cwd) {
83
+ const root = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
84
+ return root ? root.replaceAll("\\", "/") : void 0;
85
+ }
86
+ /**
87
+ * Map porcelain status character to normalized status code.
88
+ * @param char - Status character from git porcelain.
89
+ * @returns Normalized status code.
90
+ */
91
+ function parseStatusCode(char) {
92
+ switch (char) {
93
+ case "A": return "added";
94
+ case "D": return "deleted";
95
+ case "R": return "renamed";
96
+ case "C": return "copied";
97
+ case "?": return "untracked";
98
+ default: return "modified";
99
+ }
100
+ }
101
+ /**
102
+ * Parse one porcelain file status line.
103
+ * @param line - Raw line from `git status --porcelain=v1`.
104
+ * @returns Status entries for staged, unstaged, or untracked categories.
105
+ */
106
+ function parsePorcelainLine(line) {
107
+ if (line.length < 3) return {};
108
+ const x = line[0] ?? " ";
109
+ const y = line[1] ?? " ";
110
+ const rawPath = line.slice(3).trim();
111
+ let path = rawPath;
112
+ let originalPath;
113
+ if (rawPath.includes(" -> ")) {
114
+ const parts = rawPath.split(" -> ");
115
+ originalPath = parts[0]?.replaceAll("\"", "");
116
+ path = parts[1]?.replaceAll("\"", "") ?? rawPath;
117
+ } else path = rawPath.replaceAll("\"", "");
118
+ if (x === "?" && y === "?") return { untracked: {
119
+ path,
120
+ status: "untracked",
121
+ staged: false
122
+ } };
123
+ const result = {};
124
+ if (x !== " " && x !== "?") result.staged = {
125
+ path,
126
+ ...originalPath !== void 0 ? { originalPath } : {},
127
+ status: parseStatusCode(x),
128
+ staged: true
129
+ };
130
+ if (y !== " " && y !== "?") result.unstaged = {
131
+ path,
132
+ ...originalPath !== void 0 ? { originalPath } : {},
133
+ status: parseStatusCode(y),
134
+ staged: false
135
+ };
136
+ return result;
137
+ }
138
+ /**
139
+ * Query full git repository status for the given directory.
140
+ * @param cwd - Target directory.
141
+ * @returns Complete status summary.
142
+ */
143
+ async function getGitStatus(cwd) {
144
+ if (!await checkIsGitRepo(cwd)) return {
145
+ isRepo: false,
146
+ ahead: 0,
147
+ behind: 0,
148
+ staged: [],
149
+ unstaged: [],
150
+ untracked: [],
151
+ totalChanges: 0
152
+ };
153
+ const rootPath = await getGitRoot(cwd);
154
+ const porcelain = await runGit(cwd, [
155
+ "status",
156
+ "--porcelain=v1",
157
+ "-b"
158
+ ]);
159
+ let branch = "HEAD";
160
+ let upstream;
161
+ let ahead = 0;
162
+ let behind = 0;
163
+ const staged = [];
164
+ const unstaged = [];
165
+ const untracked = [];
166
+ if (porcelain) {
167
+ const lines = porcelain.split(/\r?\n/);
168
+ for (const line of lines) {
169
+ if (!line) continue;
170
+ if (line.startsWith("## ")) {
171
+ const branchHeader = line.slice(3).trim();
172
+ const bracketIndex = branchHeader.indexOf(" [");
173
+ const branchPart = bracketIndex !== -1 ? branchHeader.slice(0, bracketIndex) : branchHeader;
174
+ if (branchPart.includes("...")) {
175
+ const [b, u] = branchPart.split("...");
176
+ branch = b ?? "HEAD";
177
+ upstream = u;
178
+ } else branch = branchPart;
179
+ if (bracketIndex !== -1) {
180
+ const aheadMatch = /ahead (\d+)/.exec(branchHeader);
181
+ if (aheadMatch?.[1]) ahead = Number.parseInt(aheadMatch[1], 10);
182
+ const behindMatch = /behind (\d+)/.exec(branchHeader);
183
+ if (behindMatch?.[1]) behind = Number.parseInt(behindMatch[1], 10);
184
+ }
185
+ } else {
186
+ const parsed = parsePorcelainLine(line);
187
+ if (parsed.staged) staged.push(parsed.staged);
188
+ if (parsed.unstaged) unstaged.push(parsed.unstaged);
189
+ if (parsed.untracked) untracked.push(parsed.untracked);
190
+ }
191
+ }
192
+ }
193
+ let headCommit;
194
+ const headRaw = await runGit(cwd, [
195
+ "log",
196
+ "-1",
197
+ "--pretty=format:%H%x1f%h%x1f%s"
198
+ ]);
199
+ if (headRaw) {
200
+ const [hash, shortHash, message] = headRaw.split("");
201
+ if (hash && shortHash && message !== void 0) headCommit = {
202
+ hash,
203
+ shortHash,
204
+ message
205
+ };
206
+ }
207
+ let totalCommitsCount;
208
+ const countRaw = await runGit(cwd, [
209
+ "rev-list",
210
+ "--count",
211
+ "HEAD"
212
+ ]);
213
+ if (countRaw) {
214
+ const parsed = Number.parseInt(countRaw.trim(), 10);
215
+ if (!Number.isNaN(parsed)) totalCommitsCount = parsed;
216
+ }
217
+ return {
218
+ isRepo: true,
219
+ ...rootPath !== void 0 ? { rootPath } : {},
220
+ branch,
221
+ ...upstream !== void 0 ? { upstream } : {},
222
+ ahead,
223
+ behind,
224
+ ...headCommit !== void 0 ? { headCommit } : {},
225
+ ...totalCommitsCount !== void 0 ? { totalCommitsCount } : {},
226
+ staged,
227
+ unstaged,
228
+ untracked,
229
+ totalChanges: staged.length + unstaged.length + untracked.length
230
+ };
231
+ }
232
+ /**
233
+ * Parse refs from git %D format (e.g. "HEAD -> master, origin/master, tag: v0.1").
234
+ * @param refStr - Raw refs string.
235
+ * @returns Normalized list of clean ref names.
236
+ */
237
+ function parseCommitRefs(refStr) {
238
+ if (!refStr.trim()) return [];
239
+ const parts = refStr.split(",");
240
+ const refs = [];
241
+ for (const part of parts) {
242
+ const trimmed = part.trim();
243
+ if (!trimmed) continue;
244
+ if (trimmed.startsWith("HEAD -> ")) refs.push(trimmed.slice(8));
245
+ else refs.push(trimmed);
246
+ }
247
+ return refs;
248
+ }
249
+ /**
250
+ * Compute DAG topology lanes and vector paths for commits (IntelliJ IDEA style graph).
251
+ * Implements mainline pinning (trunk commits firmly anchored to lane 0) and smooth lane allocations.
252
+ * @param rawCommits - Chronological descending commits with parents.
253
+ * @returns Commits augmented with lane indices, lane colors, and connecting paths.
254
+ */
255
+ function buildDagTopology(rawCommits) {
256
+ const activeLanes = [];
257
+ const result = [];
258
+ for (const item of rawCommits) {
259
+ let lane = activeLanes.indexOf(item.hash);
260
+ if (lane === -1) {
261
+ if (activeLanes.length === 0 || activeLanes[0] === null) {
262
+ lane = 0;
263
+ if (activeLanes.length === 0) activeLanes.push(null);
264
+ else activeLanes[0] = null;
265
+ } else {
266
+ let emptySlot = -1;
267
+ for (let l = 1; l < activeLanes.length; l++) if (activeLanes[l] === null) {
268
+ emptySlot = l;
269
+ break;
270
+ }
271
+ if (emptySlot !== -1) lane = emptySlot;
272
+ else {
273
+ lane = activeLanes.length;
274
+ activeLanes.push(null);
275
+ }
276
+ }
277
+ }
278
+ const laneColor = LANE_PALETTE[lane % LANE_PALETTE.length] ?? "#388bfd";
279
+ activeLanes[lane] = null;
280
+ const paths = [];
281
+ const [firstParent, ...otherParents] = item.parents;
282
+ if (firstParent) {
283
+ const existingParentLane = activeLanes.indexOf(firstParent);
284
+ if (lane === 0) {
285
+ if (existingParentLane !== -1 && existingParentLane !== 0) {
286
+ activeLanes[existingParentLane] = null;
287
+ paths.push({
288
+ fromLane: existingParentLane,
289
+ toLane: 0,
290
+ kind: "merge",
291
+ color: laneColor
292
+ });
293
+ }
294
+ activeLanes[0] = firstParent;
295
+ paths.push({
296
+ fromLane: 0,
297
+ toLane: 0,
298
+ kind: "straight",
299
+ color: laneColor
300
+ });
301
+ } else if (existingParentLane !== -1) paths.push({
302
+ fromLane: lane,
303
+ toLane: existingParentLane,
304
+ kind: "merge",
305
+ color: laneColor
306
+ });
307
+ else {
308
+ activeLanes[lane] = firstParent;
309
+ paths.push({
310
+ fromLane: lane,
311
+ toLane: lane,
312
+ kind: "straight",
313
+ color: laneColor
314
+ });
315
+ }
316
+ }
317
+ for (const otherParent of otherParents) {
318
+ const existingLane = activeLanes.indexOf(otherParent);
319
+ if (existingLane !== -1) paths.push({
320
+ fromLane: lane,
321
+ toLane: existingLane,
322
+ kind: "merge",
323
+ color: LANE_PALETTE[existingLane % LANE_PALETTE.length] ?? laneColor
324
+ });
325
+ else {
326
+ let emptySlot = -1;
327
+ for (let l = 1; l < activeLanes.length; l++) if (activeLanes[l] === null) {
328
+ emptySlot = l;
329
+ break;
330
+ }
331
+ const newLane = emptySlot !== -1 ? emptySlot : Math.max(1, activeLanes.length);
332
+ if (emptySlot !== -1) activeLanes[emptySlot] = otherParent;
333
+ else {
334
+ while (activeLanes.length < newLane) activeLanes.push(null);
335
+ activeLanes[newLane] = otherParent;
336
+ }
337
+ const branchColor = LANE_PALETTE[newLane % LANE_PALETTE.length] ?? laneColor;
338
+ paths.push({
339
+ fromLane: lane,
340
+ toLane: newLane,
341
+ kind: "fork",
342
+ color: branchColor
343
+ });
344
+ }
345
+ }
346
+ for (let l = 0; l < activeLanes.length; l++) if (l !== lane && activeLanes[l] !== null) paths.push({
347
+ fromLane: l,
348
+ toLane: l,
349
+ kind: "straight",
350
+ color: LANE_PALETTE[l % LANE_PALETTE.length] ?? "#388bfd"
351
+ });
352
+ while (activeLanes.length > 0 && activeLanes.at(-1) === null) activeLanes.pop();
353
+ result.push({
354
+ hash: item.hash,
355
+ shortHash: item.shortHash,
356
+ author: item.author,
357
+ authorEmail: item.authorEmail,
358
+ relativeDate: item.relativeDate,
359
+ date: item.date,
360
+ subject: item.subject,
361
+ parents: item.parents,
362
+ refs: item.refs,
363
+ lane,
364
+ laneColor,
365
+ paths
366
+ });
367
+ }
368
+ return result;
369
+ }
370
+ /**
371
+ * Query recent git commit history with DAG graph topology for the given directory.
372
+ * @param cwd - Target directory.
373
+ * @param maxCount - Maximum number of commits to retrieve (defaults to 50).
374
+ * @returns Ordered list of recent commits with topology paths.
375
+ */
376
+ async function getGitLog(cwd, maxCount = 50) {
377
+ if (!await checkIsGitRepo(cwd)) return [];
378
+ const raw = await runGit(cwd, [
379
+ "log",
380
+ "-n",
381
+ String(maxCount),
382
+ "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%ar%x1f%ad%x1f%s%x1f%P%x1f%D"
383
+ ]);
384
+ if (!raw) return [];
385
+ const rawCommits = [];
386
+ const lines = raw.split(/\r?\n/);
387
+ for (const line of lines) {
388
+ if (!line) continue;
389
+ const parts = line.split("");
390
+ if (parts.length >= 7 && parts[0] && parts[1] && parts[2] && parts[3] && parts[4] && parts[5] && parts[6] !== void 0) {
391
+ const parentStr = parts[7] ?? "";
392
+ const parents = parentStr.trim() ? parentStr.trim().split(/\s+/) : [];
393
+ const refs = parseCommitRefs(parts[8] ?? "");
394
+ rawCommits.push({
395
+ hash: parts[0],
396
+ shortHash: parts[1],
397
+ author: parts[2],
398
+ authorEmail: parts[3],
399
+ relativeDate: parts[4],
400
+ date: parts[5],
401
+ subject: parts[6],
402
+ parents,
403
+ refs
404
+ });
405
+ }
406
+ }
407
+ return buildDagTopology(rawCommits);
408
+ }
409
+ /**
410
+ * Query details and changed files for a specific commit.
411
+ * @param cwd - Working directory.
412
+ * @param commitHash - Full or abbreviated commit hash.
413
+ * @returns Detailed commit information.
414
+ */
415
+ async function getGitCommitDetails(cwd, commitHash) {
416
+ if (!await checkIsGitRepo(cwd)) return void 0;
417
+ const headRaw = await runGit(cwd, [
418
+ "show",
419
+ "--no-patch",
420
+ "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%ar%x1f%ad%x1f%s%x1f%P%x1f%D%x1f%b",
421
+ commitHash
422
+ ]);
423
+ if (!headRaw) return void 0;
424
+ const parts = headRaw.split("");
425
+ if (parts.length < 7 || !parts[0] || !parts[1] || !parts[2] || !parts[3] || !parts[4] || !parts[5] || parts[6] === void 0) return;
426
+ const parentStr = parts[7] ?? "";
427
+ const parents = parentStr.trim() ? parentStr.trim().split(/\s+/) : [];
428
+ const refs = parseCommitRefs(parts[8] ?? "");
429
+ const rawBody = (parts[9] ?? "").trim();
430
+ const body = rawBody.length > 0 ? rawBody : void 0;
431
+ const commit = {
432
+ hash: parts[0],
433
+ shortHash: parts[1],
434
+ author: parts[2],
435
+ authorEmail: parts[3],
436
+ relativeDate: parts[4],
437
+ date: parts[5],
438
+ subject: parts[6],
439
+ ...body !== void 0 ? { body } : {},
440
+ parents,
441
+ refs
442
+ };
443
+ const numstatRaw = await runGit(cwd, [
444
+ "show",
445
+ "--numstat",
446
+ "--format=",
447
+ "-M",
448
+ commitHash
449
+ ]);
450
+ const files = [];
451
+ let totalAdditions = 0;
452
+ let totalDeletions = 0;
453
+ if (numstatRaw) {
454
+ const lines = numstatRaw.split(/\r?\n/);
455
+ for (const line of lines) {
456
+ if (!line.trim()) continue;
457
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line);
458
+ if (match && match[1] && match[2] && match[3]) {
459
+ const adds = match[1] === "-" ? 0 : Number.parseInt(match[1], 10);
460
+ const dels = match[2] === "-" ? 0 : Number.parseInt(match[2], 10);
461
+ const rawPath = match[3];
462
+ let path = rawPath;
463
+ let oldPath;
464
+ let status = "modified";
465
+ if (rawPath.includes(" => ")) {
466
+ status = "renamed";
467
+ const braceMatch = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(rawPath);
468
+ if (braceMatch && braceMatch[1] !== void 0 && braceMatch[2] !== void 0 && braceMatch[3] !== void 0 && braceMatch[4] !== void 0) {
469
+ oldPath = `${braceMatch[1]}${braceMatch[2]}${braceMatch[4]}`;
470
+ path = `${braceMatch[1]}${braceMatch[3]}${braceMatch[4]}`;
471
+ } else {
472
+ const split = rawPath.split(" => ");
473
+ oldPath = split[0]?.trim();
474
+ path = split[1]?.trim() ?? rawPath;
475
+ }
476
+ }
477
+ totalAdditions += adds;
478
+ totalDeletions += dels;
479
+ files.push({
480
+ path,
481
+ ...oldPath !== void 0 ? { oldPath } : {},
482
+ status,
483
+ additions: adds,
484
+ deletions: dels
485
+ });
486
+ }
487
+ }
488
+ }
489
+ return {
490
+ commit,
491
+ ...body !== void 0 ? { body } : {},
492
+ ...parents.length > 0 ? { parents } : {},
493
+ files,
494
+ totalAdditions,
495
+ totalDeletions
496
+ };
497
+ }
498
+ /**
499
+ * Query git diff for specified file or current directory.
500
+ * @param cwd - Target directory.
501
+ * @param options - Diff parameters.
502
+ * @returns Complete diff text and per-file parsed statistics.
503
+ */
504
+ async function getGitDiff(cwd, options) {
505
+ if (!await checkIsGitRepo(cwd)) return {
506
+ files: [],
507
+ rawDiff: ""
508
+ };
509
+ const args = [];
510
+ if (options?.commit) args.push("show", "--format=", "--patch", options.commit);
511
+ else {
512
+ args.push("diff");
513
+ if (options?.staged) args.push("--staged");
514
+ }
515
+ if (options?.path) args.push("--", options.path);
516
+ const rawDiff = await runGit(cwd, args) ?? "";
517
+ if (!rawDiff) return {
518
+ files: [],
519
+ rawDiff: ""
520
+ };
521
+ const files = [];
522
+ const fileChunks = rawDiff.split(/^diff --git /m);
523
+ for (const chunk of fileChunks) {
524
+ if (!chunk.trim()) continue;
525
+ const firstLineEnd = chunk.indexOf("\n");
526
+ const firstLine = firstLineEnd !== -1 ? chunk.slice(0, firstLineEnd).trim() : chunk.trim();
527
+ const match = /a\/(.+?) b\/(.+?)$/.exec(firstLine);
528
+ const filePath = match?.[2] ?? match?.[1] ?? "unknown";
529
+ const oldPath = match?.[1] !== match?.[2] ? match?.[1] : void 0;
530
+ let additions = 0;
531
+ let deletions = 0;
532
+ const lines = chunk.split("\n");
533
+ for (const l of lines) if (l.startsWith("+") && !l.startsWith("+++")) additions++;
534
+ else if (l.startsWith("-") && !l.startsWith("---")) deletions++;
535
+ files.push({
536
+ path: filePath,
537
+ ...oldPath !== void 0 ? { oldPath } : {},
538
+ status: "modified",
539
+ diffText: `diff --git ${chunk}`,
540
+ additions,
541
+ deletions
542
+ });
543
+ }
544
+ return {
545
+ files,
546
+ rawDiff
547
+ };
548
+ }
549
+ /**
550
+ * Query local and remote branches.
551
+ * @param cwd - Target directory.
552
+ * @returns Aggregated branch summary.
553
+ */
554
+ async function getGitAllBranches(cwd) {
555
+ if (!await checkIsGitRepo(cwd)) return {
556
+ local: [],
557
+ remote: []
558
+ };
559
+ const raw = await runGit(cwd, [
560
+ "branch",
561
+ "-a",
562
+ "-vv",
563
+ "--list"
564
+ ]);
565
+ if (!raw) return {
566
+ local: [],
567
+ remote: []
568
+ };
569
+ const local = [];
570
+ const remote = [];
571
+ let current;
572
+ const lines = raw.split(/\r?\n/);
573
+ for (const line of lines) {
574
+ if (!line.trim()) continue;
575
+ const isCurrent = line.startsWith("*");
576
+ const cleanLine = line.slice(2).trim();
577
+ if (cleanLine.includes("->")) continue;
578
+ const spaceIndex = cleanLine.indexOf(" ");
579
+ const fullName = spaceIndex !== -1 ? cleanLine.slice(0, spaceIndex) : cleanLine;
580
+ let upstream;
581
+ let ahead;
582
+ let behind;
583
+ const bracketStart = cleanLine.indexOf("[");
584
+ const bracketEnd = cleanLine.indexOf("]");
585
+ if (bracketStart !== -1 && bracketEnd > bracketStart) {
586
+ const info = cleanLine.slice(bracketStart + 1, bracketEnd);
587
+ const colonIndex = info.indexOf(":");
588
+ upstream = colonIndex !== -1 ? info.slice(0, colonIndex).trim() : info.trim();
589
+ const aheadMatch = /ahead (\d+)/.exec(info);
590
+ if (aheadMatch?.[1]) ahead = Number.parseInt(aheadMatch[1], 10);
591
+ const behindMatch = /behind (\d+)/.exec(info);
592
+ if (behindMatch?.[1]) behind = Number.parseInt(behindMatch[1], 10);
593
+ }
594
+ if (isCurrent) current = fullName;
595
+ if (fullName.startsWith("remotes/")) {
596
+ const remoteName = fullName.replace(/^remotes\//, "");
597
+ remote.push({
598
+ name: remoteName,
599
+ isCurrent: false
600
+ });
601
+ } else local.push({
602
+ name: fullName,
603
+ isCurrent,
604
+ ...upstream !== void 0 ? { upstream } : {},
605
+ ...ahead !== void 0 ? { ahead } : {},
606
+ ...behind !== void 0 ? { behind } : {}
607
+ });
608
+ }
609
+ return {
610
+ ...current !== void 0 ? { current } : {},
611
+ local,
612
+ remote
613
+ };
614
+ }
615
+ /**
616
+ * Checkout an existing branch or create a new branch.
617
+ * @param cwd - Target directory.
618
+ * @param branch - Branch name to switch to.
619
+ * @param createNew - Whether to create a new branch (-b).
620
+ * @returns Operation result.
621
+ */
622
+ async function gitCheckout(cwd, branch, createNew) {
623
+ const cleanBranch = branch.trim();
624
+ if (!cleanBranch) return {
625
+ success: false,
626
+ message: "Branch name cannot be empty"
627
+ };
628
+ const res = await runGitDetailed(cwd, createNew ? [
629
+ "checkout",
630
+ "-b",
631
+ cleanBranch
632
+ ] : ["checkout", cleanBranch]);
633
+ return {
634
+ success: res.ok,
635
+ message: res.ok ? `Checked out branch ${cleanBranch}` : res.stderr || "Checkout failed",
636
+ output: res.stdout || res.stderr
637
+ };
638
+ }
639
+ /**
640
+ * Stage files to index.
641
+ * @param cwd - Target directory.
642
+ * @param paths - Specific file paths or 'all'.
643
+ * @returns Operation result.
644
+ */
645
+ async function gitStage(cwd, paths) {
646
+ const res = await runGitDetailed(cwd, paths === "all" ? ["add", "-A"] : [
647
+ "add",
648
+ "--",
649
+ ...paths
650
+ ]);
651
+ return {
652
+ success: res.ok,
653
+ message: res.ok ? "Files staged successfully" : res.stderr || "Stage failed",
654
+ output: res.stdout || res.stderr
655
+ };
656
+ }
657
+ /**
658
+ * Unstage files from index.
659
+ * @param cwd - Target directory.
660
+ * @param paths - Specific file paths or 'all'.
661
+ * @returns Operation result.
662
+ */
663
+ async function gitUnstage(cwd, paths) {
664
+ const res = await runGitDetailed(cwd, paths === "all" ? ["reset", "HEAD"] : [
665
+ "reset",
666
+ "HEAD",
667
+ "--",
668
+ ...paths
669
+ ]);
670
+ return {
671
+ success: res.ok,
672
+ message: res.ok ? "Files unstaged successfully" : res.stderr || "Unstage failed",
673
+ output: res.stdout || res.stderr
674
+ };
675
+ }
676
+ /**
677
+ * Discard working tree changes for specified files.
678
+ * @param cwd - Target directory.
679
+ * @param paths - Specific file paths to discard.
680
+ * @returns Operation result.
681
+ */
682
+ async function gitDiscard(cwd, paths) {
683
+ if (paths.length === 0) return { success: true };
684
+ const resRestore = await runGitDetailed(cwd, [
685
+ "checkout",
686
+ "--",
687
+ ...paths
688
+ ]);
689
+ await runGitDetailed(cwd, [
690
+ "clean",
691
+ "-f",
692
+ "--",
693
+ ...paths
694
+ ]);
695
+ return {
696
+ success: resRestore.ok,
697
+ message: resRestore.ok ? "Changes discarded successfully" : resRestore.stderr || "Discard failed",
698
+ output: resRestore.stdout || resRestore.stderr
699
+ };
700
+ }
701
+ /**
702
+ * Commit staged changes with message.
703
+ * @param cwd - Target directory.
704
+ * @param message - Commit message.
705
+ * @returns Operation result.
706
+ */
707
+ async function gitCommit(cwd, message) {
708
+ const trimmed = message.trim();
709
+ if (!trimmed) return {
710
+ success: false,
711
+ message: "Commit message cannot be empty"
712
+ };
713
+ const res = await runGitDetailed(cwd, [
714
+ "commit",
715
+ "-m",
716
+ trimmed
717
+ ]);
718
+ return {
719
+ success: res.ok,
720
+ message: res.ok ? "Changes committed successfully" : res.stderr || "Commit failed",
721
+ output: res.stdout || res.stderr
722
+ };
723
+ }
724
+ /**
725
+ * Fetch updates from remotes.
726
+ * @param cwd - Target directory.
727
+ * @returns Operation result.
728
+ */
729
+ async function gitFetch(cwd) {
730
+ const res = await runGitDetailed(cwd, [
731
+ "fetch",
732
+ "--all",
733
+ "--prune"
734
+ ]);
735
+ return {
736
+ success: res.ok,
737
+ message: res.ok ? "Fetched successfully" : res.stderr || "Fetch failed",
738
+ output: res.stdout || res.stderr
739
+ };
740
+ }
741
+ /**
742
+ * Pull remote changes into current branch.
743
+ * @param cwd - Target directory.
744
+ * @returns Operation result.
745
+ */
746
+ async function gitPull(cwd) {
747
+ const res = await runGitDetailed(cwd, ["pull"]);
748
+ return {
749
+ success: res.ok,
750
+ message: res.ok ? "Pulled successfully" : res.stderr || "Pull failed",
751
+ output: res.stdout || res.stderr
752
+ };
753
+ }
754
+ /**
755
+ * Push local commits to remote upstream.
756
+ * @param cwd - Target directory.
757
+ * @returns Operation result.
758
+ */
759
+ async function gitPush(cwd) {
760
+ const res = await runGitDetailed(cwd, ["push"]);
761
+ return {
762
+ success: res.ok,
763
+ message: res.ok ? "Pushed successfully" : res.stderr || "Push failed",
764
+ output: res.stdout || res.stderr
765
+ };
766
+ }
767
+ /**
768
+ * Query all git tags in the repository sorted by creation date descending.
769
+ * @param cwd - Target directory.
770
+ * @returns Ordered list of Git tags with commit hashes and metadata.
771
+ */
772
+ async function getGitTags(cwd) {
773
+ if (!await checkIsGitRepo(cwd)) return [];
774
+ const raw = await runGit(cwd, [
775
+ "for-each-ref",
776
+ "--sort=-creatordate",
777
+ "refs/tags",
778
+ "--format=%(refname:short) %(objectname) %(*objectname) %(creatordate:iso) %(creatordate:relative) %(contents:subject) %(taggername) %(authorname)"
779
+ ]);
780
+ if (!raw) return [];
781
+ const tags = [];
782
+ const lines = raw.split(/\r?\n/);
783
+ for (const line of lines) {
784
+ if (!line.trim()) continue;
785
+ const parts = line.split(" ");
786
+ if (parts.length >= 6 && parts[0]) {
787
+ const name = parts[0];
788
+ const objectName = parts[1] ?? "";
789
+ const peeledHash = (parts[2] ?? "").trim();
790
+ const commitHash = peeledHash || objectName;
791
+ const isAnnotated = Boolean(peeledHash);
792
+ const date = parts[3] ?? "";
793
+ const relativeDate = parts[4] ?? "";
794
+ const message = parts[5] ?? "";
795
+ const author = parts[6] || parts[7] || "Unknown";
796
+ tags.push({
797
+ name,
798
+ commitHash,
799
+ shortHash: commitHash.slice(0, 7),
800
+ date,
801
+ relativeDate,
802
+ message,
803
+ author,
804
+ isAnnotated
805
+ });
806
+ }
807
+ }
808
+ return tags;
809
+ }
810
+ /**
811
+ * Create a new git tag at the specified commit or HEAD.
812
+ * @param cwd - Target directory.
813
+ * @param name - Tag name (e.g. 'v1.0.0').
814
+ * @param targetCommit - Optional commit hash (defaults to HEAD).
815
+ * @param message - Optional annotation message. If provided, creates an annotated tag (-a).
816
+ * @returns Operation result.
817
+ */
818
+ async function gitCreateTag(cwd, name, targetCommit, message) {
819
+ const cleanName = name.trim();
820
+ if (!cleanName) return {
821
+ success: false,
822
+ message: "Tag name cannot be empty"
823
+ };
824
+ const args = ["tag"];
825
+ if (message && message.trim()) args.push("-a", cleanName, "-m", message.trim());
826
+ else args.push(cleanName);
827
+ if (targetCommit && targetCommit.trim()) args.push(targetCommit.trim());
828
+ const res = await runGitDetailed(cwd, args);
829
+ return {
830
+ success: res.ok,
831
+ message: res.ok ? `Tag '${cleanName}' created successfully` : res.stderr || "Failed to create tag",
832
+ output: res.stdout || res.stderr
833
+ };
834
+ }
835
+ /**
836
+ * Delete a git tag.
837
+ * @param cwd - Target directory.
838
+ * @param name - Tag name to delete.
839
+ * @returns Operation result.
840
+ */
841
+ async function gitDeleteTag(cwd, name) {
842
+ const cleanName = name.trim();
843
+ if (!cleanName) return {
844
+ success: false,
845
+ message: "Tag name cannot be empty"
846
+ };
847
+ const res = await runGitDetailed(cwd, [
848
+ "tag",
849
+ "-d",
850
+ cleanName
851
+ ]);
852
+ return {
853
+ success: res.ok,
854
+ message: res.ok ? `Tag '${cleanName}' deleted successfully` : res.stderr || "Failed to delete tag",
855
+ output: res.stdout || res.stderr
856
+ };
857
+ }
858
+ // src/tool.ts
859
+ /**
860
+ * Register git model tools (git_view and git_checkout) in the tools service.
861
+ * @param ctx - Context carrying the tools service.
862
+ * @param resolveDir - Resolver for the current agent or session working directory.
863
+ */
864
+ function registerGitTool(ctx, resolveDir) {
865
+ ctx.inject(["tools"], (scope) => {
866
+ scope.effect(() => scope.tools.register(defineTool({
867
+ name: "git_view",
868
+ description: "Inspect the current project Git repository status, changed files, DAG commit tree, branch list, or code diffs.",
869
+ parameters: {
870
+ action: {
871
+ type: "string",
872
+ enum: [
873
+ "status",
874
+ "log",
875
+ "diff",
876
+ "branches",
877
+ "commit",
878
+ "tags"
879
+ ],
880
+ description: "The Git query action: \"status\" (changes and branch info), \"log\" (commits with DAG topology rails), \"diff\" (code diffs), \"branches\" (branch list), \"commit\" (commit details), or \"tags\" (all repository tags)."
881
+ },
882
+ path: {
883
+ type: "string",
884
+ description: "Optional file path filter for diff or status inspection."
885
+ },
886
+ staged: {
887
+ type: "boolean",
888
+ description: "Whether to check staged changes when viewing diff (defaults to false for working tree changes)."
889
+ },
890
+ maxCount: {
891
+ type: "number",
892
+ description: "Maximum number of commits to retrieve when action is \"log\" (defaults to 30)."
893
+ },
894
+ commit: {
895
+ type: "string",
896
+ description: "Specific commit hash to inspect diff or details for."
897
+ }
898
+ },
899
+ output: {
900
+ schema: {
901
+ type: "object",
902
+ additionalProperties: true
903
+ },
904
+ render: (_args, value) => [{
905
+ type: "text",
906
+ text: JSON.stringify(value, null, 2)
907
+ }]
908
+ },
909
+ async execute(args, exec) {
910
+ const cwd = resolveDir(exec.agent?.session.id);
911
+ switch (args.action ?? "status") {
912
+ case "log": {
913
+ const commits = (await getGitLog(cwd, args.maxCount ?? 30)).map((c) => ({
914
+ hash: c.hash,
915
+ shortHash: c.shortHash,
916
+ author: c.author,
917
+ authorEmail: c.authorEmail,
918
+ relativeDate: c.relativeDate,
919
+ date: c.date,
920
+ subject: c.subject,
921
+ parents: c.parents ? [...c.parents] : [],
922
+ refs: c.refs ? [...c.refs] : [],
923
+ ...c.lane !== void 0 ? { lane: c.lane } : {},
924
+ ...c.laneColor !== void 0 ? { laneColor: c.laneColor } : {}
925
+ }));
926
+ return {
927
+ action: "log",
928
+ count: commits.length,
929
+ commits
930
+ };
931
+ }
932
+ case "commit": {
933
+ if (!args.commit) return {
934
+ action: "commit",
935
+ error: "Missing commit parameter"
936
+ };
937
+ const details = await getGitCommitDetails(cwd, args.commit);
938
+ if (!details) return {
939
+ action: "commit",
940
+ found: false
941
+ };
942
+ return {
943
+ action: "commit",
944
+ found: true,
945
+ commit: {
946
+ hash: details.commit.hash,
947
+ shortHash: details.commit.shortHash,
948
+ author: details.commit.author,
949
+ date: details.commit.date,
950
+ subject: details.commit.subject
951
+ },
952
+ files: details.files.map((f) => ({
953
+ path: f.path,
954
+ status: f.status,
955
+ additions: f.additions,
956
+ deletions: f.deletions
957
+ })),
958
+ totalAdditions: details.totalAdditions,
959
+ totalDeletions: details.totalDeletions
960
+ };
961
+ }
962
+ case "diff": {
963
+ const diff = await getGitDiff(cwd, {
964
+ ...args.path !== void 0 ? { path: args.path } : {},
965
+ ...args.staged !== void 0 ? { staged: args.staged } : {},
966
+ ...args.commit !== void 0 ? { commit: args.commit } : {}
967
+ });
968
+ return {
969
+ action: "diff",
970
+ files: diff.files.map((f) => ({
971
+ path: f.path,
972
+ additions: f.additions,
973
+ deletions: f.deletions
974
+ })),
975
+ rawDiff: diff.rawDiff
976
+ };
977
+ }
978
+ case "branches": {
979
+ const summary = await getGitAllBranches(cwd);
980
+ return {
981
+ action: "branches",
982
+ ...summary.current !== void 0 ? { current: summary.current } : {},
983
+ local: summary.local.map((b) => ({
984
+ name: b.name,
985
+ isCurrent: b.isCurrent,
986
+ ...b.upstream !== void 0 ? { upstream: b.upstream } : {},
987
+ ...b.ahead !== void 0 ? { ahead: b.ahead } : {},
988
+ ...b.behind !== void 0 ? { behind: b.behind } : {}
989
+ })),
990
+ remote: summary.remote.map((b) => ({
991
+ name: b.name,
992
+ isCurrent: b.isCurrent
993
+ }))
994
+ };
995
+ }
996
+ case "tags": {
997
+ const rawTags = await getGitTags(cwd);
998
+ return {
999
+ action: "tags",
1000
+ total: rawTags.length,
1001
+ tags: rawTags.map((t) => ({
1002
+ name: t.name,
1003
+ commitHash: t.commitHash,
1004
+ shortHash: t.shortHash,
1005
+ date: t.date,
1006
+ relativeDate: t.relativeDate,
1007
+ message: t.message,
1008
+ author: t.author,
1009
+ isAnnotated: t.isAnnotated
1010
+ }))
1011
+ };
1012
+ }
1013
+ default: {
1014
+ const status = await getGitStatus(cwd);
1015
+ return {
1016
+ action: "status",
1017
+ isRepo: status.isRepo,
1018
+ ...status.rootPath !== void 0 ? { rootPath: status.rootPath } : {},
1019
+ ...status.branch !== void 0 ? { branch: status.branch } : {},
1020
+ ...status.upstream !== void 0 ? { upstream: status.upstream } : {},
1021
+ ahead: status.ahead,
1022
+ behind: status.behind,
1023
+ staged: status.staged.map((f) => ({
1024
+ path: f.path,
1025
+ status: f.status,
1026
+ staged: f.staged
1027
+ })),
1028
+ unstaged: status.unstaged.map((f) => ({
1029
+ path: f.path,
1030
+ status: f.status,
1031
+ staged: f.staged
1032
+ })),
1033
+ untracked: status.untracked.map((f) => ({
1034
+ path: f.path,
1035
+ status: f.status,
1036
+ staged: f.staged
1037
+ })),
1038
+ totalChanges: status.totalChanges
1039
+ };
1040
+ }
1041
+ }
1042
+ },
1043
+ presentCall: (args) => ({
1044
+ card: "generic",
1045
+ title: `git_view (${args.action ?? "status"})`,
1046
+ rawInput: args
1047
+ })
1048
+ })), "experimental-git: register git_view tool");
1049
+ scope.effect(() => scope.tools.register(defineTool({
1050
+ name: "git_checkout",
1051
+ description: "Switch branch or create and checkout a new branch in the current project Git repository.",
1052
+ parameters: {
1053
+ branch: {
1054
+ type: "string",
1055
+ required: true,
1056
+ description: "The target branch name to switch to (e.g. \"feature/auth\", \"dev\", \"master\")."
1057
+ },
1058
+ create: {
1059
+ type: "boolean",
1060
+ description: "Whether to create a new branch from current HEAD and check it out (-b). Defaults to false."
1061
+ }
1062
+ },
1063
+ output: {
1064
+ schema: {
1065
+ type: "object",
1066
+ additionalProperties: true
1067
+ },
1068
+ render: (_args, val) => {
1069
+ const value = val;
1070
+ return [{
1071
+ type: "text",
1072
+ text: value.success ? `Successfully checked out branch '${value.branch ?? ""}'.` : `Failed to checkout branch '${value.branch ?? ""}': ${value.message ?? ""}`
1073
+ }];
1074
+ }
1075
+ },
1076
+ async execute(args, exec) {
1077
+ const cwd = resolveDir(exec.agent?.session.id);
1078
+ const branch = args.branch.trim();
1079
+ const result = await gitCheckout(cwd, branch, args.create ?? false);
1080
+ return {
1081
+ success: result.success,
1082
+ branch,
1083
+ message: result.message ?? ""
1084
+ };
1085
+ },
1086
+ presentCall: (args) => ({
1087
+ card: "generic",
1088
+ title: `git_checkout ${args.create ? "-b " : ""}${args.branch}`,
1089
+ rawInput: args
1090
+ })
1091
+ })), "experimental-git: register git_checkout tool");
1092
+ scope.effect(() => scope.tools.register(defineTool({
1093
+ name: "git_tag",
1094
+ description: "Create or delete a Git tag in the current project repository.",
1095
+ parameters: {
1096
+ name: {
1097
+ type: "string",
1098
+ required: true,
1099
+ description: "The tag name (e.g. \"v1.0.0\", \"release-20261020\")."
1100
+ },
1101
+ action: {
1102
+ type: "string",
1103
+ enum: ["create", "delete"],
1104
+ description: "Tag operation: \"create\" (default) or \"delete\"."
1105
+ },
1106
+ commit: {
1107
+ type: "string",
1108
+ description: "Target commit hash to place the tag on when creating (defaults to current HEAD)."
1109
+ },
1110
+ message: {
1111
+ type: "string",
1112
+ description: "Optional annotation message for an annotated tag (git tag -a -m)."
1113
+ }
1114
+ },
1115
+ output: {
1116
+ schema: {
1117
+ type: "object",
1118
+ additionalProperties: true
1119
+ },
1120
+ render: (_args, val) => {
1121
+ const value = val;
1122
+ return [{
1123
+ type: "text",
1124
+ text: value.message ?? (value.success ? "Success" : "Failed")
1125
+ }];
1126
+ }
1127
+ },
1128
+ async execute(args, exec) {
1129
+ const cwd = resolveDir(exec.agent?.session.id);
1130
+ const name = args.name.trim();
1131
+ if ((args.action ?? "create") === "delete") {
1132
+ const result = await gitDeleteTag(cwd, name);
1133
+ return {
1134
+ success: result.success,
1135
+ name,
1136
+ action: "delete",
1137
+ message: result.message ?? ""
1138
+ };
1139
+ }
1140
+ const result = await gitCreateTag(cwd, name, args.commit, args.message);
1141
+ return {
1142
+ success: result.success,
1143
+ name,
1144
+ action: "create",
1145
+ message: result.message ?? ""
1146
+ };
1147
+ },
1148
+ presentCall: (args) => ({
1149
+ card: "generic",
1150
+ title: `git_tag ${args.action ?? "create"} ${args.name}`,
1151
+ rawInput: args
1152
+ })
1153
+ })), "experimental-git: register git_tag tool");
1154
+ });
1155
+ }
1156
+ // src/index.ts
1157
+ /** Cordis plugin name */
1158
+ const name = "@civilization/dsh-git-workbench";
1159
+ /** Required host services */
1160
+ const inject = ["connection", "webServer"];
1161
+ /**
1162
+ * Apply the Git host plugin.
1163
+ * Sets up authenticated RPC handlers under `/git/*` and registers the model tool.
1164
+ * @param ctx - Context carrying the connection and optional session services.
1165
+ */
1166
+ function apply(ctx) {
1167
+ const host = ctx;
1168
+ const resolveTargetDir = (sessionId) => {
1169
+ if (sessionId) {
1170
+ const liveCwd = host.sessions?.get(sessionId)?.header?.cwd;
1171
+ if (liveCwd) return liveCwd;
1172
+ }
1173
+ return host.sandboxPolicy?.workspaceRoot ?? process.cwd();
1174
+ };
1175
+ host.connection.rpc.handle("/git", async (endpoint, payload, _signal) => {
1176
+ const body = typeof payload === "object" && payload !== null ? payload : {};
1177
+ const sessionId = typeof body.sessionId === "string" ? body.sessionId : void 0;
1178
+ const cwd = typeof body.path === "string" ? body.path : resolveTargetDir(sessionId);
1179
+ try {
1180
+ if (endpoint === "status") return {
1181
+ ok: true,
1182
+ value: await getGitStatus(cwd)
1183
+ };
1184
+ if (endpoint === "log") return {
1185
+ ok: true,
1186
+ value: await getGitLog(cwd, typeof body.maxCount === "number" ? body.maxCount : 50)
1187
+ };
1188
+ if (endpoint === "commitDetails") return {
1189
+ ok: true,
1190
+ value: await getGitCommitDetails(cwd, typeof body.commit === "string" ? body.commit : "")
1191
+ };
1192
+ if (endpoint === "diff") {
1193
+ const path = typeof body.filePath === "string" ? body.filePath : void 0;
1194
+ const staged = typeof body.staged === "boolean" ? body.staged : false;
1195
+ const commit = typeof body.commit === "string" ? body.commit : void 0;
1196
+ return {
1197
+ ok: true,
1198
+ value: await getGitDiff(cwd, {
1199
+ ...path !== void 0 ? { path } : {},
1200
+ staged,
1201
+ ...commit !== void 0 ? { commit } : {}
1202
+ })
1203
+ };
1204
+ }
1205
+ if (endpoint === "branches") return {
1206
+ ok: true,
1207
+ value: await getGitAllBranches(cwd)
1208
+ };
1209
+ if (endpoint === "checkout") return {
1210
+ ok: true,
1211
+ value: await gitCheckout(cwd, typeof body.branch === "string" ? body.branch : "", typeof body.create === "boolean" ? body.create : false)
1212
+ };
1213
+ if (endpoint === "stage") return {
1214
+ ok: true,
1215
+ value: await gitStage(cwd, Array.isArray(body.paths) ? body.paths : body.paths === "all" ? "all" : typeof body.path === "string" ? [body.path] : "all")
1216
+ };
1217
+ if (endpoint === "unstage") return {
1218
+ ok: true,
1219
+ value: await gitUnstage(cwd, Array.isArray(body.paths) ? body.paths : body.paths === "all" ? "all" : typeof body.path === "string" ? [body.path] : "all")
1220
+ };
1221
+ if (endpoint === "discard") return {
1222
+ ok: true,
1223
+ value: await gitDiscard(cwd, Array.isArray(body.paths) ? body.paths : typeof body.path === "string" ? [body.path] : [])
1224
+ };
1225
+ if (endpoint === "commit") return {
1226
+ ok: true,
1227
+ value: await gitCommit(cwd, typeof body.message === "string" ? body.message : "")
1228
+ };
1229
+ if (endpoint === "fetch") return {
1230
+ ok: true,
1231
+ value: await gitFetch(cwd)
1232
+ };
1233
+ if (endpoint === "pull") return {
1234
+ ok: true,
1235
+ value: await gitPull(cwd)
1236
+ };
1237
+ if (endpoint === "push") return {
1238
+ ok: true,
1239
+ value: await gitPush(cwd)
1240
+ };
1241
+ if (endpoint === "tags") return {
1242
+ ok: true,
1243
+ value: await getGitTags(cwd)
1244
+ };
1245
+ if (endpoint === "createTag") return {
1246
+ ok: true,
1247
+ value: await gitCreateTag(cwd, typeof body.name === "string" ? body.name : "", typeof body.commit === "string" ? body.commit : void 0, typeof body.message === "string" ? body.message : void 0)
1248
+ };
1249
+ if (endpoint === "deleteTag") return {
1250
+ ok: true,
1251
+ value: await gitDeleteTag(cwd, typeof body.name === "string" ? body.name : "")
1252
+ };
1253
+ return {
1254
+ ok: false,
1255
+ error: {
1256
+ code: "git/not_found",
1257
+ message: `Unknown git RPC endpoint: ${endpoint}`,
1258
+ details: {}
1259
+ }
1260
+ };
1261
+ } catch (error) {
1262
+ return {
1263
+ ok: false,
1264
+ error: {
1265
+ code: "git/error",
1266
+ message: error instanceof Error ? error.message : String(error),
1267
+ details: {}
1268
+ }
1269
+ };
1270
+ }
1271
+ });
1272
+ registerGitTool(ctx, resolveTargetDir);
1273
+ }
1274
+ export { apply, inject, name };