@rethunk/mcp-multi-root-git 2.4.0 → 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 CHANGED
@@ -22,7 +22,7 @@ IDEs injecting this as context: do not re-link from rules.
22
22
  | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
23
23
  | [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT`, **`MAX_ABSOLUTE_GIT_ROOTS`** (256), optional **`absoluteGitRoots`** on workspace pick |
24
24
  | [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
25
- | [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
25
+ | [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `isContentEquivalentlyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `inferRemoteFromUpstream`; `conflictPaths` |
26
26
  | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
27
27
  | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
28
28
  | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to `@rethunk/mcp-multi-root-git` are documented here. Format
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.5.0] — 2026-05-15
8
+
9
+ Bug-fix and documentation release; includes one new `git_log` output format.
10
+
11
+ ### Added
12
+
13
+ - **`git_log` `format: "oneline"`** — minimal `<sha7> <subject>` output per commit for low-token post-commit verification. Multi-root output uses `### repo (branch)` separators; single-root output emits one line per commit with no headers.
14
+
15
+ ### Fixed
16
+
17
+ - **`git_stash_list`**: format string used for-each-ref placeholders (`%(subject)`, `%(objectname:short)`) instead of git-log format (`%s`, `%h`), printing literal placeholder text as the stash message.
18
+ - **`git_stash_list`**: stash message containing `|` caused the SHA field to be parsed as a mid-message fragment; SHA is now always the last pipe-separated field.
19
+ - **`batch_commit`**: deleted tracked files (missing on disk) could not be staged; now staged via `git rm --cached`. Combining `{ path, lines }` with a deleted file is validated as an error.
20
+ - **`batch_commit` hunk-level staging**: `newCount=0` pure-deletion hunks were excluded from line ranges that included the deletion point (`hunkEnd` was `newStart-1`); now `hunkEnd = newStart` for zero-count hunks.
21
+ - **`git_diff_summary`**: diff header regex mis-parsed file paths containing ` b/` (e.g. `src/b/file.ts`), reporting wrong path and zero stats; now uses midpoint-symmetry split for non-renames, falls back to regex for renames.
22
+ - **`git_diff_summary`**: `git diff --stat` bar-graph character counting is scaled to terminal width and under-reports for large files; replaced with `git diff --numstat` for exact addition/deletion counts.
23
+ - **`git_cherry_pick`**: branch deletion after cherry-pick now uses patch-id equivalence by default so source branches with differing SHAs but identical content are correctly detected as merged. Pass `strictMergedRefEquality: true` for strict SHA-reachability semantics.
24
+ - **`parseGitSubmodulePaths`**: non-regular files at `.gitmodules` (character devices, sockets — common in Claude Code / sandbox environments) are now rejected via `lstatSync().isFile()` before `readFileSync`, preventing `EACCES` errors.
25
+
26
+ ### Tests
27
+
28
+ - Integration tests added for `git_stash_list`, `git_stash_apply`, `git_fetch`, `git_status`, `git_inventory`, and `git_parity` execute paths.
29
+ - `isContentEquivalentlyMergedInto` integration tests via real git repos with cherry-picked and diverged histories.
30
+ - `batch_commit` dryRun tests covering deleted-file unstaging.
31
+
32
+ ### Documentation
33
+
34
+ - **`git_status`, `git_inventory`, `git_parity`, `list_presets`** now have complete parameter tables, JSON shape examples, and error code tables in `docs/mcp-tools.md`.
35
+ - **`git_cherry_pick`** `strictMergedRefEquality` parameter added to docs; `deleteMergedBranches` description corrected (default is patch-id equivalence, not SHA-reachability).
36
+ - **`batch_commit`** `files` parameter and `stage_failed` error code updated to document deleted-file staging path.
37
+
7
38
  ## [2.4.0] — 2026-05-07
8
39
 
9
40
  New git MCP tools, better `batch_commit` ergonomics, published schema coverage for the full tool surface, and a broad docs/test refresh since `v2.3.4`.
@@ -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";
@@ -22,7 +23,9 @@ const CommitEntrySchema = z.object({
22
23
  files: z
23
24
  .array(FileEntrySchema)
24
25
  .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."),
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."),
26
29
  });
27
30
  const PushModeSchema = z
28
31
  .enum(["never", "after"])
