@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.
Files changed (45) hide show
  1. package/AGENTS.md +30 -4
  2. package/CHANGELOG.md +74 -0
  3. package/HUMANS.md +54 -5
  4. package/README.md +15 -1
  5. package/dist/server/batch-commit-tool.js +266 -19
  6. package/dist/server/git-cherry-pick-tool.js +32 -8
  7. package/dist/server/git-diff-summary-tool.js +39 -21
  8. package/dist/server/git-diff-tool.js +132 -0
  9. package/dist/server/git-fetch-tool.js +131 -0
  10. package/dist/server/git-log-tool.js +37 -0
  11. package/dist/server/git-push-tool.js +8 -1
  12. package/dist/server/git-refs.js +72 -0
  13. package/dist/server/git-show-tool.js +148 -0
  14. package/dist/server/git-stash-tool.js +131 -0
  15. package/dist/server/git-tag-tool.js +162 -0
  16. package/dist/server/git.js +35 -4
  17. package/dist/server/roots.js +8 -4
  18. package/dist/server/test-harness.js +68 -5
  19. package/dist/server/tool-parameter-schemas.js +21 -1
  20. package/dist/server/tools.js +11 -0
  21. package/docs/install.md +19 -2
  22. package/docs/mcp-tools.md +443 -10
  23. package/package.json +18 -6
  24. package/schemas/batch_commit.json +125 -0
  25. package/schemas/git_cherry_pick.json +69 -0
  26. package/schemas/git_diff.json +62 -0
  27. package/schemas/git_diff_summary.json +75 -0
  28. package/schemas/git_fetch.json +52 -0
  29. package/schemas/git_inventory.json +74 -0
  30. package/schemas/git_log.json +77 -0
  31. package/schemas/git_merge.json +79 -0
  32. package/schemas/git_parity.json +74 -0
  33. package/schemas/git_push.json +50 -0
  34. package/schemas/git_reset_soft.json +42 -0
  35. package/schemas/git_show.json +39 -0
  36. package/schemas/git_stash_apply.json +44 -0
  37. package/schemas/git_stash_list.json +30 -0
  38. package/schemas/git_status.json +49 -0
  39. package/schemas/git_tag.json +50 -0
  40. package/schemas/git_worktree_add.json +52 -0
  41. package/schemas/git_worktree_list.json +30 -0
  42. package/schemas/git_worktree_remove.json +48 -0
  43. package/schemas/index.json +108 -0
  44. package/schemas/list_presets.json +44 -0
  45. 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.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
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
- return { ok: true, branch, upstream: t.upstream };
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 rel of entry.files) {
77
- const abs = resolvePathForRepo(rel, gitTop);
261
+ for (const path of filePaths) {
262
+ const abs = resolvePathForRepo(path, gitTop);
78
263
  if (!isStrictlyUnderGitTop(abs, gitTop)) {
79
- escapedPaths.push(rel);
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: entry.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
- const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
95
- if (!addResult.ok) {
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: entry.files,
294
+ files: filePaths,
101
295
  error: "stage_failed",
102
- detail: (addResult.stderr || addResult.stdout).trim(),
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: entry.files,
330
+ files: filePaths,
114
331
  error: "commit_failed",
115
- detail: (commitResult.stderr || commitResult.stdout).trim(),
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: entry.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 fully " +
100
- "merged source branches and their worktrees, skipping protected names.",
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
- const merged = await isFullyMergedInto(gitTop, src.raw, onto);
225
- if (merged) {
226
- const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
227
- if (r.ok)
228
- src.branchDeleted = true;
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 --stat` output into per-file stats.
28
- * Format: " path/to/file | N ++++---"
29
- * Final line: " N files changed, N insertions(+), N deletions(-)"
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 parseStatOutput(stat) {
31
+ function parseNumstatOutput(numstat) {
32
32
  const result = new Map();
33
- for (const line of stat.split("\n")) {
34
- // Skip the summary line and blank lines
35
- if (!line.includes("|"))
33
+ for (const line of numstat.split("\n")) {
34
+ const parts = line.split("\t");
35
+ if (parts.length < 3)
36
36
  continue;
37
- const pipeIdx = line.indexOf("|");
38
- const filePart = line.slice(0, pipeIdx).trim();
39
- const statPart = line.slice(pipeIdx + 1).trim();
40
- // statPart looks like "5 ++---" or "3 +++", count + and -
41
- const additions = (statPart.match(/\+/g) ?? []).length;
42
- const deletions = (statPart.match(/-/g) ?? []).length;
43
- result.set(filePart, { additions, deletions });
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
- const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
73
- const aPath = headerMatch?.[1] ?? "";
74
- const bPath = headerMatch?.[2] ?? aPath;
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 --stat ---
206
- const statResult = await spawnGitAsync(gitTop, ["diff", "--stat", ...diffArgs]);
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 = parseStatOutput(statResult.stdout);
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) {