@rethunk/mcp-multi-root-git 2.3.4 → 2.5.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 -4
- package/CHANGELOG.md +74 -0
- package/HUMANS.md +54 -5
- package/README.md +15 -1
- package/dist/server/batch-commit-tool.js +266 -19
- package/dist/server/git-cherry-pick-tool.js +32 -8
- package/dist/server/git-diff-summary-tool.js +39 -21
- package/dist/server/git-diff-tool.js +132 -0
- package/dist/server/git-fetch-tool.js +131 -0
- package/dist/server/git-log-tool.js +37 -0
- package/dist/server/git-push-tool.js +8 -1
- package/dist/server/git-refs.js +72 -0
- 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 +35 -4
- package/dist/server/roots.js +8 -4
- package/dist/server/test-harness.js +68 -5
- package/dist/server/tool-parameter-schemas.js +21 -1
- package/dist/server/tools.js +11 -0
- package/docs/install.md +19 -2
- package/docs/mcp-tools.md +443 -10
- package/package.json +18 -6
- package/schemas/batch_commit.json +125 -0
- package/schemas/git_cherry_pick.json +69 -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 +77 -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 +327 -6
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
4
|
import { spawnGitAsync } from "./git.js";
|
|
@@ -5,9 +6,26 @@ import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
|
5
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
7
|
import { requireSingleRepo } from "./roots.js";
|
|
7
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
9
|
+
const FileEntrySchema = z.union([
|
|
10
|
+
z.string().min(1),
|
|
11
|
+
z.object({
|
|
12
|
+
path: z.string().min(1).describe("File path relative to git root."),
|
|
13
|
+
lines: z
|
|
14
|
+
.object({
|
|
15
|
+
from: z.number().int().min(1).describe("Start line number (1-indexed)."),
|
|
16
|
+
to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
|
|
17
|
+
})
|
|
18
|
+
.describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
|
|
19
|
+
}),
|
|
20
|
+
]);
|
|
8
21
|
const CommitEntrySchema = z.object({
|
|
9
22
|
message: z.string().min(1).describe("Commit message."),
|
|
10
|
-
files: z
|
|
23
|
+
files: z
|
|
24
|
+
.array(FileEntrySchema)
|
|
25
|
+
.min(1)
|
|
26
|
+
.describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. " +
|
|
27
|
+
"Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. " +
|
|
28
|
+
"Combining { path, lines } with a deleted file is an error."),
|
|
11
29
|
});
|
|
12
30
|
const PushModeSchema = z
|
|
13
31
|
.enum(["never", "after"])
|
|
@@ -16,6 +34,151 @@ const PushModeSchema = z
|
|
|
16
34
|
.describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
|
|
17
35
|
"fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
|
|
18
36
|
"Enum reserved for future modes such as `force-with-lease`.");
|
|
37
|
+
const DryRunSchema = z
|
|
38
|
+
.boolean()
|
|
39
|
+
.optional()
|
|
40
|
+
.default(false)
|
|
41
|
+
.describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
|
|
42
|
+
"Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
|
|
43
|
+
/**
|
|
44
|
+
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
45
|
+
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
46
|
+
* Uses new file line numbers (after @@) to determine overlap.
|
|
47
|
+
*/
|
|
48
|
+
function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
49
|
+
const lines = diffContent.split("\n");
|
|
50
|
+
// Find file header lines (index, ---, +++)
|
|
51
|
+
const fileHeaderLines = [];
|
|
52
|
+
let firstHunkIdx = -1;
|
|
53
|
+
for (let i = 0; i < lines.length; i++) {
|
|
54
|
+
const line = lines[i];
|
|
55
|
+
if (line === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
if (line.startsWith("@@")) {
|
|
58
|
+
firstHunkIdx = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
fileHeaderLines.push(line);
|
|
62
|
+
}
|
|
63
|
+
if (firstHunkIdx === -1) {
|
|
64
|
+
// No hunks found
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const result = [...fileHeaderLines];
|
|
68
|
+
let i = firstHunkIdx;
|
|
69
|
+
while (i < lines.length) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
if (line === undefined) {
|
|
72
|
+
i++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
76
|
+
const hunkMatch = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
|
|
77
|
+
if (hunkMatch) {
|
|
78
|
+
const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
|
|
79
|
+
const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
|
|
80
|
+
// newCount=0 means pure deletion; hunkEnd must equal newStart so ranges
|
|
81
|
+
// that include newStart correctly capture the deletion.
|
|
82
|
+
const hunkEnd = newCount === 0 ? newStart : newStart + newCount - 1;
|
|
83
|
+
// Check if hunk overlaps with requested line range
|
|
84
|
+
const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
|
|
85
|
+
if (hasOverlap) {
|
|
86
|
+
// Add hunk header
|
|
87
|
+
result.push(line);
|
|
88
|
+
i++;
|
|
89
|
+
// Add hunk content until next hunk or EOF
|
|
90
|
+
while (i < lines.length) {
|
|
91
|
+
const contentLine = lines[i];
|
|
92
|
+
if (contentLine === undefined) {
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Stop at next hunk header
|
|
97
|
+
if (contentLine.startsWith("@@")) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
result.push(contentLine);
|
|
101
|
+
i++;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Skip hunk
|
|
106
|
+
i++;
|
|
107
|
+
while (i < lines.length) {
|
|
108
|
+
const contentLine = lines[i];
|
|
109
|
+
if (contentLine === undefined) {
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (contentLine.startsWith("@@")) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
i++;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return result.length > fileHeaderLines.length ? result.join("\n") : null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Stages a file with optional line range. If lines are provided, only hunks
|
|
128
|
+
* overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
|
|
129
|
+
*/
|
|
130
|
+
async function stageFile(gitTop, filePath, lines) {
|
|
131
|
+
const absPath = resolvePathForRepo(filePath, gitTop);
|
|
132
|
+
const fileOnDisk = existsSync(absPath);
|
|
133
|
+
if (!fileOnDisk) {
|
|
134
|
+
if (lines) {
|
|
135
|
+
return { ok: false, error: "cannot stage line range for deleted file" };
|
|
136
|
+
}
|
|
137
|
+
// File missing on disk — stage as removal if tracked in HEAD
|
|
138
|
+
const lsResult = await spawnGitAsync(gitTop, ["ls-files", "--error-unmatch", "--", filePath]);
|
|
139
|
+
if (!lsResult.ok) {
|
|
140
|
+
return { ok: false, error: `pathspec '${filePath}' did not match any files` };
|
|
141
|
+
}
|
|
142
|
+
const rmResult = await spawnGitAsync(gitTop, ["rm", "--cached", "--", filePath]);
|
|
143
|
+
return {
|
|
144
|
+
ok: rmResult.ok,
|
|
145
|
+
error: rmResult.ok ? undefined : (rmResult.stderr || rmResult.stdout).trim(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!lines) {
|
|
149
|
+
// Simple case: stage the whole file
|
|
150
|
+
const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
|
|
151
|
+
return {
|
|
152
|
+
ok: addResult.ok,
|
|
153
|
+
error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Line range case: extract overlapping hunks and apply patch
|
|
157
|
+
const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
|
|
158
|
+
if (!diffResult.ok) {
|
|
159
|
+
return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
|
|
160
|
+
}
|
|
161
|
+
const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
|
|
162
|
+
if (!partialPatch) {
|
|
163
|
+
return { ok: false, error: "No hunks found in line range" };
|
|
164
|
+
}
|
|
165
|
+
// Write partial patch to temp file in the git repo and apply it to the index
|
|
166
|
+
const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
167
|
+
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
168
|
+
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
169
|
+
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
170
|
+
// Clean up temp file
|
|
171
|
+
try {
|
|
172
|
+
unlinkSync(tempPatchFile);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Ignore cleanup errors
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
ok: applyResult.ok,
|
|
179
|
+
error: applyResult.ok ? undefined : (applyResult.stderr || applyResult.stdout).trim(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
19
182
|
/**
|
|
20
183
|
* After all commits succeed, push the current branch to its upstream.
|
|
21
184
|
* Commits are already applied at this point — do NOT attempt rollback on push failure.
|
|
@@ -39,7 +202,13 @@ export async function runPushAfter(gitTop) {
|
|
|
39
202
|
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
40
203
|
};
|
|
41
204
|
}
|
|
42
|
-
|
|
205
|
+
const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
branch,
|
|
209
|
+
upstream: t.upstream,
|
|
210
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
211
|
+
};
|
|
43
212
|
}
|
|
44
213
|
export function registerBatchCommitTool(server) {
|
|
45
214
|
server.addTool({
|
|
@@ -47,7 +216,8 @@ export function registerBatchCommitTool(server) {
|
|
|
47
216
|
description: "Create multiple sequential git commits in a single call. " +
|
|
48
217
|
"Each entry stages the listed files then commits with the given message. " +
|
|
49
218
|
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
50
|
-
"to its upstream once all commits succeed."
|
|
219
|
+
"to its upstream once all commits succeed. " +
|
|
220
|
+
"Optional `dryRun: true` previews what would be staged/committed without writing commits.",
|
|
51
221
|
annotations: {
|
|
52
222
|
readOnlyHint: false,
|
|
53
223
|
destructiveHint: false,
|
|
@@ -60,6 +230,7 @@ export function registerBatchCommitTool(server) {
|
|
|
60
230
|
.max(50)
|
|
61
231
|
.describe("Commits to create, applied in order."),
|
|
62
232
|
push: PushModeSchema,
|
|
233
|
+
dryRun: DryRunSchema,
|
|
63
234
|
}),
|
|
64
235
|
execute: async (args) => {
|
|
65
236
|
const pre = requireSingleRepo(server, args);
|
|
@@ -67,16 +238,30 @@ export function registerBatchCommitTool(server) {
|
|
|
67
238
|
return jsonRespond(pre.error);
|
|
68
239
|
const gitTop = pre.gitTop;
|
|
69
240
|
const results = [];
|
|
241
|
+
const stagedFilesForCleanup = new Set();
|
|
70
242
|
for (let i = 0; i < args.commits.length; i++) {
|
|
71
243
|
const entry = args.commits[i];
|
|
72
244
|
if (!entry)
|
|
73
245
|
break;
|
|
246
|
+
// Normalize file entries to { path, lines? } format
|
|
247
|
+
const fileEntries = [];
|
|
248
|
+
const filePaths = [];
|
|
249
|
+
for (const fileEntry of entry.files) {
|
|
250
|
+
if (typeof fileEntry === "string") {
|
|
251
|
+
fileEntries.push({ path: fileEntry });
|
|
252
|
+
filePaths.push(fileEntry);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
fileEntries.push(fileEntry);
|
|
256
|
+
filePaths.push(fileEntry.path);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
74
259
|
// --- Validate all paths are under the git toplevel ---
|
|
75
260
|
const escapedPaths = [];
|
|
76
|
-
for (const
|
|
77
|
-
const abs = resolvePathForRepo(
|
|
261
|
+
for (const path of filePaths) {
|
|
262
|
+
const abs = resolvePathForRepo(path, gitTop);
|
|
78
263
|
if (!isStrictlyUnderGitTop(abs, gitTop)) {
|
|
79
|
-
escapedPaths.push(
|
|
264
|
+
escapedPaths.push(path);
|
|
80
265
|
}
|
|
81
266
|
}
|
|
82
267
|
if (escapedPaths.length > 0) {
|
|
@@ -84,53 +269,94 @@ export function registerBatchCommitTool(server) {
|
|
|
84
269
|
index: i,
|
|
85
270
|
ok: false,
|
|
86
271
|
message: entry.message,
|
|
87
|
-
files:
|
|
272
|
+
files: filePaths,
|
|
88
273
|
error: "path_escapes_repository",
|
|
89
274
|
detail: escapedPaths.join(", "),
|
|
90
275
|
});
|
|
91
276
|
break;
|
|
92
277
|
}
|
|
93
|
-
// --- Stage files ---
|
|
94
|
-
|
|
95
|
-
|
|
278
|
+
// --- Stage files (with optional line ranges) ---
|
|
279
|
+
let stagingFailed = false;
|
|
280
|
+
let stagingError = "";
|
|
281
|
+
for (const fileEntry of fileEntries) {
|
|
282
|
+
const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
|
|
283
|
+
if (!stageResult.ok) {
|
|
284
|
+
stagingFailed = true;
|
|
285
|
+
stagingError = stageResult.error || "Unknown error";
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (stagingFailed) {
|
|
96
290
|
results.push({
|
|
97
291
|
index: i,
|
|
98
292
|
ok: false,
|
|
99
293
|
message: entry.message,
|
|
100
|
-
files:
|
|
294
|
+
files: filePaths,
|
|
101
295
|
error: "stage_failed",
|
|
102
|
-
detail:
|
|
296
|
+
detail: stagingError,
|
|
297
|
+
...spreadDefined("output", stagingError || undefined),
|
|
103
298
|
});
|
|
104
299
|
break;
|
|
105
300
|
}
|
|
301
|
+
// Track staged files for cleanup in dry-run
|
|
302
|
+
if (args.dryRun) {
|
|
303
|
+
for (const path of filePaths) {
|
|
304
|
+
stagedFilesForCleanup.add(path);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// --- Dry-run mode: collect preview and unstage ---
|
|
308
|
+
if (args.dryRun) {
|
|
309
|
+
// Get diff stat for this staged entry
|
|
310
|
+
const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
|
|
311
|
+
const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
|
|
312
|
+
results.push({
|
|
313
|
+
index: i,
|
|
314
|
+
ok: true,
|
|
315
|
+
message: entry.message,
|
|
316
|
+
files: filePaths,
|
|
317
|
+
staged: filePaths,
|
|
318
|
+
...spreadDefined("diffStat", diffStat || undefined),
|
|
319
|
+
});
|
|
320
|
+
continue; // Skip actual commit in dry-run mode
|
|
321
|
+
}
|
|
106
322
|
// --- Commit ---
|
|
107
323
|
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
108
324
|
if (!commitResult.ok) {
|
|
325
|
+
const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
|
|
109
326
|
results.push({
|
|
110
327
|
index: i,
|
|
111
328
|
ok: false,
|
|
112
329
|
message: entry.message,
|
|
113
|
-
files:
|
|
330
|
+
files: filePaths,
|
|
114
331
|
error: "commit_failed",
|
|
115
|
-
detail:
|
|
332
|
+
detail: gitOutput,
|
|
333
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
116
334
|
});
|
|
117
335
|
break;
|
|
118
336
|
}
|
|
119
337
|
// --- Extract SHA from commit output ---
|
|
120
338
|
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
339
|
+
const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
|
|
121
340
|
results.push({
|
|
122
341
|
index: i,
|
|
123
342
|
ok: true,
|
|
124
343
|
sha: shaMatch?.[1],
|
|
125
344
|
message: entry.message,
|
|
126
|
-
files:
|
|
345
|
+
files: filePaths,
|
|
346
|
+
...spreadDefined("output", gitOutput || undefined),
|
|
127
347
|
});
|
|
128
348
|
}
|
|
349
|
+
// --- In dry-run mode, unstage all files ---
|
|
350
|
+
if (args.dryRun && stagedFilesForCleanup.size > 0) {
|
|
351
|
+
const filesToReset = Array.from(stagedFilesForCleanup);
|
|
352
|
+
await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
|
|
353
|
+
}
|
|
129
354
|
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;
|
|
355
|
+
// --- Optional push after all commits succeed (not in dry-run mode) ---
|
|
356
|
+
const push = !args.dryRun && allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
132
357
|
if (args.format === "json") {
|
|
133
358
|
return jsonRespond({
|
|
359
|
+
...spreadWhen(args.dryRun, { dryRun: true }),
|
|
134
360
|
ok: allOk,
|
|
135
361
|
committed: results.filter((r) => r.ok).length,
|
|
136
362
|
total: args.commits.length,
|
|
@@ -140,8 +366,11 @@ export function registerBatchCommitTool(server) {
|
|
|
140
366
|
...spreadDefined("sha", r.sha),
|
|
141
367
|
message: r.message,
|
|
142
368
|
files: r.files,
|
|
369
|
+
...spreadDefined("staged", r.staged),
|
|
370
|
+
...spreadDefined("diffStat", r.diffStat),
|
|
143
371
|
...spreadDefined("error", r.error),
|
|
144
372
|
...spreadDefined("detail", r.detail),
|
|
373
|
+
...spreadDefined("output", r.output),
|
|
145
374
|
})),
|
|
146
375
|
...spreadWhen(push !== undefined, {
|
|
147
376
|
push: {
|
|
@@ -150,15 +379,17 @@ export function registerBatchCommitTool(server) {
|
|
|
150
379
|
...spreadDefined("upstream", push?.upstream),
|
|
151
380
|
...spreadDefined("error", push?.error),
|
|
152
381
|
...spreadDefined("detail", push?.detail),
|
|
382
|
+
...spreadDefined("output", push?.output),
|
|
153
383
|
},
|
|
154
384
|
}),
|
|
155
385
|
});
|
|
156
386
|
}
|
|
157
387
|
// --- Markdown ---
|
|
158
388
|
const lines = [];
|
|
389
|
+
const dryRunPrefix = args.dryRun ? "DRY RUN — " : "";
|
|
159
390
|
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)`;
|
|
391
|
+
? `# Batch commit: ${dryRunPrefix}${results.length}/${args.commits.length} committed`
|
|
392
|
+
: `# Batch commit: ${dryRunPrefix}${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
162
393
|
lines.push(header, "");
|
|
163
394
|
for (const r of results) {
|
|
164
395
|
const icon = r.ok ? "✓" : "✗";
|
|
@@ -167,11 +398,24 @@ export function registerBatchCommitTool(server) {
|
|
|
167
398
|
if (!r.ok && r.detail) {
|
|
168
399
|
lines.push(` Error: ${r.error} — ${r.detail}`);
|
|
169
400
|
}
|
|
401
|
+
if (args.dryRun && r.staged) {
|
|
402
|
+
lines.push(` Staged: ${r.staged.join(", ")}`);
|
|
403
|
+
}
|
|
404
|
+
if (args.dryRun && r.diffStat) {
|
|
405
|
+
lines.push(` Diff stat:`);
|
|
406
|
+
lines.push(` ${r.diffStat.replace(/\n/g, "\n ")}`);
|
|
407
|
+
}
|
|
408
|
+
if (r.output) {
|
|
409
|
+
lines.push(` Output: ${r.output.replace(/\n/g, "\n ")}`);
|
|
410
|
+
}
|
|
170
411
|
}
|
|
171
412
|
if (!allOk && results.length < args.commits.length) {
|
|
172
413
|
const skipped = args.commits.length - results.length;
|
|
173
414
|
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
174
415
|
}
|
|
416
|
+
if (args.dryRun) {
|
|
417
|
+
lines.push("", "**DRY RUN — no commits written. All staged files have been unstaged.**");
|
|
418
|
+
}
|
|
175
419
|
if (push) {
|
|
176
420
|
lines.push("");
|
|
177
421
|
if (push.ok) {
|
|
@@ -180,6 +424,9 @@ export function registerBatchCommitTool(server) {
|
|
|
180
424
|
else {
|
|
181
425
|
lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
|
|
182
426
|
}
|
|
427
|
+
if (push.output) {
|
|
428
|
+
lines.push(` Output: ${push.output.replace(/\n/g, "\n ")}`);
|
|
429
|
+
}
|
|
183
430
|
}
|
|
184
431
|
return lines.join("\n");
|
|
185
432
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { spawnGitAsync } from "./git.js";
|
|
3
|
-
import { commitListBetween, conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
3
|
+
import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
5
|
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -96,8 +96,11 @@ export function registerGitCherryPickTool(server) {
|
|
|
96
96
|
description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
|
|
97
97
|
"`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
|
|
98
98
|
"Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
|
|
99
|
-
"stops on the first conflict and reports paths. Optional flags auto-delete
|
|
100
|
-
"
|
|
99
|
+
"stops on the first conflict and reports paths. Optional flags auto-delete source " +
|
|
100
|
+
"branches and worktrees after success; deletion uses patch-id equivalence by default " +
|
|
101
|
+
"(content-identical commits with different SHAs are treated as merged, which is the " +
|
|
102
|
+
"normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
|
|
103
|
+
"ancestry semantics. Protected branch names always skipped.",
|
|
101
104
|
annotations: {
|
|
102
105
|
readOnlyHint: false,
|
|
103
106
|
destructiveHint: false,
|
|
@@ -127,6 +130,15 @@ export function registerGitCherryPickTool(server) {
|
|
|
127
130
|
.default(false)
|
|
128
131
|
.describe("After success, remove any local worktree attached to a branch-kind source " +
|
|
129
132
|
"(`git worktree remove`). Protected tails always skipped."),
|
|
133
|
+
strictMergedRefEquality: z
|
|
134
|
+
.boolean()
|
|
135
|
+
.optional()
|
|
136
|
+
.default(false)
|
|
137
|
+
.describe("When false (default), branch deletion uses patch-id equivalence: a source branch " +
|
|
138
|
+
"is deleted when every commit it contains has a content-equivalent commit on the " +
|
|
139
|
+
"destination (same diff, different SHA — the normal cherry-pick outcome). " +
|
|
140
|
+
"Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
|
|
141
|
+
"will refuse deletion after a cherry-pick because the SHA differs."),
|
|
130
142
|
}),
|
|
131
143
|
execute: async (args) => {
|
|
132
144
|
const pre = requireSingleRepo(server, args);
|
|
@@ -221,11 +233,23 @@ export function registerGitCherryPickTool(server) {
|
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
if (args.deleteMergedBranches) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
if (args.strictMergedRefEquality) {
|
|
237
|
+
const merged = await isFullyMergedInto(gitTop, src.raw, onto);
|
|
238
|
+
if (merged) {
|
|
239
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
|
|
240
|
+
if (r.ok)
|
|
241
|
+
src.branchDeleted = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
const merged = await isContentEquivalentlyMergedInto(gitTop, src.raw, onto);
|
|
246
|
+
if (merged) {
|
|
247
|
+
// -D required: git branch -d checks ref ancestry (fails after cherry-pick),
|
|
248
|
+
// but we've already verified content equivalence via patch-id.
|
|
249
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-D", src.raw]);
|
|
250
|
+
if (r.ok)
|
|
251
|
+
src.branchDeleted = true;
|
|
252
|
+
}
|
|
229
253
|
}
|
|
230
254
|
}
|
|
231
255
|
}
|
|
@@ -24,23 +24,25 @@ const DEFAULT_EXCLUDE_PATTERNS = [
|
|
|
24
24
|
// Helpers
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
/**
|
|
27
|
-
* Parse `git diff --
|
|
28
|
-
* Format: "
|
|
29
|
-
*
|
|
27
|
+
* Parse `git diff --numstat` output into exact per-file counts.
|
|
28
|
+
* Format per line: "<additions>\t<deletions>\t<path>"
|
|
29
|
+
* Binary files emit "-\t-\t<path>" and are recorded as 0/0.
|
|
30
30
|
*/
|
|
31
|
-
function
|
|
31
|
+
function parseNumstatOutput(numstat) {
|
|
32
32
|
const result = new Map();
|
|
33
|
-
for (const line of
|
|
34
|
-
|
|
35
|
-
if (
|
|
33
|
+
for (const line of numstat.split("\n")) {
|
|
34
|
+
const parts = line.split("\t");
|
|
35
|
+
if (parts.length < 3)
|
|
36
36
|
continue;
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const additions =
|
|
42
|
-
const deletions =
|
|
43
|
-
|
|
37
|
+
const [addStr, delStr, ...pathParts] = parts;
|
|
38
|
+
const filePath = pathParts.join("\t");
|
|
39
|
+
if (!filePath)
|
|
40
|
+
continue;
|
|
41
|
+
const additions = addStr === "-" ? 0 : Number.parseInt(addStr ?? "0", 10);
|
|
42
|
+
const deletions = delStr === "-" ? 0 : Number.parseInt(delStr ?? "0", 10);
|
|
43
|
+
if (!Number.isNaN(additions) && !Number.isNaN(deletions)) {
|
|
44
|
+
result.set(filePath, { additions, deletions });
|
|
45
|
+
}
|
|
44
46
|
}
|
|
45
47
|
return result;
|
|
46
48
|
}
|
|
@@ -68,10 +70,26 @@ function parseDiffOutput(diff) {
|
|
|
68
70
|
* Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
|
|
69
71
|
*/
|
|
70
72
|
function extractFileInfo(header, body) {
|
|
71
|
-
// Parse "diff --git a/X b/Y"
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
73
|
+
// Parse "diff --git a/X b/Y". For non-renames X === Y; use midpoint split so
|
|
74
|
+
// paths containing " b/" (e.g. "src/b/file.ts") are not mis-parsed by a greedy regex.
|
|
75
|
+
const prefix = "diff --git a/";
|
|
76
|
+
const raw = header.startsWith(prefix) ? header.slice(prefix.length) : "";
|
|
77
|
+
const midLen = (raw.length - " b/".length) / 2;
|
|
78
|
+
let aPath = "";
|
|
79
|
+
let bPath = "";
|
|
80
|
+
if (Number.isInteger(midLen) && midLen > 0) {
|
|
81
|
+
const candidate = raw.slice(0, midLen);
|
|
82
|
+
if (raw.slice(midLen) === ` b/${candidate}`) {
|
|
83
|
+
aPath = candidate;
|
|
84
|
+
bPath = candidate;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!aPath) {
|
|
88
|
+
// Fall back for renames (aPath ≠ bPath); rename paths also come from body lines.
|
|
89
|
+
const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
|
|
90
|
+
aPath = headerMatch?.[1] ?? "";
|
|
91
|
+
bPath = headerMatch?.[2] ?? aPath;
|
|
92
|
+
}
|
|
75
93
|
let status = "modified";
|
|
76
94
|
let oldPath;
|
|
77
95
|
if (/^new file mode/m.test(body)) {
|
|
@@ -202,15 +220,15 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
202
220
|
return jsonRespond({ error: diffArgsResult.error });
|
|
203
221
|
}
|
|
204
222
|
const diffArgs = diffArgsResult.args;
|
|
205
|
-
// --- Run git diff --
|
|
206
|
-
const statResult = await spawnGitAsync(gitTop, ["diff", "--
|
|
223
|
+
// --- Run git diff --numstat for exact addition/deletion counts ---
|
|
224
|
+
const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
|
|
207
225
|
if (!statResult.ok) {
|
|
208
226
|
return jsonRespond({
|
|
209
227
|
error: "git_diff_failed",
|
|
210
228
|
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
211
229
|
});
|
|
212
230
|
}
|
|
213
|
-
const statMap =
|
|
231
|
+
const statMap = parseNumstatOutput(statResult.stdout);
|
|
214
232
|
// --- Run git diff ---
|
|
215
233
|
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
216
234
|
if (!diffResult.ok) {
|