@@ -74,7 +77,9 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
74
77
  if (hunkMatch) {
75
78
  const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
76
79
  const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
77
- const hunkEnd = newStart + newCount - 1;
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;
78
83
  // Check if hunk overlaps with requested line range
79
84
  const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
80
85
  if (hasOverlap) {
@@ -123,6 +128,23 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
123
128
  * overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
124
129
  */
125
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
+ }
126
148
  if (!lines) {
127
149
  // Simple case: stage the whole file
128
150
  const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
@@ -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) {
@@ -133,6 +133,22 @@ async function runGitLog(opts) {
133
133
  // ---------------------------------------------------------------------------
134
134
  // Markdown rendering
135
135
  // ---------------------------------------------------------------------------
136
+ function renderLogOneline(group, multiRoot) {
137
+ const lines = [];
138
+ if (multiRoot) {
139
+ lines.push(`### ${group.repo} (${group.branch})`);
140
+ }
141
+ for (const c of group.commits) {
142
+ lines.push(`${c.sha.slice(0, 7)} ${c.subject}`);
143
+ }
144
+ if (group.commits.length === 0) {
145
+ lines.push("(no commits)");
146
+ }
147
+ if (group.truncated) {
148
+ lines.push(`(+${group.omittedCount} more)`);
149
+ }
150
+ return lines.join("\n");
151
+ }
136
152
  function renderLogMarkdown(group, filterSummary) {
137
153
  const lines = [];
138
154
  lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
@@ -164,6 +180,13 @@ export function registerGitLogTool(server) {
164
180
  readOnlyHint: true,
165
181
  },
166
182
  parameters: WorkspacePickSchema.extend({
183
+ format: z
184
+ .enum(["markdown", "json", "oneline"])
185
+ .optional()
186
+ .default("markdown")
187
+ .describe("`markdown` (default): headed sections per root. " +
188
+ "`json`: structured groups array. " +
189
+ "`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
167
190
  since: z
168
191
  .string()
169
192
  .optional()
@@ -258,6 +281,20 @@ export function registerGitLogTool(server) {
258
281
  });
259
282
  return jsonRespond({ groups });
260
283
  }
284
+ // Oneline
285
+ if (args.format === "oneline") {
286
+ const multiRoot = results.length > 1;
287
+ const chunks = [];
288
+ for (const r of results) {
289
+ if (r._error) {
290
+ chunks.push(multiRoot ? `### ${r.workspaceRoot}\n(error: ${r.error})` : `(error: ${r.error})`);
291
+ continue;
292
+ }
293
+ const { _error: _e, ...group } = r;
294
+ chunks.push(renderLogOneline(group, multiRoot));
295
+ }
296
+ return chunks.join("\n\n");
297
+ }
261
298
  // Markdown
262
299
  const mdChunks = ["# Git log"];
263
300
  for (const r of results) {
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { spawnGitAsync } from "./git.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Merge conflict helpers (shared between git_merge and git_cherry_pick)
@@ -124,6 +125,77 @@ export async function isFullyMergedInto(cwd, branch, target) {
124
125
  const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
125
126
  return r.ok;
126
127
  }
128
+ /**
129
+ * Returns the git patch-id for a single commit, or undefined on failure.
130
+ * Uses execFileSync to pipe `git diff-tree --patch -r <sha>` into `git patch-id --stable`.
131
+ */
132
+ function commitPatchId(cwd, sha) {
133
+ try {
134
+ const diff = execFileSync("git", ["diff-tree", "--patch", "-r", sha], {
135
+ cwd,
136
+ encoding: "utf8",
137
+ });
138
+ const out = execFileSync("git", ["patch-id", "--stable"], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ input: diff,
142
+ });
143
+ return out.trim().split(" ")[0] || undefined;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ /**
150
+ * Returns true when every commit reachable from `branch` but not from `target` has a
151
+ * patch-equivalent commit already in `target` (content-equivalent merge check for
152
+ * cherry-pick workflows where SHA differs but diff is identical).
153
+ *
154
+ * Falls back to ref-equality (`isFullyMergedInto`) when patch-id comparison fails.
155
+ */
156
+ export async function isContentEquivalentlyMergedInto(cwd, branch, target) {
157
+ if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
158
+ return false;
159
+ // Fast path: ref equality (normal merge).
160
+ const refMerged = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
161
+ if (refMerged.ok)
162
+ return true;
163
+ // Collect commits in branch not reachable from target.
164
+ const srcList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${branch}`, `^${target}`]);
165
+ if (!srcList.ok)
166
+ return false;
167
+ const srcShas = srcList.stdout
168
+ .split("\n")
169
+ .map((l) => l.trim())
170
+ .filter((l) => l.length > 0);
171
+ if (srcShas.length === 0)
172
+ return true; // nothing to check
173
+ // Build patch-id set for target commits since merge-base.
174
+ const base = await spawnGitAsync(cwd, ["merge-base", branch, target]);
175
+ if (!base.ok)
176
+ return false;
177
+ const baseSha = base.stdout.trim();
178
+ const destList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${baseSha}..${target}`]);
179
+ if (!destList.ok)
180
+ return false;
181
+ const destShas = destList.stdout
182
+ .split("\n")
183
+ .map((l) => l.trim())
184
+ .filter((l) => l.length > 0);
185
+ const destPatchIds = new Set();
186
+ for (const sha of destShas) {
187
+ const patchId = commitPatchId(cwd, sha);
188
+ if (patchId)
189
+ destPatchIds.add(patchId);
190
+ }
191
+ // Every source commit must have its patch-id present in destination.
192
+ for (const sha of srcShas) {
193
+ const patchId = commitPatchId(cwd, sha);
194
+ if (!patchId || !destPatchIds.has(patchId))
195
+ return false;
196
+ }
197
+ return true;
198
+ }
127
199
  /**
128
200
  * SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
129
201
  * Returns `null` on git failure.
@@ -12,7 +12,7 @@ import { WorkspacePickSchema } from "./schemas.js";
12
12
  */
13
13
  async function runGitShow(opts) {
14
14
  const { top, ref, path } = opts;
15
- // Build git show args. Start with --no-patch to get just the commit message.
15
+ // Build git show args. Shows commit message + full diff (or file content when path given).
16
16
  const showArgs = ["show", ref];
17
17
  if (path) {
18
18
  // When path is specified, show that path at the ref without --no-patch
@@ -24,11 +24,8 @@ export function registerGitStashListTool(server) {
24
24
  return jsonRespond(pre.error);
25
25
  const { gitTop } = pre;
26
26
  // List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
27
- const r = await spawnGitAsync(gitTop, [
28
- "stash",
29
- "list",
30
- "--format=%(refname:short)|%(subject)|%(objectname:short)",
31
- ]);
27
+ // git stash list uses git-log format (%s, %h) not for-each-ref format %(subject).
28
+ const r = await spawnGitAsync(gitTop, ["stash", "list", "--format=%gd|%s|%h"]);
32
29
  if (!r.ok) {
33
30
  // If there are no stashes, git still returns ok=true with empty output
34
31
  // Only treat as error if git itself failed
@@ -46,11 +43,14 @@ export function registerGitStashListTool(server) {
46
43
  const line = lines[i];
47
44
  if (line) {
48
45
  const parts = line.split("|");
49
- if (parts.length >= 3 && parts[1] && parts[2]) {
46
+ // parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
47
+ const sha = parts[parts.length - 1];
48
+ const message = parts.slice(1, -1).join("|");
49
+ if (parts.length >= 3 && message && sha) {
50
50
  stashes.push({
51
51
  index: i,
52
- message: parts[1],
53
- sha: parts[2],
52
+ message,
53
+ sha,
54
54
  });
55
55
  }
56
56
  }
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { cpus } from "node:os";
4
4
  import { join } from "node:path";
5
5
  /**
@@ -77,7 +77,22 @@ export function parseGitSubmodulePaths(gitRoot) {
77
77
  const f = join(gitRoot, ".gitmodules");
78
78
  if (!existsSync(f))
79
79
  return [];
80
- const text = readFileSync(f, "utf8");
80
+ // Skip non-regular files (character devices, sockets, etc.) — common in
81
+ // Claude Code sandbox environments where stub device files shadow paths.
82
+ try {
83
+ if (!lstatSync(f).isFile())
84
+ return [];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ let text;
90
+ try {
91
+ text = readFileSync(f, "utf8");
92
+ }
93
+ catch {
94
+ return [];
95
+ }
81
96
  const paths = [];
82
97
  for (const line of text.split("\n")) {
83
98
  const m = /^\s*path\s*=\s*(.+)\s*$/.exec(line);
package/docs/mcp-tools.md CHANGED
@@ -15,7 +15,7 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
15
15
  | `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args (`absoluteGitRoots` cannot combine with `preset`/`nestedRoots`). **Read-only.** |
16
16
  | `git_parity` | `rethunk-git_git_parity` | Compare `git rev-parse HEAD` for path pairs. `pairs`, `preset`, `presetMerge`, `format`, plus workspace pick args (`absoluteGitRoots` for sibling clone batches). **Read-only.** |
17
17
  | `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only (includes `absoluteGitRoots`). **Read-only.** |
18
- | `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args (`absoluteGitRoots` for sibling clones) + `format`. **Read-only.** |
18
+ | `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args (`absoluteGitRoots` for sibling clones) + `format` (`markdown`/`json`/`oneline`). **Read-only.** |
19
19
  | `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args (optional single-entry `absoluteGitRoots`) + `format`. **Read-only.** |
20
20
  | `git_diff` | `rethunk-git_git_diff` | Raw diff text for a single repo. Supports unstaged, staged, or `base..head` ranges, optionally scoped to one path. Args: `base?`, `head?`, `path?`, `staged?`, plus single-repo workspace pick + `format`. **Read-only.** |
21
21
  | `git_show` | `rethunk-git_git_show` | Inspect one commit or ref. Returns commit message plus diff, or file content at `path` for that ref. Args: `ref`, `path?`, plus single-repo workspace pick + `format`. **Read-only.** |
@@ -29,11 +29,234 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
29
29
  | `git_reset_soft` | `rethunk-git_git_reset_soft` | Soft-reset the current branch to a ref (`HEAD~N`, SHA, branch). Rewound changes land in the staging index; requires a clean working tree. Args: `ref`, plus workspace pick + `format`. **Mutating — not idempotent.** |
30
30
  | `batch_commit` | `rethunk-git_batch_commit` | Create multiple sequential git commits in a single call. Each entry stages the listed files or line-ranged file hunks, then commits with the given message. Stops on first failure. Optional `push: "after"` pushes once every commit lands; optional `dryRun: true` previews staged content without writing commits. Args: `commits` (array of `{message, files}`), `push?`, `dryRun?`, plus workspace pick args + `format`. **Mutating — not idempotent.** |
31
31
  | `git_merge` | `rethunk-git_git_merge` | Merge one or more source branches into a destination. Default strategy `auto` cascades fast-forward → rebase → merge-commit per source, preferring linear history. Refuses on dirty tree; stops on first conflict with structured path report. Optional `deleteMergedBranches` / `deleteMergedWorktrees` cascade cleanup, always skipping protected names (main/master/dev/develop/stable/trunk/prod/production/release\*/hotfix\*). Args: `sources`, `into?`, `strategy?`, `message?`, cleanup flags + workspace pick + `format`. **Mutating.** |
32
- | `git_cherry_pick` | `rethunk-git_git_cherry_pick` | Play commits from one or more sources onto a destination. Sources may be SHAs, `A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). Uses `--empty=drop` so patch-equivalent re-applies add nothing. Refuses on dirty tree; stops on first conflict, aborting cleanly. Same cleanup flags as `git_merge` (branch-kind sources only, protected names skipped). Args: `sources`, `onto?`, cleanup flags + workspace pick + `format`. **Mutating.** |
32
+ | `git_cherry_pick` | `rethunk-git_git_cherry_pick` | Play commits from one or more sources onto a destination. Sources may be SHAs, `A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). Uses `--empty=drop` so patch-equivalent re-applies add nothing. Refuses on dirty tree; stops on first conflict, aborting cleanly. Same cleanup flags as `git_merge` (branch-kind sources only, protected names skipped); branch deletion uses patch-id equivalence by default so cherry-pick workflows (where SHA differs but diff is identical) clean up correctly. Pass `strictMergedRefEquality: true` for strict `git branch -d` ancestry semantics. Args: `sources`, `onto?`, cleanup flags, `strictMergedRefEquality?` + workspace pick + `format`. **Mutating.** |
33
33
  | `git_stash_apply` | `rethunk-git_git_stash_apply` | Apply or pop a stash entry for one repo. Args: `index?`, `pop?`, plus single-repo workspace pick + `format`. **Mutating.** |
34
34
 
35
35
  Pass **`format: "json"`** on any tool for structured JSON instead of markdown (default).
36
36
 
37
+ ---
38
+
39
+ ### `git_status` — parameters
40
+
41
+ | Parameter | Type | Default | Notes |
42
+ |-----------|------|---------|-------|
43
+ | `includeSubmodules` | boolean | `true` | When `true`, reads `.gitmodules` at each git toplevel and runs `git status --short -b` for each checked-out submodule in parallel. Set `false` to skip submodule discovery entirely. |
44
+ | `workspaceRoot` | string | — | Explicit root; highest priority. |
45
+ | `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
46
+ | `allWorkspaceRoots` | boolean | `false` | Fan out across all MCP roots. |
47
+ | `absoluteGitRoots` | string[] | — | Explicit list of absolute paths; replaces normal workspace pick. Max 256. Read-only tools only. |
48
+ | `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
49
+
50
+ ### `git_status` — JSON shape (`format: "json"`)
51
+
52
+ ```json
53
+ {
54
+ "groups": [{
55
+ "mcpRoot": "/abs/workspace",
56
+ "repos": [
57
+ { "label": ".", "path": "/abs/workspace", "statusText": "## main...origin/main\nM src/foo.ts", "ok": true },
58
+ { "label": "sub", "path": "/abs/workspace/sub", "statusText": "## main...origin/main", "ok": true }
59
+ ]
60
+ }]
61
+ }
62
+ ```
63
+
64
+ One `groups` entry per resolved root. Each `repos` entry has `label` (relative path, `"."` for the root), `path` (absolute), `statusText` (full `git status --short -b` output), and `ok` (`false` when git failed or the submodule is not checked out).
65
+
66
+ ### `git_status` — error codes
67
+
68
+ Error payloads appear as top-level JSON or inline in individual repo rows (`ok: false`):
69
+
70
+ | Code / statusText | Context | Meaning |
71
+ |-------------------|---------|---------|
72
+ | `git_not_found` | top-level | `git` binary not on `PATH`. |
73
+ | `not_a_git_repository` | repo row `statusText` | Root is not inside a git repository. |
74
+ | `(submodule path escapes repository — rejected)` | repo row `statusText` | `.gitmodules` path resolves outside the git toplevel (security guard). |
75
+ | `(no .git — submodule not checked out?)` | repo row `statusText` | Submodule directory exists but has no `.git` — not initialized. |
76
+ | `root_index_out_of_range` | top-level | `rootIndex` exceeds the number of MCP file roots. |
77
+ | `absolute_git_roots_exclusive` | top-level | `absoluteGitRoots` combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
78
+ | `invalid_absolute_git_root` | top-level | An `absoluteGitRoots` entry is not a git-recognized directory. |
79
+ | `absolute_git_roots_too_many` | top-level | More than 256 entries in `absoluteGitRoots`. |
80
+ | `absolute_git_roots_empty` | top-level | `absoluteGitRoots` resolved to zero toplevels. |
81
+
82
+ ---
83
+
84
+ ### `git_inventory` — parameters
85
+
86
+ | Parameter | Type | Default | Notes |
87
+ |-----------|------|---------|-------|
88
+ | `nestedRoots` | string[] | — | Relative paths (from the workspace git toplevel) to treat as independent git repos to inventory. Each must be a valid git work tree; invalid paths produce skip entries rather than errors. Cannot combine with `absoluteGitRoots` or `preset`. |
89
+ | `preset` | string | — | Preset name from `.rethunk/git-mcp-presets.json`. Loads `nestedRoots` from the preset's entry. Cannot combine with `absoluteGitRoots`. |
90
+ | `presetMerge` | boolean | `false` | When `true`, merge inline `nestedRoots` with preset roots instead of replacing. |
91
+ | `remote` | string | — | Fixed remote for ahead/behind tracking. Must be paired with `branch`. |
92
+ | `branch` | string | — | Fixed branch for ahead/behind tracking. Must be paired with `remote`. When both are absent the tool uses each repo's `@{u}` upstream. |
93
+ | `maxRoots` | int | `64` | Max nested roots to process (1–256). Roots beyond the limit are omitted; `nestedRootsTruncated: true` and `nestedRootsOmittedCount` are set on the group. |
94
+ | `workspaceRoot` | string | — | Explicit root; highest priority. |
95
+ | `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
96
+ | `allWorkspaceRoots` | boolean | `false` | Fan out across all MCP roots. |
97
+ | `absoluteGitRoots` | string[] | — | Explicit list; replaces normal workspace pick. Cannot combine with `nestedRoots` or `preset`. |
98
+ | `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
99
+
100
+ ### `git_inventory` — JSON shape (`format: "json"`)
101
+
102
+ ```json
103
+ {
104
+ "inventories": [{
105
+ "workspace_root": "/abs/path",
106
+ "entries": [{
107
+ "label": ".",
108
+ "path": "/abs/path",
109
+ "upstreamMode": "auto",
110
+ "branchStatus": "## main...origin/main",
111
+ "headAbbrev": "a1b2c3d",
112
+ "upstreamRef": "origin/main",
113
+ "ahead": "2",
114
+ "behind": "0"
115
+ }],
116
+ "nestedRootsTruncated": true,
117
+ "nestedRootsOmittedCount": 3,
118
+ "upstream": { "mode": "fixed", "remote": "origin", "branch": "main" }
119
+ }]
120
+ }
121
+ ```
122
+
123
+ `nestedRootsTruncated` / `nestedRootsOmittedCount` present only when `maxRoots` cut the list. `upstream` object present only when `remote`+`branch` were supplied (fixed mode). `presetSchemaVersion` present only when a preset was loaded. See *v2/v3 field omission* for `entries[*]` optional fields.
124
+
125
+ ### `git_inventory` — error codes
126
+
127
+ | Code | Meaning |
128
+ |------|---------|
129
+ | `git_not_found` | `git` binary not on `PATH`. |
130
+ | `remote_branch_mismatch` | Only one of `remote` / `branch` was provided; supply both or neither. |
131
+ | `invalid_remote_or_branch` | `remote` or `branch` contains characters outside the safe token set. |
132
+ | `absolute_git_roots_nested_or_preset_conflict` | `absoluteGitRoots` combined with `nestedRoots` or `preset`. |
133
+ | `absolute_git_roots_exclusive` | `absoluteGitRoots` combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
134
+ | `absolute_git_roots_preset_conflict` | `absoluteGitRoots` combined with a `preset` argument. |
135
+ | `invalid_absolute_git_root` | An `absoluteGitRoots` entry is not a git-recognized directory. |
136
+ | `absolute_git_roots_too_many` | More than 256 entries in `absoluteGitRoots`. |
137
+ | `absolute_git_roots_empty` | `absoluteGitRoots` resolved to zero toplevels. |
138
+ | `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
139
+ | `preset_not_found` | Named preset does not exist in the preset file. |
140
+ | `invalid_json` | Preset file contains invalid JSON. |
141
+ | `invalid_schema` | Preset file fails schema validation. |
142
+
143
+ Skip entries (individual repos that could not be inventoried) appear inline in `entries[*]` with `skipReason` rather than as top-level errors.
144
+
145
+ ---
146
+
147
+ ### `git_parity` — parameters
148
+
149
+ | Parameter | Type | Notes |
150
+ |-----------|------|-------|
151
+ | `pairs` | `{left: string, right: string, label?: string}[]` | Path pairs to compare. `left` and `right` are relative to the workspace git toplevel. `label` is optional display name; defaults to `"left / right"`. At least one pair required (via inline `pairs` or `preset`). |
152
+ | `preset` | string | Preset name from `.rethunk/git-mcp-presets.json`. Loads `parityPairs` from the preset's entry. |
153
+ | `presetMerge` | boolean | Default `false`. When `true`, merge inline `pairs` with preset pairs instead of replacing. |
154
+ | `workspaceRoot` | string | Explicit root; highest priority. |
155
+ | `rootIndex` | int | Pick one of several MCP roots (0-based). |
156
+ | `allWorkspaceRoots` | boolean | `false` | Fan out across all MCP roots. |
157
+ | `absoluteGitRoots` | string[] | Explicit list of absolute paths; replaces normal workspace pick. Useful for checking parity across sibling clones. |
158
+ | `format` | `"markdown"` \| `"json"` | Output format. Default: `"markdown"`. |
159
+
160
+ ### `git_parity` — JSON shape (`format: "json"`)
161
+
162
+ ```json
163
+ {
164
+ "parity": [{
165
+ "workspace_root": "/abs/path",
166
+ "status": "MISMATCH",
167
+ "pairs": [
168
+ {
169
+ "label": "shared",
170
+ "leftPath": "/abs/path/left",
171
+ "rightPath": "/abs/path/right",
172
+ "match": true,
173
+ "sha": "a1b2c3d4e5f6…"
174
+ },
175
+ {
176
+ "label": "config",
177
+ "leftPath": "/abs/path/cfg-a",
178
+ "rightPath": "/abs/path/cfg-b",
179
+ "match": false,
180
+ "leftSha": "a1b2c3d…",
181
+ "rightSha": "f9e8d7c…"
182
+ }
183
+ ]
184
+ }]
185
+ }
186
+ ```
187
+
188
+ `status` is `"OK"` when every pair matches, `"MISMATCH"` when any pair differs or errors. On a match, `sha` carries the common HEAD SHA. On a mismatch, `leftSha` / `rightSha` carry the differing SHAs. On error (path escape or `git rev-parse HEAD` failure), `error` carries a description string and both SHA fields are absent. `presetSchemaVersion` is present when a preset was loaded.
189
+
190
+ ### `git_parity` — error codes
191
+
192
+ | Code | Meaning |
193
+ |------|---------|
194
+ | `git_not_found` | `git` binary not on `PATH`. |
195
+ | `no_pairs` | Neither inline `pairs` nor a preset with `parityPairs` was supplied. |
196
+ | `absolute_git_roots_exclusive` | `absoluteGitRoots` combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
197
+ | `absolute_git_roots_preset_conflict` | `absoluteGitRoots` combined with a `preset` argument. |
198
+ | `invalid_absolute_git_root` | An `absoluteGitRoots` entry is not a git-recognized directory. |
199
+ | `absolute_git_roots_too_many` | More than 256 entries in `absoluteGitRoots`. |
200
+ | `absolute_git_roots_empty` | `absoluteGitRoots` resolved to zero toplevels. |
201
+ | `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
202
+ | `preset_not_found` | Named preset does not exist in the preset file. |
203
+ | `invalid_json` | Preset file contains invalid JSON. |
204
+ | `invalid_schema` | Preset file fails schema validation. |
205
+
206
+ Path-escape and `rev-parse` failures are reported inline in `pairs[*].error`, not as top-level error codes.
207
+
208
+ ---
209
+
210
+ ### `list_presets` — parameters
211
+
212
+ | Parameter | Type | Notes |
213
+ |-----------|------|-------|
214
+ | `workspaceRoot` | string | Explicit root; highest priority. |
215
+ | `rootIndex` | int | Pick one of several MCP roots (0-based). |
216
+ | `allWorkspaceRoots` | boolean | Default `false`. Fan out across all MCP roots. |
217
+ | `absoluteGitRoots` | string[] | Explicit list; replaces normal workspace pick. |
218
+ | `format` | `"markdown"` \| `"json"` | Output format. Default: `"markdown"`. |
219
+
220
+ ### `list_presets` — JSON shape (`format: "json"`)
221
+
222
+ ```json
223
+ {
224
+ "roots": [{
225
+ "workspaceRoot": "/abs/workspace",
226
+ "gitTop": "/abs/workspace",
227
+ "presetFile": "/abs/workspace/.rethunk/git-mcp-presets.json",
228
+ "fileExists": true,
229
+ "presetSchemaVersion": "1",
230
+ "presets": [
231
+ {
232
+ "name": "monorepo",
233
+ "nestedRootsCount": 5,
234
+ "parityPairsCount": 2,
235
+ "workspaceRootHint": "/abs/workspace"
236
+ }
237
+ ]
238
+ }]
239
+ }
240
+ ```
241
+
242
+ `gitTop` is `null` when the workspace root is not inside a git repository. `presetSchemaVersion` is omitted when absent. `workspaceRootHint` is omitted when not set. When `fileExists: false` the `presets` array is empty and no `error` is present. When the file exists but fails to load, `error` contains a structured error object and `presets` is empty.
243
+
244
+ ### `list_presets` — error codes
245
+
246
+ | Code | Meaning |
247
+ |------|---------|
248
+ | `git_not_found` | `git` binary not on `PATH`. |
249
+ | `not_a_git_repository` | Root is not inside a git repository (reported inline in the `roots[*].error` field, not top-level). |
250
+ | `invalid_json` | Preset file is not valid JSON (inline in `roots[*].error`). |
251
+ | `invalid_schema` | Preset file fails schema validation (inline in `roots[*].error`). |
252
+ | `absolute_git_roots_exclusive` | `absoluteGitRoots` combined with `workspaceRoot`, `rootIndex`, or `allWorkspaceRoots: true`. |
253
+ | `invalid_absolute_git_root` | An `absoluteGitRoots` entry is not a git-recognized directory. |
254
+ | `absolute_git_roots_too_many` | More than 256 entries in `absoluteGitRoots`. |
255
+ | `absolute_git_roots_empty` | `absoluteGitRoots` resolved to zero toplevels. |
256
+ | `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
257
+
258
+ ---
259
+
37
260
  ## JSON responses
38
261
 
39
262
  Tool JSON bodies are minified and contain only the payload — no `rethunkGitMcp` envelope. Current `MCP_JSON_FORMAT_VERSION` is **`"3"`**; server + format version are discoverable via MCP `initialize`. Payload keys (`groups`, `inventories`, `parity`, `roots`) are stable within a given format version. Preset-related responses may include **`presetSchemaVersion`**.
@@ -74,7 +297,7 @@ To keep responses compact, **optional fields are omitted when they would be empt
74
297
  | `workspaceRoot` | string | — | Explicit root; highest priority. |
75
298
  | `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
76
299
  | `allWorkspaceRoots` | boolean | `false` | Fan out across all MCP roots. |
77
- | `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
300
+ | `format` | `"markdown"` \| `"json"` \| `"oneline"` | `"markdown"` | Output format. `oneline` returns `<sha7> <subject>` per line with no headers (single root) or `### repo (branch)` separators per group (multi-root). Lowest-token option for post-commit verification. |
78
301
 
79
302
  ### `git_log` — JSON shape (`format: "json"`)
80
303
 
@@ -327,7 +550,7 @@ Do NOT do this: make two separate calls hoping to stage files incrementally. Tha
327
550
 
328
551
  | Parameter | Type | Notes |
329
552
  |-----------|------|-------|
330
- | `commits` | `{message: string, files: (string \| {path: string, lines: {from: number, to: number}})[]}[]` | Commits to create in order. 1–50 entries. Each `files` entry is either a path relative to the git root or a `{path, lines}` object for hunk-level staging. All paths must stay within the git toplevel. |
553
+ | `commits` | `{message: string, files: (string \| {path: string, lines: {from: number, to: number}})[]}[]` | Commits to create in order. 1–50 entries. Each `files` entry is either: (a) a path relative to the git root, staged with `git add`; (b) a `{path, lines: {from, to}}` object for hunk-level staging — only unified-diff hunks overlapping the given 1-indexed line range are staged; or (c) a path to a **deleted tracked file** (missing on disk but tracked in HEAD), which is staged as a removal via `git rm --cached` — combining `{path, lines}` with a deleted file is an error. All paths must stay within the git toplevel. |
331
554
  | `push` | `"never"` \| `"after"` | Default `"never"`. `"after"` pushes the current branch to its upstream **once all commits succeed**. Never auto-sets upstream — branches without an upstream fail with `push_no_upstream`. Commits are **not** rolled back on push failure. Enum reserved for future modes such as `"force-with-lease"`. |
332
555
  | `dryRun` | boolean | Default `false`. When `true`, stages each entry, reports what would be committed (`staged`, `diffStat`), then unstages everything without writing commits. |
333
556
  | `workspaceRoot` | string | Explicit root; highest priority. |
@@ -373,7 +596,7 @@ The `push` object is present only when `push: "after"` was requested **and** eve
373
596
  | Code | Meaning |
374
597
  |------|---------|
375
598
  | `path_escapes_repository` | One of the listed file paths resolves outside the git toplevel. |
376
- | `stage_failed` | `git add` failed (e.g. untracked path or permission error). |
599
+ | `stage_failed` | Staging failed. `git add` error for modified/new files; `git rm --cached` error for deleted files (e.g. path never tracked in HEAD); `{path, lines}` on a deleted file. |
377
600
  | `commit_failed` | `git commit` failed (e.g. nothing staged, hooks rejected). |
378
601
  | `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
379
602
 
@@ -457,8 +680,9 @@ On conflict: top-level `ok` is `false`, the conflicting entry has `ok: false` wi
457
680
  |-----------|------|-------|
458
681
  | `sources` | `string[]` | Source specs. 1–50 entries. Each entry is one of: a full/short SHA, an `A..B` / `A...B` range, or a branch name. Branch names expand to `onto..<branch>` (oldest-first). |
459
682
  | `onto` | string | Destination branch. Defaults to the currently checked-out branch. Rejected when HEAD is detached. |
460
- | `deleteMergedBranches` | boolean | Default `false`. After all commits apply, delete each **branch-kind** source locally (`git branch -d`) when it is fully merged into the destination by SHA-reachability (not patch-equivalence). Protected names always skipped; never touches remote refs. |
683
+ | `deleteMergedBranches` | boolean | Default `false`. After all commits apply, delete each **branch-kind** source locally. Deletion uses **patch-id equivalence** by default correct for cherry-pick workflows where SHA differs but diff is identical. Protected names always skipped; never touches remote refs. |
461
684
  | `deleteMergedWorktrees` | boolean | Default `false`. After success, remove any local worktree attached to a branch-kind source (`git worktree remove`). Protected tails always skipped. |
685
+ | `strictMergedRefEquality` | boolean | Default `false`. When `true`, branch deletion uses strict SHA-reachability (`git branch -d` ancestry semantics) instead of patch-id equivalence. Use when you need git's exact ancestry guarantee rather than content equivalence. |
462
686
  | `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
463
687
 
464
688
  ### `git_cherry_pick` — JSON shape (`format: "json"`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rethunk/mcp-multi-root-git",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "MCP stdio server: multi-root git status, inventory, and HEAD parity checks. Generic tools usable by any workspace.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -68,9 +68,19 @@
68
68
  "zod": "^4.4.3"
69
69
  },
70
70
  "devDependencies": {
71
- "@biomejs/biome": "^2.4.14",
72
- "@types/node": "^25.6.0",
71
+ "@biomejs/biome": "^2.4.15",
72
+ "@types/node": "^25.8.0",
73
73
  "rimraf": "^6.1.3",
74
74
  "typescript": "^6.0.3"
75
+ },
76
+ "overrides": {
77
+ "@hono/node-server": "^1.19.14",
78
+ "hono": "^4.12.18",
79
+ "fast-uri": "^3.1.2",
80
+ "postcss": "^8.5.10",
81
+ "vite": "^7.3.2",
82
+ "ip-address": "^10.1.1",
83
+ "axios": "^1.15.2",
84
+ "follow-redirects": "^1.15.12"
75
85
  }
76
86
  }
@@ -88,7 +88,7 @@
88
88
  }
89
89
  ]
90
90
  },
91
- "description": "Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."
91
+ "description": "Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. Combining { path, lines } with a deleted file is an error."
92
92
  }
93
93
  },
94
94
  "required": [
@@ -50,6 +50,11 @@
50
50
  "default": false,
51
51
  "description": "After success, remove any local worktree attached to a branch-kind source (`git worktree remove`). Protected tails always skipped.",
52
52
  "type": "boolean"
53
+ },
54
+ "strictMergedRefEquality": {
55
+ "default": false,
56
+ "description": "When false (default), branch deletion uses patch-id equivalence: a source branch is deleted when every commit it contains has a content-equivalent commit on the destination (same diff, different SHA — the normal cherry-pick outcome). Set to true to require strict ref ancestry (`git branch -d` semantics), which will refuse deletion after a cherry-pick because the SHA differs.",
57
+ "type": "boolean"
53
58
  }
54
59
  },
55
60
  "required": [
@@ -57,7 +62,8 @@
57
62
  "format",
58
63
  "sources",
59
64
  "deleteMergedBranches",
60
- "deleteMergedWorktrees"
65
+ "deleteMergedWorktrees",
66
+ "strictMergedRefEquality"
61
67
  ],
62
68
  "additionalProperties": false
63
69
  }
