@rethunk/mcp-multi-root-git 2.4.0 → 2.6.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 +4 -4
- package/CHANGELOG.md +55 -1
- package/dist/server/batch-commit-tool.js +49 -6
- package/dist/server/git-cherry-pick-tool.js +32 -8
- package/dist/server/git-diff-summary-tool.js +46 -22
- package/dist/server/git-diff-tool.js +4 -1
- package/dist/server/git-fetch-tool.js +8 -1
- package/dist/server/git-inventory-tool.js +2 -2
- package/dist/server/git-log-tool.js +42 -0
- package/dist/server/git-parity-tool.js +2 -2
- package/dist/server/git-refs.js +84 -5
- package/dist/server/git-show-tool.js +16 -2
- package/dist/server/git-stash-tool.js +17 -16
- package/dist/server/git-tag-tool.js +3 -3
- package/dist/server/git.js +33 -5
- package/dist/server.js +7 -0
- package/docs/mcp-tools.md +246 -13
- package/package.json +14 -3
- package/schemas/batch_commit.json +1 -1
- package/schemas/git_cherry_pick.json +7 -1
- package/schemas/git_diff.json +0 -14
- package/schemas/git_diff_summary.json +0 -6
- package/schemas/git_log.json +3 -1
- package/schemas/git_show.json +1 -0
- package/tool-parameters.schema.json +12 -23
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)
|
|
@@ -24,17 +25,24 @@ const PROTECTED_EXACT = new Set([
|
|
|
24
25
|
"trunk",
|
|
25
26
|
"prod",
|
|
26
27
|
"production",
|
|
27
|
-
"
|
|
28
|
+
"head",
|
|
28
29
|
]);
|
|
29
30
|
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
|
|
30
31
|
/** True when a branch name is on the protected list and must not be auto-deleted. */
|
|
31
32
|
export function isProtectedBranch(name) {
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// Normalize: trim whitespace, strip leading refs/heads/ prefix, then lowercase.
|
|
34
|
+
const trimmed = name.trim();
|
|
35
|
+
if (trimmed === "")
|
|
34
36
|
return true;
|
|
35
|
-
|
|
37
|
+
const stripped = trimmed.startsWith("refs/heads/")
|
|
38
|
+
? trimmed.slice("refs/heads/".length)
|
|
39
|
+
: trimmed;
|
|
40
|
+
const normalized = stripped.toLowerCase();
|
|
41
|
+
if (normalized === "")
|
|
36
42
|
return true;
|
|
37
|
-
|
|
43
|
+
if (PROTECTED_EXACT.has(normalized))
|
|
44
|
+
return true;
|
|
45
|
+
return PROTECTED_PATTERN.test(normalized);
|
|
38
46
|
}
|
|
39
47
|
// ---------------------------------------------------------------------------
|
|
40
48
|
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
|
|
@@ -124,6 +132,77 @@ export async function isFullyMergedInto(cwd, branch, target) {
|
|
|
124
132
|
const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
|
|
125
133
|
return r.ok;
|
|
126
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Returns the git patch-id for a single commit, or undefined on failure.
|
|
137
|
+
* Uses execFileSync to pipe `git diff-tree --patch -r <sha>` into `git patch-id --stable`.
|
|
138
|
+
*/
|
|
139
|
+
function commitPatchId(cwd, sha) {
|
|
140
|
+
try {
|
|
141
|
+
const diff = execFileSync("git", ["diff-tree", "--patch", "-r", sha], {
|
|
142
|
+
cwd,
|
|
143
|
+
encoding: "utf8",
|
|
144
|
+
});
|
|
145
|
+
const out = execFileSync("git", ["patch-id", "--stable"], {
|
|
146
|
+
cwd,
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
input: diff,
|
|
149
|
+
});
|
|
150
|
+
return out.trim().split(" ")[0] || undefined;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Returns true when every commit reachable from `branch` but not from `target` has a
|
|
158
|
+
* patch-equivalent commit already in `target` (content-equivalent merge check for
|
|
159
|
+
* cherry-pick workflows where SHA differs but diff is identical).
|
|
160
|
+
*
|
|
161
|
+
* Falls back to ref-equality (`isFullyMergedInto`) when patch-id comparison fails.
|
|
162
|
+
*/
|
|
163
|
+
export async function isContentEquivalentlyMergedInto(cwd, branch, target) {
|
|
164
|
+
if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
|
|
165
|
+
return false;
|
|
166
|
+
// Fast path: ref equality (normal merge).
|
|
167
|
+
const refMerged = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
|
|
168
|
+
if (refMerged.ok)
|
|
169
|
+
return true;
|
|
170
|
+
// Collect commits in branch not reachable from target.
|
|
171
|
+
const srcList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${branch}`, `^${target}`]);
|
|
172
|
+
if (!srcList.ok)
|
|
173
|
+
return false;
|
|
174
|
+
const srcShas = srcList.stdout
|
|
175
|
+
.split("\n")
|
|
176
|
+
.map((l) => l.trim())
|
|
177
|
+
.filter((l) => l.length > 0);
|
|
178
|
+
if (srcShas.length === 0)
|
|
179
|
+
return true; // nothing to check
|
|
180
|
+
// Build patch-id set for target commits since merge-base.
|
|
181
|
+
const base = await spawnGitAsync(cwd, ["merge-base", branch, target]);
|
|
182
|
+
if (!base.ok)
|
|
183
|
+
return false;
|
|
184
|
+
const baseSha = base.stdout.trim();
|
|
185
|
+
const destList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${baseSha}..${target}`]);
|
|
186
|
+
if (!destList.ok)
|
|
187
|
+
return false;
|
|
188
|
+
const destShas = destList.stdout
|
|
189
|
+
.split("\n")
|
|
190
|
+
.map((l) => l.trim())
|
|
191
|
+
.filter((l) => l.length > 0);
|
|
192
|
+
const destPatchIds = new Set();
|
|
193
|
+
for (const sha of destShas) {
|
|
194
|
+
const patchId = commitPatchId(cwd, sha);
|
|
195
|
+
if (patchId)
|
|
196
|
+
destPatchIds.add(patchId);
|
|
197
|
+
}
|
|
198
|
+
// Every source commit must have its patch-id present in destination.
|
|
199
|
+
for (const sha of srcShas) {
|
|
200
|
+
const patchId = commitPatchId(cwd, sha);
|
|
201
|
+
if (!patchId || !destPatchIds.has(patchId))
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
127
206
|
/**
|
|
128
207
|
* SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
|
|
129
208
|
* Returns `null` on git failure.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
2
3
|
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
3
5
|
import { jsonRespond } from "./json.js";
|
|
4
6
|
import { requireSingleRepo } from "./roots.js";
|
|
5
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -12,7 +14,7 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
12
14
|
*/
|
|
13
15
|
async function runGitShow(opts) {
|
|
14
16
|
const { top, ref, path } = opts;
|
|
15
|
-
// Build git show args.
|
|
17
|
+
// Build git show args. Shows commit message + full diff (or file content when path given).
|
|
16
18
|
const showArgs = ["show", ref];
|
|
17
19
|
if (path) {
|
|
18
20
|
// When path is specified, show that path at the ref without --no-patch
|
|
@@ -119,7 +121,10 @@ export function registerGitShowTool(server) {
|
|
|
119
121
|
format: true,
|
|
120
122
|
})
|
|
121
123
|
.extend({
|
|
122
|
-
ref: z
|
|
124
|
+
ref: z
|
|
125
|
+
.string()
|
|
126
|
+
.min(1)
|
|
127
|
+
.describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
|
|
123
128
|
path: z
|
|
124
129
|
.string()
|
|
125
130
|
.optional()
|
|
@@ -130,6 +135,15 @@ export function registerGitShowTool(server) {
|
|
|
130
135
|
if (!pre.ok)
|
|
131
136
|
return jsonRespond(pre.error);
|
|
132
137
|
const top = pre.gitTop;
|
|
138
|
+
if (!isSafeGitAncestorRef(args.ref)) {
|
|
139
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
|
|
140
|
+
}
|
|
141
|
+
if (args.path !== undefined) {
|
|
142
|
+
const resolved = resolvePathForRepo(args.path, top);
|
|
143
|
+
if (!assertRelativePathUnderTop(args.path, resolved, top)) {
|
|
144
|
+
return jsonRespond({ error: "path_escapes_repo", path: args.path });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
133
147
|
const result = await runGitShow({
|
|
134
148
|
top,
|
|
135
149
|
ref: args.ref,
|
|
@@ -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
|
|
@@ -42,18 +39,22 @@ export function registerGitStashListTool(server) {
|
|
|
42
39
|
.split("\n")
|
|
43
40
|
.map((l) => l.trim())
|
|
44
41
|
.filter((l) => l.length > 0);
|
|
45
|
-
for (
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const parts = line.split("|");
|
|
44
|
+
// parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
|
|
45
|
+
const sha = parts[parts.length - 1];
|
|
46
|
+
const message = parts.slice(1, -1).join("|");
|
|
47
|
+
// Parse the real stash index from the canonical stash@{N} ref in parts[0].
|
|
48
|
+
const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
|
|
49
|
+
if (!indexMatch || parts.length < 3 || !message || !sha) {
|
|
50
|
+
// Malformed line — skip without affecting index tracking.
|
|
51
|
+
continue;
|
|
56
52
|
}
|
|
53
|
+
stashes.push({
|
|
54
|
+
index: Number(indexMatch[1]),
|
|
55
|
+
message,
|
|
56
|
+
sha,
|
|
57
|
+
});
|
|
57
58
|
}
|
|
58
59
|
if (args.format === "json") {
|
|
59
60
|
return jsonRespond({ stashes });
|
|
@@ -72,11 +72,11 @@ export function registerGitTagTool(server) {
|
|
|
72
72
|
const gitTop = pre.gitTop;
|
|
73
73
|
const tag = args.tag.trim();
|
|
74
74
|
if (!tag) {
|
|
75
|
-
return jsonRespond({ error: "
|
|
75
|
+
return jsonRespond({ error: "empty_tag_name" });
|
|
76
76
|
}
|
|
77
77
|
// Validate tag name: no shell metacharacters
|
|
78
78
|
if (!isSafeGitUpstreamToken(tag)) {
|
|
79
|
-
return jsonRespond({ error: "
|
|
79
|
+
return jsonRespond({ error: "unsafe_tag_token", tag });
|
|
80
80
|
}
|
|
81
81
|
// Handle deletion
|
|
82
82
|
if (args.delete === true) {
|
|
@@ -99,7 +99,7 @@ export function registerGitTagTool(server) {
|
|
|
99
99
|
// Determine the ref to tag (default HEAD)
|
|
100
100
|
const ref = (args.ref ?? "HEAD").trim();
|
|
101
101
|
if (!isSafeGitUpstreamToken(ref)) {
|
|
102
|
-
return jsonRespond({ error: "
|
|
102
|
+
return jsonRespond({ error: "unsafe_ref_token", ref });
|
|
103
103
|
}
|
|
104
104
|
// Get the SHA of the ref to tag
|
|
105
105
|
const sha = await getRefSha(gitTop, ref);
|
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,12 +77,40 @@ 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
|
-
|
|
83
|
-
|
|
97
|
+
let inSubmoduleSection = false;
|
|
98
|
+
for (const rawLine of text.split("\n")) {
|
|
99
|
+
// Strip inline and whole-line comments (; and #)
|
|
100
|
+
const commentIdx = rawLine.search(/\s*[;#]/);
|
|
101
|
+
const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine;
|
|
102
|
+
// Track INI section header
|
|
103
|
+
const sectionMatch = /^\s*\[(.+)\]\s*$/.exec(line);
|
|
104
|
+
if (sectionMatch) {
|
|
105
|
+
inSubmoduleSection = /^submodule\s+"/.test(sectionMatch[1] ?? "");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Only collect path = lines inside a [submodule "..."] section
|
|
109
|
+
if (!inSubmoduleSection)
|
|
110
|
+
continue;
|
|
111
|
+
const m = /^\s*path\s*=\s*(.+?)\s*$/.exec(line);
|
|
84
112
|
if (m?.[1])
|
|
85
|
-
paths.push(m[1]
|
|
113
|
+
paths.push(m[1]);
|
|
86
114
|
}
|
|
87
115
|
return paths;
|
|
88
116
|
}
|
package/dist/server.js
CHANGED
|
@@ -2,9 +2,16 @@
|
|
|
2
2
|
import { FastMCP } from "fastmcp";
|
|
3
3
|
import { readMcpServerVersion } from "./server/json.js";
|
|
4
4
|
import { registerRethunkGitTools } from "./server/tools.js";
|
|
5
|
+
/**
|
|
6
|
+
* JSON payload contract version. Bump on incompatible JSON output changes
|
|
7
|
+
* (renamed/nested/omitted fields). Surfaced via the FastMCP `instructions`
|
|
8
|
+
* field below, so it is discoverable in the MCP `initialize` response.
|
|
9
|
+
*/
|
|
10
|
+
export const MCP_JSON_FORMAT_VERSION = "3";
|
|
5
11
|
const server = new FastMCP({
|
|
6
12
|
name: "rethunk-git",
|
|
7
13
|
version: readMcpServerVersion(),
|
|
14
|
+
instructions: `rethunk-git MCP server. JSON payload contract: format version ${MCP_JSON_FORMAT_VERSION} (minified, no envelope; optional fields omitted when empty/null/false).`,
|
|
8
15
|
roots: { enabled: true },
|
|
9
16
|
});
|
|
10
17
|
registerRethunkGitTools(server);
|