@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 +1 -1
- package/CHANGELOG.md +31 -0
- package/dist/server/batch-commit-tool.js +24 -2
- package/dist/server/git-cherry-pick-tool.js +32 -8
- package/dist/server/git-diff-summary-tool.js +39 -21
- package/dist/server/git-log-tool.js +37 -0
- package/dist/server/git-refs.js +72 -0
- package/dist/server/git-show-tool.js +1 -1
- package/dist/server/git-stash-tool.js +8 -8
- package/dist/server/git.js +17 -2
- package/docs/mcp-tools.md +230 -6
- package/package.json +13 -3
- package/schemas/batch_commit.json +1 -1
- package/schemas/git_cherry_pick.json +7 -1
- package/schemas/git_log.json +3 -1
- package/tool-parameters.schema.json +11 -3
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
|
-
|
|
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
|
|
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) {
|
|
@@ -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) {
|
package/dist/server/git-refs.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
53
|
-
sha
|
|
52
|
+
message,
|
|
53
|
+
sha,
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
}
|
package/dist/server/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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.
|
|
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.
|
|
72
|
-
"@types/node": "^25.
|
|
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
|
}
|
package/schemas/git_log.json
CHANGED
|
@@ -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
|
},
|