@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/LICENSE +201 -0
- package/README.md +119 -2
- package/cordis.patch.yml +3 -0
- package/icon.svg +26 -0
- package/lib/client.js +2943 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1274 -0
- package/package.json +91 -4
- package/src/client/GitBody.module.css +1283 -0
- package/src/client/GitBody.tsx +1496 -0
- package/src/client/GitBranchPill.module.css +292 -0
- package/src/client/GitBranchPill.tsx +368 -0
- package/src/client/GitTitle.tsx +27 -0
- package/src/client/definition.tsx +63 -0
- package/src/client/git-helpers.ts +351 -0
- package/src/client/index.ts +221 -0
- package/src/client/locales.ts +183 -0
- package/src/css.d.ts +4 -0
- package/src/git-executor.ts +1025 -0
- package/src/index.ts +220 -0
- package/src/tool.ts +299 -0
- package/src/types.ts +187 -0
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 };
|