@@ -29,10 +29,12 @@
29
29
  },
30
30
  "format": {
31
31
  "default": "markdown",
32
+ "description": "`markdown` (default): headed sections per root. `json`: structured groups array. `oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification.",
32
33
  "type": "string",
33
34
  "enum": [
34
35
  "markdown",
35
- "json"
36
+ "json",
37
+ "oneline"
36
38
  ]
37
39
  },
38
40
  "since": {
@@ -266,10 +266,12 @@
266
266
  },
267
267
  "format": {
268
268
  "default": "markdown",
269
+ "description": "`markdown` (default): headed sections per root. `json`: structured groups array. `oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification.",
269
270
  "type": "string",
270
271
  "enum": [
271
272
  "markdown",
272
- "json"
273
+ "json",
274
+ "oneline"
273
275
  ]
274
276
  },
275
277
  "since": {
@@ -674,7 +676,7 @@
674
676
  }
675
677
  ]
676
678
  },
677
- "description": "Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."
679
+ "description": "Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging. Deleted files (missing on disk but tracked in HEAD) are staged as removals via `git rm --cached`. Combining { path, lines } with a deleted file is an error."
678
680
  }
679
681
  },
680
682
  "required": [
@@ -884,6 +886,11 @@
884
886
  "default": false,
885
887
  "description": "After success, remove any local worktree attached to a branch-kind source (`git worktree remove`). Protected tails always skipped.",
886
888
  "type": "boolean"
889
+ },
890
+ "strictMergedRefEquality": {
891
+ "default": false,
892
+ "description": "When false (default), branch deletion uses patch-id equivalence: a source branch is deleted when every commit it contains has a content-equivalent commit on the destination (same diff, different SHA — the normal cherry-pick outcome). Set to true to require strict ref ancestry (`git branch -d` semantics), which will refuse deletion after a cherry-pick because the SHA differs.",
893
+ "type": "boolean"
887
894
  }
888
895
  },
889
896
  "required": [
@@ -891,7 +898,8 @@
891
898
  "format",
892
899
  "sources",
893
900
  "deleteMergedBranches",
894
- "deleteMergedWorktrees"
901
+ "deleteMergedWorktrees",
902
+ "strictMergedRefEquality"
895
903
  ],
896
904
  "additionalProperties": false
897
905
  },