@rethunk/mcp-multi-root-git 2.3.3 → 2.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/AGENTS.md +30 -3
- package/CHANGELOG.md +61 -0
- package/HUMANS.md +106 -2
- package/README.md +15 -1
- package/dist/server/batch-commit-tool.js +244 -19
- package/dist/server/coverage.js +22 -0
- package/dist/server/git-diff-tool.js +132 -0
- package/dist/server/git-fetch-tool.js +131 -0
- package/dist/server/git-push-tool.js +8 -1
- package/dist/server/git-show-tool.js +148 -0
- package/dist/server/git-stash-tool.js +131 -0
- package/dist/server/git-tag-tool.js +162 -0
- package/dist/server/git.js +18 -2
- package/dist/server/roots.js +8 -4
- package/dist/server/test-harness.js +77 -6
- package/dist/server/tool-parameter-schemas.js +94 -0
- package/dist/server/tools.js +11 -0
- package/docs/install.md +19 -2
- package/docs/mcp-tools.md +235 -5
- package/package.json +15 -8
- package/schemas/batch_commit.json +125 -0
- package/schemas/git_cherry_pick.json +63 -0
- package/schemas/git_diff.json +62 -0
- package/schemas/git_diff_summary.json +75 -0
- package/schemas/git_fetch.json +52 -0
- package/schemas/git_inventory.json +74 -0
- package/schemas/git_log.json +75 -0
- package/schemas/git_merge.json +79 -0
- package/schemas/git_parity.json +74 -0
- package/schemas/git_push.json +50 -0
- package/schemas/git_reset_soft.json +42 -0
- package/schemas/git_show.json +39 -0
- package/schemas/git_stash_apply.json +44 -0
- package/schemas/git_stash_list.json +30 -0
- package/schemas/git_status.json +49 -0
- package/schemas/git_tag.json +50 -0
- package/schemas/git_worktree_add.json +52 -0
- package/schemas/git_worktree_list.json +30 -0
- package/schemas/git_worktree_remove.json +48 -0
- package/schemas/index.json +108 -0
- package/schemas/list_presets.json +44 -0
- package/tool-parameters.schema.json +1125 -0
|
@@ -5,9 +5,24 @@ import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
|
5
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
6
|
import { requireSingleRepo } from "./roots.js";
|
|
7
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
const FileEntrySchema = z.union([
|
|
9
|
+
z.string().min(1),
|
|
10
|
+
z.object({
|
|
11
|
+
path: z.string().min(1).describe("File path relative to git root."),
|
|
12
|
+
lines: z
|
|
13
|
+
.object({
|
|
14
|
+
from: z.number().int().min(1).describe("Start line number (1-indexed)."),
|
|
15
|
+
to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
|
|
16
|
+
})
|
|
17
|
+
.describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
8
20
|
const CommitEntrySchema = z.object({
|
|
9
21
|
message: z.string().min(1).describe("Commit message."),
|
|
10
|
-
files: z
|
|
22
|
+
files: z
|
|
23
|
+
.array(FileEntrySchema)
|
|
24
|
+
.min(1)
|
|
25
|
+
.describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."),
|
|
11
26
|
});
|
|
12
27
|
const PushModeSchema = z
|
|
13
28
|
.enum(["never", "after"])
|
|
@@ -16,6 +31,132 @@ const PushModeSchema = z
|
|
|
16
31
|
.describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
|
|
17
32
|
"fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
|
|
18
33
|
"Enum reserved for future modes such as `force-with-lease`.");
|
|
34
|
+
const DryRunSchema = z
|
|
35
|
+
.boolean()
|
|
36
|
+
.optional()
|
|
37
|
+
.default(false)
|
|
38
|
+
.describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
|
|
39
|
+
"Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
|
|
40
|
+
/**
|
|
41
|
+
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
42
|
+
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
43
|
+
* Uses new file line numbers (after @@) to determine overlap.
|
|
44
|
+
*/
|
|
45
|
+
function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
46
|
+
const lines = diffContent.split("\n");
|
|
47
|
+
// Find file header lines (index, ---, +++)
|
|
48
|
+
const fileHeaderLines = [];
|
|
49
|
+
let firstHunkIdx = -1;
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
if (line === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
if (line.startsWith("@@")) {
|
|
55
|
+
firstHunkIdx = i;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
fileHeaderLines.push(line);
|
|
59
|
+
}
|
|
60
|
+
if (firstHunkIdx === -1) {
|
|
61
|
+
// No hunks found
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const result = [...fileHeaderLines];
|
|
65
|
+
let i = firstHunkIdx;
|
|
66
|
+
while (i < lines.length) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
if (line === undefined) {
|
|
69
|
+
i++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
73
|
+
const hunkMatch = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
|
|
74
|
+
if (hunkMatch) {
|
|
75
|
+
const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
|
|
76
|
+
const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
|
|
77
|
+
const hunkEnd = newStart + newCount - 1;
|
|
78
|
+
// Check if hunk overlaps with requested line range
|
|
79
|
+
const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
|
|
80
|
+
if (hasOverlap) {
|
|
81
|
+
// Add hunk header
|
|
82
|
+
result.push(line);
|
|
83
|
+
i++;
|
|
84
|
+
// Add hunk content until next hunk or EOF
|
|
85
|
+
while (i < lines.length) {
|
|
86
|
+
const contentLine = lines[i];
|
|
87
|
+
if (contentLine === undefined) {
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Stop at next hunk header
|
|
92
|
+
if (contentLine.startsWith("@@")) {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
result.push(contentLine);
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Skip hunk
|
|
101
|
+
i++;
|
|
102
|
+
while (i < lines.length) {
|
|
103
|
+
const contentLine = lines[i];
|
|
104
|
+
if (contentLine === undefined) {
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (contentLine.startsWith("@@")) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result.length > fileHeaderLines.length ? result.join("\n") : null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Stages a file with optional line range. If lines are provided, only hunks
|
|
123
|
+
* overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
|
|
124
|
+
*/
|
|
125
|
+
async function stageFile(gitTop, filePath, lines) {
|
|
126
|
+
if (!lines) {
|
|
127
|
+
// Simple case: stage the whole file
|
|
128
|
+
const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
|
|
129
|
+
return {
|
|
130
|
+
ok: addResult.ok,
|
|
131
|
+
error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Line range case: extract overlapping hunks and apply patch
|
|
135
|
+
const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
|
|
136
|
+
if (!diffResult.ok) {
|
|
137
|
+
return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
|
|
138
|
+
}
|
|
139
|
+
const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
|
|
140
|
+
if (!partialPatch) {
|
|
141
|
+
return { ok: false, error: "No hunks found in line range" };
|
|
142
|
+
}
|
|
143
|
+
// Write partial patch to temp file in the git repo and apply it to the index
|
|
144
|
+
const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
145
|
+
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
146
|
+
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
147
|
+
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
148
|
+
// Clean up temp file
|
|
149
|
+
try {
|
|
150
|
+
unlinkSync(tempPatchFile);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Ignore cleanup errors
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: applyResult.ok,
|
|
157
|
+
error: applyResult.ok ? undefined : (applyResult.stderr || applyResult.stdout).trim(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
19
160
|
/**
|
|
20
161
|
* After all commits succeed, push the current branch to its upstream.
|
|
21
162
|
* Commits are already applied at this point — do NOT attempt rollback on push failure.
|
|
@@ -39,7 +180,13 @@ export async function runPushAfter(gitTop) {
|
|
|
39
180
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
40
181
|
};
|
|
41
182
|
}
|
|
42
|
-
|
|
183
|
+
const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
branch,
|
|
187
|
+
upstream: t.upstream,
|
|
188
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
189
|
+
};
|
|
43
190
|
}
|
|
44
191
|
export function registerBatchCommitTool(server) {
|
|
45
192
|
server.addTool({
|
|
@@ -47,7 +194,8 @@ export function registerBatchCommitTool(server) {
|
|
|
47
194
|
description: "Create multiple sequential git commits in a single call. " +
|
|
48
195
|
"Each entry stages the listed files then commits with the given message. " +
|
|
49
196
|
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
50
|
-
"to its upstream once all commits succeed."
|
|
197
|
+
"to its upstream once all commits succeed. " +
|
|
198
|
+
"Optional `dryRun: true` previews what would be staged/committed without writing commits.",
|
|
51
199
|
annotations: {
|
|
52
200
|
readOnlyHint: false,
|
|
53
201
|
destructiveHint: false,
|
|
@@ -60,6 +208,7 @@ export function registerBatchCommitTool(server) {
|
|
|
60
208
|
.max(50)
|
|
61
209
|
.describe("Commits to create, applied in order."),
|
|
62
210
|
push: PushModeSchema,
|
|
211
|
+
dryRun: DryRunSchema,
|
|
63
212
|
}),
|
|
64
213
|
execute: async (args) => {
|
|
65
214
|
const pre = requireSingleRepo(server, args);
|
|
@@ -67,16 +216,30 @@ export function registerBatchCommitTool(server) {
|
|
|
67
216
|
return jsonRespond(pre.error);
|
|
68
217
|
const gitTop = pre.gitTop;
|
|
69
218
|
const results = [];
|
|
219
|
+
const stagedFilesForCleanup = new Set();
|
|
70
220
|
for (let i = 0; i < args.commits.length; i++) {
|
|
71
221
|
const entry = args.commits[i];
|
|
72
222
|
if (!entry)
|
|
73
223
|
break;
|
|
224
|
+
// Normalize file entries to { path, lines? } format
|
|
225
|
+
const fileEntries = [];
|
|
226
|
+
const filePaths = [];
|
|
227
|
+
for (const fileEntry of entry.files) {
|
|
228
|
+
if (typeof fileEntry === "string") {
|
|
229
|
+
fileEntries.push({ path: fileEntry });
|
|
230
|
+
filePaths.push(fileEntry);
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
fileEntries.push(fileEntry);
|
|
234
|
+
filePaths.push(fileEntry.path);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
74
237
|
// --- Validate all paths are under the git toplevel ---
|
|
75
238
|
const escapedPaths = [];
|
|
76
|
-
for (const
|
|
77
|
-
const abs = resolvePathForRepo(
|
|
239
|
+
for (const path of filePaths) {
|
|
240
|
+
const abs = resolvePathForRepo(path, gitTop);
|
|
78
241
|
if (!isStrictlyUnderGitTop(abs, gitTop)) {
|
|
79
|
-
escapedPaths.push(
|
|
242
|
+
escapedPaths.push(path);
|
|
80
243
|
}
|
|
81
244
|
}
|
|
82
245
|
if (escapedPaths.length > 0) {
|
|
@@ -84,53 +247,94 @@ export function registerBatchCommitTool(server) {
|
|
|
84
247
|
index: i,
|
|
85
248
|
ok: false,
|
|
86
249
|
message: entry.message,
|
|
87
|
-
files:
|
|
250
|
+
files: filePaths,
|
|
88
251
|
error: "path_escapes_repository",
|
|
89
252
|
detail: escapedPaths.join(", "),
|
|
90
253
|
});
|
|
91
254
|
break;
|
|
92
255
|
}
|
|
93
|
-
// --- Stage files ---
|
|
94
|
-
|
|
95
|
-
|
|
256
|
+
// --- Stage files (with optional line ranges) ---
|
|
257
|
+
let stagingFailed = false;
|
|
258
|
+
let stagingError = "";
|
|
259
|
+
for (const fileEntry of fileEntries) {
|
|
260
|
+
const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
|
|
261
|
+
if (!stageResult.ok) {
|
|
262
|
+
stagingFailed = true;
|
|
263
|
+
stagingError = stageResult.error || "Unknown error";
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (stagingFailed) {
|
|
96
268
|
results.push({
|
|
97
269
|
index: i,
|
|
98
270
|
ok: false,
|
|
99
271
|
message: entry.message,
|
|
100
|
-
files:
|
|
272
|
+
files: filePaths,
|
|
101
273
|
error: "stage_failed",
|
|
102
|
-
detail:
|
|
274
|
+
detail: stagingError,
|
|
275
|
+
...spreadDefined("output", stagingError || undefined),
|
|
103
276
|
});
|
|
104
277
|
break;
|
|
105
278
|
}
|
|
279
|
+
// Track staged files for cleanup in dry-run
|
|
280
|
+
if (args.dryRun) {
|
|
281
|
+
for (const path of filePaths) {
|
|
282
|
+
stagedFilesForCleanup.add(path);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// --- Dry-run mode: collect preview and unstage ---
|
|
286
|
+
if (args.dryRun) {
|
|
287
|
+
// Get diff stat for this staged entry
|
|
288
|
+
const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
|
|
289
|
+
const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
|
|
290
|
+
results.push({
|
|
291
|
+
index: i,
|
|
292
|
+
ok: true,
|
|
293
|
+
message: entry.message,
|
|
294
|
+
files: filePaths,
|
|
295
|
+
staged: filePaths,
|
|
296
|
+
...spreadDefined("diffStat", diffStat || undefined),
|
|
297
|
+
});
|
|
298
|
+
continue; // Skip actual commit in dry-run mode
|
|
299
|
+
}
|
|
106
300
|
// --- Commit ---
|
|
107
301
|
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
108
302
|
if (!commitResult.ok) {
|
|
303
|
+
const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
|
|
109
304
|
results.push({
|
|
110
305
|
index: i,
|
|
111
306
|
ok: false,
|
|
112
307
|
message: entry.message,
|
|
113
|
-
files:
|
|
308
|
+
files: filePaths,
|
|
114
309
|
error: "commit_failed",
|
|
115
|
-
detail:
|
|
310
|
+
detail: gitOutput,
|
|
311
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
116
312
|
});
|
|
117
313
|
break;
|
|
118
314
|
}
|
|
119
315
|
// --- Extract SHA from commit output ---
|
|
120
316
|
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
317
|
+
const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
|
|
121
318
|
results.push({
|
|
122
319
|
index: i,
|
|
123
320
|
ok: true,
|
|
124
321
|
sha: shaMatch?.[1],
|
|
125
322
|
message: entry.message,
|
|
126
|
-
files:
|
|
323
|
+
files: filePaths,
|
|
324
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
127
325
|
});
|
|
128
326
|
}
|
|
327
|
+
// --- In dry-run mode, unstage all files ---
|
|
328
|
+
if (args.dryRun && stagedFilesForCleanup.size > 0) {
|
|
329
|
+
const filesToReset = Array.from(stagedFilesForCleanup);
|
|
330
|
+
await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
|
|
331
|
+
}
|
|
129
332
|
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
130
|
-
// --- Optional push after all commits succeed ---
|
|
131
|
-
const push = allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
333
|
+
// --- Optional push after all commits succeed (not in dry-run mode) ---
|
|
334
|
+
const push = !args.dryRun && allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
132
335
|
if (args.format === "json") {
|
|
133
336
|
return jsonRespond({
|
|
337
|
+
...spreadWhen(args.dryRun, { dryRun: true }),
|
|
134
338
|
ok: allOk,
|
|
135
339
|
committed: results.filter((r) => r.ok).length,
|
|
136
340
|
total: args.commits.length,
|
|
@@ -140,8 +344,11 @@ export function registerBatchCommitTool(server) {
|
|
|
140
344
|
...spreadDefined("sha", r.sha),
|
|
141
345
|
message: r.message,
|
|
142
346
|
files: r.files,
|
|
347
|
+
...spreadDefined("staged", r.staged),
|
|
348
|
+
...spreadDefined("diffStat", r.diffStat),
|
|
143
349
|
...spreadDefined("error", r.error),
|
|
144
350
|
...spreadDefined("detail", r.detail),
|
|
351
|
+
...spreadDefined("output", r.output),
|
|
145
352
|
})),
|
|
146
353
|
...spreadWhen(push !== undefined, {
|
|
147
354
|
push: {
|
|
@@ -150,15 +357,17 @@ export function registerBatchCommitTool(server) {
|
|
|
150
357
|
...spreadDefined("upstream", push?.upstream),
|
|
151
358
|
...spreadDefined("error", push?.error),
|
|
152
359
|
...spreadDefined("detail", push?.detail),
|
|
360
|
+
...spreadDefined("output", push?.output),
|
|
153
361
|
},
|
|
154
362
|
}),
|
|
155
363
|
});
|
|
156
364
|
}
|
|
157
365
|
// --- Markdown ---
|
|
158
366
|
const lines = [];
|
|
367
|
+
const dryRunPrefix = args.dryRun ? "DRY RUN — " : "";
|
|
159
368
|
const header = allOk
|
|
160
|
-
? `# Batch commit: ${results.length}/${args.commits.length} committed`
|
|
161
|
-
: `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
369
|
+
? `# Batch commit: ${dryRunPrefix}${results.length}/${args.commits.length} committed`
|
|
370
|
+
: `# Batch commit: ${dryRunPrefix}${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
162
371
|
lines.push(header, "");
|
|
163
372
|
for (const r of results) {
|
|
164
373
|
const icon = r.ok ? "✓" : "✗";
|
|
@@ -167,11 +376,24 @@ export function registerBatchCommitTool(server) {
|
|
|
167
376
|
if (!r.ok && r.detail) {
|
|
168
377
|
lines.push(` Error: ${r.error} — ${r.detail}`);
|
|
169
378
|
}
|
|
379
|
+
if (args.dryRun && r.staged) {
|
|
380
|
+
lines.push(` Staged: ${r.staged.join(", ")}`);
|
|
381
|
+
}
|
|
382
|
+
if (args.dryRun && r.diffStat) {
|
|
383
|
+
lines.push(` Diff stat:`);
|
|
384
|
+
lines.push(` ${r.diffStat.replace(/\n/g, "\n ")}`);
|
|
385
|
+
}
|
|
386
|
+
if (r.output) {
|
|
387
|
+
lines.push(` Output: ${r.output.replace(/\n/g, "\n ")}`);
|
|
388
|
+
}
|
|
170
389
|
}
|
|
171
390
|
if (!allOk && results.length < args.commits.length) {
|
|
172
391
|
const skipped = args.commits.length - results.length;
|
|
173
392
|
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
174
393
|
}
|
|
394
|
+
if (args.dryRun) {
|
|
395
|
+
lines.push("", "**DRY RUN — no commits written. All staged files have been unstaged.**");
|
|
396
|
+
}
|
|
175
397
|
if (push) {
|
|
176
398
|
lines.push("");
|
|
177
399
|
if (push.ok) {
|
|
@@ -180,6 +402,9 @@ export function registerBatchCommitTool(server) {
|
|
|
180
402
|
else {
|
|
181
403
|
lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
|
|
182
404
|
}
|
|
405
|
+
if (push.output) {
|
|
406
|
+
lines.push(` Output: ${push.output.replace(/\n/g, "\n ")}`);
|
|
407
|
+
}
|
|
183
408
|
}
|
|
184
409
|
return lines.join("\n");
|
|
185
410
|
},
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function parseAllFilesLineCoverage(output) {
|
|
2
|
+
const rows = output
|
|
3
|
+
.split(/\r?\n/)
|
|
4
|
+
.map((line) => line.trim())
|
|
5
|
+
.filter((line) => line.includes("|"));
|
|
6
|
+
const header = rows.find((line) => /^File\s*\|/i.test(line));
|
|
7
|
+
if (!header)
|
|
8
|
+
return null;
|
|
9
|
+
const columns = header.split("|").map((column) => column.trim().toLowerCase());
|
|
10
|
+
const lineIndex = columns.indexOf("% lines");
|
|
11
|
+
if (lineIndex === -1)
|
|
12
|
+
return null;
|
|
13
|
+
const allFiles = rows.find((line) => /^All files\s*\|/i.test(line));
|
|
14
|
+
if (!allFiles)
|
|
15
|
+
return null;
|
|
16
|
+
const cells = allFiles.split("|").map((cell) => cell.trim());
|
|
17
|
+
const raw = cells[lineIndex];
|
|
18
|
+
if (!raw)
|
|
19
|
+
return null;
|
|
20
|
+
const value = Number.parseFloat(raw);
|
|
21
|
+
return Number.isFinite(value) ? value : null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/** Build the diff args array from parameters. */
|
|
10
|
+
function buildDiffArgs(opts) {
|
|
11
|
+
const args = ["diff"];
|
|
12
|
+
// Handle staged flag first
|
|
13
|
+
if (opts.staged === true) {
|
|
14
|
+
args.push("--staged");
|
|
15
|
+
}
|
|
16
|
+
else if (opts.base || opts.head) {
|
|
17
|
+
// Range-based diff: base..head or base...head
|
|
18
|
+
// If only base is given, use base~0..HEAD (implicit HEAD)
|
|
19
|
+
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
20
|
+
const headStr = opts.head?.trim() ?? "HEAD";
|
|
21
|
+
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
|
|
22
|
+
return { ok: false, error: "unsafe_range_token" };
|
|
23
|
+
}
|
|
24
|
+
// Use two-dot range: base..head
|
|
25
|
+
args.push(`${baseStr}..${headStr}`);
|
|
26
|
+
}
|
|
27
|
+
// Scope to path if provided
|
|
28
|
+
if (opts.path?.trim()) {
|
|
29
|
+
args.push("--", opts.path.trim());
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, args };
|
|
32
|
+
}
|
|
33
|
+
/** Human-readable label for the range. */
|
|
34
|
+
function rangeLabel(opts) {
|
|
35
|
+
let label = "";
|
|
36
|
+
if (opts.staged === true) {
|
|
37
|
+
label = "staged changes";
|
|
38
|
+
}
|
|
39
|
+
else if (opts.base || opts.head) {
|
|
40
|
+
const baseStr = opts.base?.trim() ?? "HEAD";
|
|
41
|
+
const headStr = opts.head?.trim() ?? "HEAD";
|
|
42
|
+
label = `${baseStr}..${headStr}`;
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
label = "unstaged changes";
|
|
46
|
+
}
|
|
47
|
+
if (opts.path?.trim()) {
|
|
48
|
+
label += ` (${opts.path.trim()})`;
|
|
49
|
+
}
|
|
50
|
+
return label;
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tool registration
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export function registerGitDiffTool(server) {
|
|
56
|
+
server.addTool({
|
|
57
|
+
name: "git_diff",
|
|
58
|
+
description: "Get diff text for scoped file or range. Returns the raw diff output. " +
|
|
59
|
+
"Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
|
|
60
|
+
"and `path` to scope to a specific file.",
|
|
61
|
+
annotations: {
|
|
62
|
+
readOnlyHint: true,
|
|
63
|
+
},
|
|
64
|
+
parameters: WorkspacePickSchema.extend({
|
|
65
|
+
base: z
|
|
66
|
+
.string()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe('Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
|
|
69
|
+
"If omitted and `staged: false`, shows unstaged changes."),
|
|
70
|
+
head: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
|
|
74
|
+
"Only used if `base` is provided."),
|
|
75
|
+
path: z
|
|
76
|
+
.string()
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('Scope diff to a single file path (e.g. "src/main.ts").'),
|
|
79
|
+
staged: z
|
|
80
|
+
.boolean()
|
|
81
|
+
.optional()
|
|
82
|
+
.default(false)
|
|
83
|
+
.describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
|
|
84
|
+
}),
|
|
85
|
+
execute: async (args) => {
|
|
86
|
+
const pre = requireSingleRepo(server, args);
|
|
87
|
+
if (!pre.ok)
|
|
88
|
+
return jsonRespond(pre.error);
|
|
89
|
+
const gitTop = pre.gitTop;
|
|
90
|
+
// Build git diff args
|
|
91
|
+
const diffArgsResult = buildDiffArgs({
|
|
92
|
+
base: args.base,
|
|
93
|
+
head: args.head,
|
|
94
|
+
path: args.path,
|
|
95
|
+
staged: args.staged,
|
|
96
|
+
});
|
|
97
|
+
if (!diffArgsResult.ok) {
|
|
98
|
+
return jsonRespond({ error: diffArgsResult.error });
|
|
99
|
+
}
|
|
100
|
+
// Run git diff
|
|
101
|
+
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return jsonRespond({
|
|
104
|
+
error: "git_diff_failed",
|
|
105
|
+
detail: (result.stderr || result.stdout).trim(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const label = rangeLabel({
|
|
109
|
+
base: args.base,
|
|
110
|
+
head: args.head,
|
|
111
|
+
path: args.path,
|
|
112
|
+
staged: args.staged,
|
|
113
|
+
});
|
|
114
|
+
if (args.format === "json") {
|
|
115
|
+
return jsonRespond({
|
|
116
|
+
range: label,
|
|
117
|
+
diff: result.stdout,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Markdown output
|
|
121
|
+
const lines = [];
|
|
122
|
+
lines.push(`# Diff: ${label}`, "");
|
|
123
|
+
if (result.stdout.trim()) {
|
|
124
|
+
lines.push("```diff", result.stdout.trimEnd(), "```");
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
lines.push("_(no changes)_");
|
|
128
|
+
}
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { jsonRespond } from "./json.js";
|
|
4
|
+
import { requireSingleRepo } from "./roots.js";
|
|
5
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Parse `git fetch` output to extract updated and new refs.
|
|
11
|
+
* Lines containing "[new" indicate new refs (new branch, new tag, new ref).
|
|
12
|
+
* Lines with " -> " but not containing "[new" indicate updated refs.
|
|
13
|
+
*/
|
|
14
|
+
function parseGitFetchOutput(output) {
|
|
15
|
+
const lines = output.split("\n");
|
|
16
|
+
const updatedRefs = [];
|
|
17
|
+
const newRefs = [];
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed)
|
|
21
|
+
continue;
|
|
22
|
+
// Lines containing "[new" indicate new refs (e.g. "[new branch]", "[new tag]", "[new ref]")
|
|
23
|
+
if (trimmed.includes("[new")) {
|
|
24
|
+
newRefs.push(trimmed);
|
|
25
|
+
}
|
|
26
|
+
// Lines with " -> " that don't contain "[new" indicate ref updates
|
|
27
|
+
else if (trimmed.includes(" -> ")) {
|
|
28
|
+
updatedRefs.push(trimmed);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { updatedRefs, newRefs };
|
|
32
|
+
}
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Tool registration
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export function registerGitFetchTool(server) {
|
|
37
|
+
server.addTool({
|
|
38
|
+
name: "git_fetch",
|
|
39
|
+
description: "Fetch updates from a remote repository without modifying the working tree. " +
|
|
40
|
+
"Returns structured output distinguishing updated refs from new refs.",
|
|
41
|
+
annotations: {
|
|
42
|
+
readOnlyHint: false, // Fetch modifies refs but not working tree; not strictly read-only but safe
|
|
43
|
+
},
|
|
44
|
+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
|
|
45
|
+
.pick({
|
|
46
|
+
workspaceRoot: true,
|
|
47
|
+
rootIndex: true,
|
|
48
|
+
format: true,
|
|
49
|
+
})
|
|
50
|
+
.extend({
|
|
51
|
+
remote: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.default("origin")
|
|
55
|
+
.describe("Remote to fetch from (default: origin)."),
|
|
56
|
+
branch: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe("If specified: fetch only this branch (e.g. 'main')."),
|
|
60
|
+
prune: z
|
|
61
|
+
.boolean()
|
|
62
|
+
.optional()
|
|
63
|
+
.default(false)
|
|
64
|
+
.describe("Pass --prune to remove deleted remote branches (default: false)."),
|
|
65
|
+
tags: z
|
|
66
|
+
.boolean()
|
|
67
|
+
.optional()
|
|
68
|
+
.default(false)
|
|
69
|
+
.describe("Pass --tags to also fetch all tags (default: false)."),
|
|
70
|
+
}),
|
|
71
|
+
execute: async (args) => {
|
|
72
|
+
const pre = requireSingleRepo(server, args);
|
|
73
|
+
if (!pre.ok) {
|
|
74
|
+
return jsonRespond(pre.error);
|
|
75
|
+
}
|
|
76
|
+
const gitTop = pre.gitTop;
|
|
77
|
+
const remote = (args.remote ?? "origin").trim();
|
|
78
|
+
const branch = args.branch?.trim();
|
|
79
|
+
const prune = args.prune === true;
|
|
80
|
+
const tags = args.tags === true;
|
|
81
|
+
// Build git fetch command
|
|
82
|
+
const fetchArgs = ["fetch"];
|
|
83
|
+
if (prune) {
|
|
84
|
+
fetchArgs.push("--prune");
|
|
85
|
+
}
|
|
86
|
+
if (tags) {
|
|
87
|
+
fetchArgs.push("--tags");
|
|
88
|
+
}
|
|
89
|
+
fetchArgs.push(remote);
|
|
90
|
+
if (branch) {
|
|
91
|
+
fetchArgs.push(branch);
|
|
92
|
+
}
|
|
93
|
+
const result = await spawnGitAsync(gitTop, fetchArgs);
|
|
94
|
+
const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
|
|
95
|
+
const fetchResult = {
|
|
96
|
+
ok: result.ok,
|
|
97
|
+
remote,
|
|
98
|
+
updatedRefs,
|
|
99
|
+
newRefs,
|
|
100
|
+
output: (result.stdout + result.stderr).trim(),
|
|
101
|
+
};
|
|
102
|
+
if (args.format === "json") {
|
|
103
|
+
return jsonRespond(fetchResult);
|
|
104
|
+
}
|
|
105
|
+
// Markdown output
|
|
106
|
+
const lines = [`# Git fetch from '${remote}'`];
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
lines.push("", "**Status**: Failed", "");
|
|
109
|
+
lines.push("```", result.stdout || result.stderr || "(no output)", "```");
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
lines.push("", "**Status**: Success", "");
|
|
113
|
+
if (updatedRefs.length > 0) {
|
|
114
|
+
lines.push("## Updated refs", "");
|
|
115
|
+
for (const ref of updatedRefs) {
|
|
116
|
+
lines.push(`- ${ref}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (newRefs.length > 0) {
|
|
120
|
+
lines.push("", "## New refs", "");
|
|
121
|
+
for (const ref of newRefs) {
|
|
122
|
+
lines.push(`- ${ref}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (updatedRefs.length === 0 && newRefs.length === 0 && result.stdout.trim()) {
|
|
126
|
+
lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|