@rethunk/mcp-multi-root-git 3.1.0 → 4.0.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 +21 -15
- package/CHANGELOG.md +114 -39
- package/HUMANS.md +20 -39
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +187 -46
- package/dist/server/error-codes.js +25 -7
- package/dist/server/git-blame-tool.js +6 -3
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +220 -11
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +36 -25
- package/dist/server/git-diff-tool.js +55 -27
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +26 -6
- package/dist/server/git-log-tool.js +35 -4
- package/dist/server/git-merge-tool.js +70 -12
- package/dist/server/git-parity-tool.js +13 -4
- package/dist/server/git-push-tool.js +3 -1
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +7 -3
- package/dist/server/git-stash-tool.js +110 -67
- package/dist/server/git-tag-tool.js +7 -6
- package/dist/server/git-worktree-tool.js +65 -53
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +13 -1
- package/dist/server/schemas.js +9 -2
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +44 -55
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +385 -178
- package/package.json +6 -6
- package/schemas/batch_commit.json +2 -2
- package/schemas/git_blame.json +1 -1
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +12 -2
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
- package/schemas/git_diff.json +11 -3
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +19 -5
- package/schemas/git_log.json +7 -6
- package/schemas/git_merge.json +2 -2
- package/schemas/git_parity.json +0 -5
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +1 -1
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +0 -5
- package/schemas/git_worktree_add.json +1 -1
- package/schemas/git_worktree_remove.json +1 -1
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +0 -5
- package/tool-parameters.schema.json +326 -167
- package/dist/server/git-branch-list-tool.js +0 -132
- package/dist/server/git-fetch-tool.js +0 -257
- package/dist/server/git-reflog-tool.js +0 -120
- package/schemas/git_branch_list.json +0 -30
- package/schemas/git_fetch.json +0 -46
- package/schemas/git_stash_list.json +0 -24
- package/schemas/git_worktree_list.json +0 -24
package/dist/server/git.js
CHANGED
|
@@ -7,13 +7,11 @@ import { ERROR_CODES } from "./error-codes.js";
|
|
|
7
7
|
* Parallel git subprocesses for inventory rows and git_status submodule rows.
|
|
8
8
|
* Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
|
|
9
9
|
*/
|
|
10
|
-
function resolveGitSubprocessParallelism() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const n = Number.parseInt(env, 10);
|
|
10
|
+
export function resolveGitSubprocessParallelism(envValue = process.env.GIT_SUBPROCESS_PARALLELISM, cpuCount = cpus().length) {
|
|
11
|
+
if (envValue) {
|
|
12
|
+
const n = Number.parseInt(envValue, 10);
|
|
14
13
|
if (!Number.isNaN(n) && n >= 1) {
|
|
15
|
-
const
|
|
16
|
-
const maxParallel = cpuCount * 2;
|
|
14
|
+
const maxParallel = Math.max(1, cpuCount * 2);
|
|
17
15
|
return Math.min(n, maxParallel);
|
|
18
16
|
}
|
|
19
17
|
}
|
|
@@ -26,10 +24,9 @@ export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
|
|
|
26
24
|
* A value of 0 (or negative/NaN) disables the timeout — use for operations
|
|
27
25
|
* like large clones where unbounded wait is intentional.
|
|
28
26
|
*/
|
|
29
|
-
function resolveGitSubprocessTimeoutMs() {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const n = Number.parseInt(env, 10);
|
|
27
|
+
export function resolveGitSubprocessTimeoutMs(envValue = process.env.GIT_SUBPROCESS_TIMEOUT_MS) {
|
|
28
|
+
if (envValue) {
|
|
29
|
+
const n = Number.parseInt(envValue, 10);
|
|
33
30
|
if (!Number.isNaN(n) && n > 0)
|
|
34
31
|
return n;
|
|
35
32
|
// 0 or negative → disabled
|
|
@@ -39,10 +36,31 @@ function resolveGitSubprocessTimeoutMs() {
|
|
|
39
36
|
return 120_000;
|
|
40
37
|
}
|
|
41
38
|
export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
|
|
39
|
+
/**
|
|
40
|
+
* Max combined stdout+stderr bytes retained from spawnGitAsync.
|
|
41
|
+
* Env: GIT_SUBPROCESS_MAX_BUFFER_BYTES (default 16 MiB). Exceeding kills the child.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveGitSubprocessMaxBufferBytes(envValue = process.env.GIT_SUBPROCESS_MAX_BUFFER_BYTES) {
|
|
44
|
+
if (envValue) {
|
|
45
|
+
const n = Number.parseInt(envValue, 10);
|
|
46
|
+
if (!Number.isNaN(n) && n >= 1024)
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
return 16 * 1024 * 1024;
|
|
50
|
+
}
|
|
51
|
+
export const GIT_SUBPROCESS_MAX_BUFFER_BYTES = resolveGitSubprocessMaxBufferBytes();
|
|
52
|
+
/** Delay after SIGTERM before escalating to SIGKILL (spawnGitAsync timeout/abort/overflow). */
|
|
53
|
+
export const GIT_SUBPROCESS_SIGKILL_ESCALATION_MS = 2_000;
|
|
54
|
+
/** Timeout for sync spawnSync helpers (gateGit, rev-parse). */
|
|
55
|
+
export const GIT_SYNC_TIMEOUT_MS = 30_000;
|
|
42
56
|
let gitPathState = "unknown";
|
|
43
57
|
const GIT_NOT_FOUND_BODY = {
|
|
44
58
|
error: ERROR_CODES.GIT_NOT_FOUND,
|
|
45
59
|
};
|
|
60
|
+
/** Test-only: reset the cached git-on-PATH probe. */
|
|
61
|
+
export function resetGitPathStateForTests() {
|
|
62
|
+
gitPathState = "unknown";
|
|
63
|
+
}
|
|
46
64
|
export function gateGit() {
|
|
47
65
|
if (gitPathState === "ok") {
|
|
48
66
|
return { ok: true };
|
|
@@ -53,9 +71,16 @@ export function gateGit() {
|
|
|
53
71
|
body: GIT_NOT_FOUND_BODY,
|
|
54
72
|
};
|
|
55
73
|
}
|
|
56
|
-
const r = spawnSync("git", ["--version"], {
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
const r = spawnSync("git", ["--version"], {
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
77
|
+
});
|
|
78
|
+
if (r.error || r.status !== 0) {
|
|
79
|
+
// Do not cache "missing" on timeout — a wedged git may recover.
|
|
80
|
+
const timedOut = r.error !== undefined && r.error.code === "ETIMEDOUT";
|
|
81
|
+
if (!timedOut) {
|
|
82
|
+
gitPathState = "missing";
|
|
83
|
+
}
|
|
59
84
|
return {
|
|
60
85
|
ok: false,
|
|
61
86
|
body: GIT_NOT_FOUND_BODY,
|
|
@@ -71,8 +96,9 @@ export function gitTopLevel(cwd) {
|
|
|
71
96
|
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
72
97
|
cwd,
|
|
73
98
|
encoding: "utf8",
|
|
99
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
74
100
|
});
|
|
75
|
-
if (r.status !== 0)
|
|
101
|
+
if (r.error || r.status !== 0)
|
|
76
102
|
return null;
|
|
77
103
|
return r.stdout.trim();
|
|
78
104
|
}
|
|
@@ -80,15 +106,17 @@ export function gitRevParseGitDir(cwd) {
|
|
|
80
106
|
const r = spawnSync("git", ["rev-parse", "--git-dir"], {
|
|
81
107
|
cwd,
|
|
82
108
|
encoding: "utf8",
|
|
109
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
83
110
|
});
|
|
84
|
-
return r.status === 0;
|
|
111
|
+
return !r.error && r.status === 0;
|
|
85
112
|
}
|
|
86
113
|
export function gitRevParseHead(cwd) {
|
|
87
114
|
const r = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
88
115
|
cwd,
|
|
89
116
|
encoding: "utf8",
|
|
117
|
+
timeout: GIT_SYNC_TIMEOUT_MS,
|
|
90
118
|
});
|
|
91
|
-
if (r.status !== 0) {
|
|
119
|
+
if (r.error || r.status !== 0) {
|
|
92
120
|
return { ok: false, text: (r.stderr || r.stdout || "git rev-parse HEAD failed").trim() };
|
|
93
121
|
}
|
|
94
122
|
return { ok: true, sha: r.stdout.trim(), text: r.stdout.trim() };
|
|
@@ -163,17 +191,70 @@ export async function asyncPool(items, concurrency, fn) {
|
|
|
163
191
|
}
|
|
164
192
|
export function spawnGitAsync(cwd, args, opts) {
|
|
165
193
|
return new Promise((resolveP) => {
|
|
166
|
-
const child = spawn("git", args, { cwd, stdio: ["
|
|
194
|
+
const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
167
195
|
let stdout = "";
|
|
168
196
|
let stderr = "";
|
|
169
197
|
let settled = false;
|
|
198
|
+
let stdoutBytes = 0;
|
|
199
|
+
let stderrBytes = 0;
|
|
200
|
+
let sigkillTimer;
|
|
201
|
+
const maxBuffer = opts?.maxBufferBytes ?? GIT_SUBPROCESS_MAX_BUFFER_BYTES;
|
|
202
|
+
const sigkillAfter = opts?.sigkillAfterMs ?? GIT_SUBPROCESS_SIGKILL_ESCALATION_MS;
|
|
203
|
+
if (!opts?.holdStdin) {
|
|
204
|
+
child.stdin?.end();
|
|
205
|
+
}
|
|
170
206
|
child.stdout?.setEncoding("utf8");
|
|
171
207
|
child.stderr?.setEncoding("utf8");
|
|
208
|
+
function escalateKill() {
|
|
209
|
+
try {
|
|
210
|
+
child.kill("SIGTERM");
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
/* already dead */
|
|
214
|
+
}
|
|
215
|
+
if (sigkillTimer !== undefined)
|
|
216
|
+
return;
|
|
217
|
+
sigkillTimer = setTimeout(() => {
|
|
218
|
+
sigkillTimer = undefined;
|
|
219
|
+
try {
|
|
220
|
+
if (!settled && child.exitCode === null && child.signalCode === null) {
|
|
221
|
+
child.kill("SIGKILL");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* already dead */
|
|
226
|
+
}
|
|
227
|
+
}, sigkillAfter);
|
|
228
|
+
// Do not keep the process alive solely for SIGKILL escalation.
|
|
229
|
+
if (typeof sigkillTimer === "object" && "unref" in sigkillTimer) {
|
|
230
|
+
sigkillTimer.unref();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function onChunk(stream, chunk) {
|
|
234
|
+
const byteLen = Buffer.byteLength(chunk, "utf8");
|
|
235
|
+
if (stream === "stdout") {
|
|
236
|
+
stdoutBytes += byteLen;
|
|
237
|
+
stdout += chunk;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
stderrBytes += byteLen;
|
|
241
|
+
stderr += chunk;
|
|
242
|
+
}
|
|
243
|
+
if (stdoutBytes + stderrBytes > maxBuffer) {
|
|
244
|
+
escalateKill();
|
|
245
|
+
settle({
|
|
246
|
+
ok: false,
|
|
247
|
+
stdout,
|
|
248
|
+
stderr: `${stderr}\n<git output exceeded ${maxBuffer} bytes>`,
|
|
249
|
+
truncated: true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
172
253
|
child.stdout?.on("data", (c) => {
|
|
173
|
-
stdout
|
|
254
|
+
onChunk("stdout", c);
|
|
174
255
|
});
|
|
175
256
|
child.stderr?.on("data", (c) => {
|
|
176
|
-
stderr
|
|
257
|
+
onChunk("stderr", c);
|
|
177
258
|
});
|
|
178
259
|
const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
|
|
179
260
|
let timer;
|
|
@@ -183,10 +264,20 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
183
264
|
clearTimeout(timer);
|
|
184
265
|
timer = undefined;
|
|
185
266
|
}
|
|
267
|
+
if (sigkillTimer !== undefined) {
|
|
268
|
+
clearTimeout(sigkillTimer);
|
|
269
|
+
sigkillTimer = undefined;
|
|
270
|
+
}
|
|
186
271
|
if (abortListener !== undefined && opts?.signal) {
|
|
187
272
|
opts.signal.removeEventListener("abort", abortListener);
|
|
188
273
|
abortListener = undefined;
|
|
189
274
|
}
|
|
275
|
+
try {
|
|
276
|
+
child.stdin?.destroy();
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
/* ignore */
|
|
280
|
+
}
|
|
190
281
|
}
|
|
191
282
|
function settle(result) {
|
|
192
283
|
if (settled)
|
|
@@ -195,15 +286,18 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
195
286
|
cleanup();
|
|
196
287
|
resolveP(result);
|
|
197
288
|
}
|
|
289
|
+
// Register lifecycle handlers before any early kill so close/error are observed.
|
|
290
|
+
child.on("error", () => settle({ ok: false, stdout, stderr }));
|
|
291
|
+
child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
|
|
198
292
|
// AbortSignal: kill immediately if already aborted, else listen
|
|
199
293
|
if (opts?.signal) {
|
|
200
294
|
if (opts.signal.aborted) {
|
|
201
|
-
|
|
295
|
+
escalateKill();
|
|
202
296
|
settle({ ok: false, stdout, stderr, aborted: true });
|
|
203
297
|
return;
|
|
204
298
|
}
|
|
205
299
|
abortListener = () => {
|
|
206
|
-
|
|
300
|
+
escalateKill();
|
|
207
301
|
settle({ ok: false, stdout, stderr, aborted: true });
|
|
208
302
|
};
|
|
209
303
|
opts.signal.addEventListener("abort", abortListener, { once: true });
|
|
@@ -211,7 +305,7 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
211
305
|
// Timeout: set timer if effectiveTimeout > 0
|
|
212
306
|
if (effectiveTimeout > 0) {
|
|
213
307
|
timer = setTimeout(() => {
|
|
214
|
-
|
|
308
|
+
escalateKill();
|
|
215
309
|
settle({
|
|
216
310
|
ok: false,
|
|
217
311
|
stdout,
|
|
@@ -220,8 +314,6 @@ export function spawnGitAsync(cwd, args, opts) {
|
|
|
220
314
|
});
|
|
221
315
|
}, effectiveTimeout);
|
|
222
316
|
}
|
|
223
|
-
child.on("error", () => settle({ ok: false, stdout, stderr }));
|
|
224
|
-
child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
|
|
225
317
|
});
|
|
226
318
|
}
|
|
227
319
|
function gitStatusFailText(r) {
|
package/dist/server/inventory.js
CHANGED
|
@@ -22,6 +22,15 @@ export function buildInventorySectionMarkdown(e) {
|
|
|
22
22
|
else if (e.upstreamNote) {
|
|
23
23
|
lines.push(`upstream: ${e.upstreamNote}`);
|
|
24
24
|
}
|
|
25
|
+
if (e.compareRefs) {
|
|
26
|
+
const cr = e.compareRefs;
|
|
27
|
+
if (cr.ahead !== undefined && cr.behind !== undefined) {
|
|
28
|
+
lines.push(`${cr.left}...${cr.right}: ahead ${cr.ahead}, behind ${cr.behind}`);
|
|
29
|
+
}
|
|
30
|
+
else if (cr.note) {
|
|
31
|
+
lines.push(`compareRefs ${cr.left}...${cr.right}: ${cr.note}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
const single = lines.length === 1 ? lines[0] : undefined;
|
|
26
35
|
if (single !== undefined && !single.includes("\n")) {
|
|
27
36
|
return ["", header, single];
|
|
@@ -53,7 +62,48 @@ function buildEntry(params) {
|
|
|
53
62
|
out.upstreamNote = params.upstreamNote;
|
|
54
63
|
return out;
|
|
55
64
|
}
|
|
56
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Ahead = commits reachable from `right` but not `left` (`left..right`).
|
|
67
|
+
* Behind = commits reachable from `left` but not `right` (`right..left`).
|
|
68
|
+
*/
|
|
69
|
+
async function fetchCompareAheadBehind(absPath, left, right) {
|
|
70
|
+
const [aheadR, behindR] = await Promise.all([
|
|
71
|
+
spawnGitAsync(absPath, ["rev-list", "--count", `${left}..${right}`]),
|
|
72
|
+
spawnGitAsync(absPath, ["rev-list", "--count", `${right}..${left}`]),
|
|
73
|
+
]);
|
|
74
|
+
return {
|
|
75
|
+
ahead: aheadR.ok ? aheadR.stdout.trim() : null,
|
|
76
|
+
behind: behindR.ok ? behindR.stdout.trim() : null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function attachCompareRefs(entry, absPath, compareRefs) {
|
|
80
|
+
if (!compareRefs)
|
|
81
|
+
return entry;
|
|
82
|
+
const left = compareRefs.left;
|
|
83
|
+
const right = compareRefs.right;
|
|
84
|
+
const [leftOk, rightOk] = await Promise.all([
|
|
85
|
+
spawnGitAsync(absPath, ["rev-parse", "--verify", left]),
|
|
86
|
+
spawnGitAsync(absPath, ["rev-parse", "--verify", right]),
|
|
87
|
+
]);
|
|
88
|
+
if (!leftOk.ok || !rightOk.ok) {
|
|
89
|
+
entry.compareRefs = {
|
|
90
|
+
left,
|
|
91
|
+
right,
|
|
92
|
+
note: `(ref unreadable: ${[!leftOk.ok ? left : "", !rightOk.ok ? right : ""].filter(Boolean).join(", ")})`,
|
|
93
|
+
};
|
|
94
|
+
return entry;
|
|
95
|
+
}
|
|
96
|
+
const { ahead, behind } = await fetchCompareAheadBehind(absPath, left, right);
|
|
97
|
+
entry.compareRefs = {
|
|
98
|
+
left,
|
|
99
|
+
right,
|
|
100
|
+
...(ahead != null ? { ahead } : {}),
|
|
101
|
+
...(behind != null ? { behind } : {}),
|
|
102
|
+
...(ahead == null || behind == null ? { note: "(counts unreadable)" } : {}),
|
|
103
|
+
};
|
|
104
|
+
return entry;
|
|
105
|
+
}
|
|
106
|
+
export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch, compareRefs) {
|
|
57
107
|
const [snap, headR] = await Promise.all([
|
|
58
108
|
gitStatusSnapshotAsync(absPath),
|
|
59
109
|
spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
@@ -62,11 +112,12 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
|
|
|
62
112
|
const headAbbrev = headR.ok ? headR.stdout.trim() : "";
|
|
63
113
|
const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
|
|
64
114
|
const base = { label, absPath, branchStatus, detached, headAbbrev };
|
|
115
|
+
let entry;
|
|
65
116
|
if (fixedRemote !== undefined && fixedBranch !== undefined) {
|
|
66
117
|
const ref = `${fixedRemote}/${fixedBranch}`;
|
|
67
118
|
const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
|
|
68
119
|
if (!verify.ok) {
|
|
69
|
-
|
|
120
|
+
entry = buildEntry({
|
|
70
121
|
...base,
|
|
71
122
|
upstreamMode: "fixed",
|
|
72
123
|
upstreamRef: ref,
|
|
@@ -75,36 +126,43 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
|
|
|
75
126
|
upstreamNote: `(no local ref ${ref} or unreadable)`,
|
|
76
127
|
});
|
|
77
128
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
129
|
+
else {
|
|
130
|
+
const { ahead, behind } = await fetchAheadBehind(absPath, ref);
|
|
131
|
+
entry = buildEntry({
|
|
132
|
+
...base,
|
|
133
|
+
upstreamMode: "fixed",
|
|
134
|
+
upstreamRef: ref,
|
|
135
|
+
ahead,
|
|
136
|
+
behind,
|
|
137
|
+
upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
87
140
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
141
|
+
else {
|
|
142
|
+
const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
|
|
143
|
+
if (!upVerify.ok) {
|
|
144
|
+
entry = buildEntry({
|
|
145
|
+
...base,
|
|
146
|
+
upstreamMode: "auto",
|
|
147
|
+
upstreamRef: null,
|
|
148
|
+
ahead: null,
|
|
149
|
+
behind: null,
|
|
150
|
+
upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
|
|
155
|
+
const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
|
|
156
|
+
const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
|
|
157
|
+
entry = buildEntry({
|
|
158
|
+
...base,
|
|
159
|
+
upstreamMode: "auto",
|
|
160
|
+
upstreamRef,
|
|
161
|
+
ahead,
|
|
162
|
+
behind,
|
|
163
|
+
upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
98
166
|
}
|
|
99
|
-
|
|
100
|
-
const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
|
|
101
|
-
const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
|
|
102
|
-
return buildEntry({
|
|
103
|
-
...base,
|
|
104
|
-
upstreamMode: "auto",
|
|
105
|
-
upstreamRef,
|
|
106
|
-
ahead,
|
|
107
|
-
behind,
|
|
108
|
-
upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
|
|
109
|
-
});
|
|
167
|
+
return attachCompareRefs(entry, absPath, compareRefs);
|
|
110
168
|
}
|
|
@@ -10,39 +10,56 @@ export function registerPresetsResource(server) {
|
|
|
10
10
|
name: "git-mcp-presets",
|
|
11
11
|
mimeType: "application/json",
|
|
12
12
|
async load() {
|
|
13
|
-
const pre = requireGitAndRoots(server, {}, undefined);
|
|
13
|
+
const pre = requireGitAndRoots(server, { root: "*" }, undefined);
|
|
14
14
|
if (!pre.ok) {
|
|
15
15
|
return { text: jsonRespond(pre.error) };
|
|
16
16
|
}
|
|
17
|
-
|
|
18
|
-
if (!ws) {
|
|
17
|
+
if (pre.roots.length === 0) {
|
|
19
18
|
return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
|
|
20
19
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const loaded = loadPresetsFromGitTop(top);
|
|
26
|
-
if (!loaded.ok) {
|
|
27
|
-
if (loaded.reason === "missing") {
|
|
20
|
+
const roots = pre.roots.map((ws) => {
|
|
21
|
+
const top = gitTopLevel(ws);
|
|
22
|
+
const presetFile = top ? join(top, PRESET_FILE_PATH) : join(ws, PRESET_FILE_PATH);
|
|
23
|
+
if (!top) {
|
|
28
24
|
return {
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
workspaceRoot: ws,
|
|
26
|
+
gitTop: null,
|
|
27
|
+
presetFile,
|
|
28
|
+
fileExists: false,
|
|
29
|
+
presets: {},
|
|
30
|
+
error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const loaded = loadPresetsFromGitTop(top);
|
|
34
|
+
if (!loaded.ok) {
|
|
35
|
+
if (loaded.reason === "missing") {
|
|
36
|
+
return {
|
|
37
|
+
workspaceRoot: ws,
|
|
38
|
+
gitTop: top,
|
|
39
|
+
presetFile,
|
|
31
40
|
fileExists: false,
|
|
32
41
|
presets: {},
|
|
33
|
-
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
workspaceRoot: ws,
|
|
46
|
+
gitTop: top,
|
|
47
|
+
presetFile,
|
|
48
|
+
fileExists: true,
|
|
49
|
+
presets: {},
|
|
50
|
+
error: presetLoadErrorPayload(top, loaded),
|
|
34
51
|
};
|
|
35
52
|
}
|
|
36
|
-
return {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
presetFile: join(top, PRESET_FILE_PATH),
|
|
53
|
+
return {
|
|
54
|
+
workspaceRoot: ws,
|
|
55
|
+
gitTop: top,
|
|
56
|
+
presetFile,
|
|
41
57
|
fileExists: true,
|
|
42
58
|
...spreadDefined("presetSchemaVersion", loaded.schemaVersion),
|
|
43
59
|
presets: loaded.data,
|
|
44
|
-
}
|
|
45
|
-
};
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
return { text: jsonRespond({ roots }) };
|
|
46
63
|
},
|
|
47
64
|
});
|
|
48
65
|
}
|
package/dist/server/presets.js
CHANGED
|
@@ -5,21 +5,56 @@ import { ERROR_CODES } from "./error-codes.js";
|
|
|
5
5
|
/**
|
|
6
6
|
* Schema for `.rethunk/git-mcp-presets.json` at the workspace root.
|
|
7
7
|
* Each named entry defines roots for `git_inventory` and/or pairs for `git_parity`.
|
|
8
|
+
* Must stay aligned with `git-mcp-presets.schema.json`.
|
|
8
9
|
*/
|
|
9
|
-
const
|
|
10
|
+
const ParityPairSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
left: z.string(),
|
|
13
|
+
right: z.string(),
|
|
14
|
+
label: z.string().optional(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
const PresetEntrySchema = z
|
|
18
|
+
.object({
|
|
10
19
|
nestedRoots: z.array(z.string()).optional(),
|
|
11
|
-
parityPairs: z
|
|
12
|
-
.array(z.object({
|
|
13
|
-
left: z.string(),
|
|
14
|
-
right: z.string(),
|
|
15
|
-
label: z.string().optional(),
|
|
16
|
-
}))
|
|
17
|
-
.optional(),
|
|
20
|
+
parityPairs: z.array(ParityPairSchema).optional(),
|
|
18
21
|
/** When multiple MCP file roots exist, prefer one whose path basename or suffix matches this hint. */
|
|
19
22
|
workspaceRootHint: z.string().optional(),
|
|
20
|
-
})
|
|
23
|
+
})
|
|
24
|
+
.strict();
|
|
21
25
|
const PresetFileSchema = z.record(z.string(), PresetEntrySchema);
|
|
22
26
|
export const PRESET_FILE_PATH = ".rethunk/git-mcp-presets.json";
|
|
27
|
+
const PRESET_ENTRY_FIELD_NAMES = new Set(["nestedRoots", "parityPairs", "workspaceRootHint"]);
|
|
28
|
+
const WRAPPED_META_KEYS = new Set(["$schema", "schemaVersion", "presets"]);
|
|
29
|
+
const PRESET_SCHEMA_VERSION = "1";
|
|
30
|
+
function looksLikePresetEntryObject(obj) {
|
|
31
|
+
const keys = Object.keys(obj);
|
|
32
|
+
if (keys.length === 0)
|
|
33
|
+
return true;
|
|
34
|
+
return keys.every((k) => PRESET_ENTRY_FIELD_NAMES.has(k));
|
|
35
|
+
}
|
|
36
|
+
function isWrappedLayout(o) {
|
|
37
|
+
if (!("presets" in o))
|
|
38
|
+
return false;
|
|
39
|
+
const inner = o.presets;
|
|
40
|
+
if (inner === null || typeof inner !== "object" || Array.isArray(inner)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const innerObj = inner;
|
|
44
|
+
// Non-empty inner with only PresetEntry field names → legacy preset named "presets".
|
|
45
|
+
if (looksLikePresetEntryObject(innerObj) && Object.keys(innerObj).length > 0) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
// Empty inner: wrapped when top level has only metadata + presets.
|
|
49
|
+
if (Object.keys(innerObj).length === 0) {
|
|
50
|
+
const topKeys = Object.keys(o);
|
|
51
|
+
return topKeys.every((k) => WRAPPED_META_KEYS.has(k));
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function schemaIssue(message, path = []) {
|
|
56
|
+
return { code: "custom", path, message };
|
|
57
|
+
}
|
|
23
58
|
/**
|
|
24
59
|
* Supports:
|
|
25
60
|
* - Wrapped: `{ "schemaVersion": "1", "presets": { "name": { ... } } }`
|
|
@@ -30,10 +65,12 @@ function splitPresetFileRaw(raw) {
|
|
|
30
65
|
throw new Error("invalid_root");
|
|
31
66
|
}
|
|
32
67
|
const o = raw;
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
if (isWrappedLayout(o)) {
|
|
69
|
+
for (const key of Object.keys(o)) {
|
|
70
|
+
if (!WRAPPED_META_KEYS.has(key)) {
|
|
71
|
+
throw new Error("wrapped_extra_keys");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
37
74
|
const sv = o.schemaVersion;
|
|
38
75
|
return {
|
|
39
76
|
mapRaw: o.presets,
|
|
@@ -69,13 +106,39 @@ export function loadPresetsFromGitTop(gitTop) {
|
|
|
69
106
|
mapRaw = s.mapRaw;
|
|
70
107
|
schemaVersion = s.schemaVersion;
|
|
71
108
|
}
|
|
72
|
-
catch {
|
|
109
|
+
catch (e) {
|
|
110
|
+
if (e instanceof Error && e.message === "wrapped_extra_keys") {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: "schema",
|
|
114
|
+
issues: [
|
|
115
|
+
schemaIssue("Wrapped preset files allow only $schema, schemaVersion, and presets"),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
73
119
|
return {
|
|
74
120
|
ok: false,
|
|
75
121
|
reason: "invalid_json",
|
|
76
122
|
message: "Preset file root must be a JSON object",
|
|
77
123
|
};
|
|
78
124
|
}
|
|
125
|
+
if (schemaVersion !== undefined && schemaVersion !== PRESET_SCHEMA_VERSION) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: "schema",
|
|
129
|
+
issues: [schemaIssue(`schemaVersion must be "${PRESET_SCHEMA_VERSION}"`, ["schemaVersion"])],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (mapRaw === null ||
|
|
133
|
+
typeof mapRaw !== "object" ||
|
|
134
|
+
Array.isArray(mapRaw) ||
|
|
135
|
+
Object.keys(mapRaw).length === 0) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: "schema",
|
|
139
|
+
issues: [schemaIssue("Preset file must contain at least one preset")],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
79
142
|
const parsed = PresetFileSchema.safeParse(mapRaw);
|
|
80
143
|
if (!parsed.success) {
|
|
81
144
|
return { ok: false, reason: "schema", issues: parsed.error.issues };
|
|
@@ -150,7 +213,16 @@ function mergePairs(preset, inline) {
|
|
|
150
213
|
const b = inline ?? [];
|
|
151
214
|
if (a.length === 0 && b.length === 0)
|
|
152
215
|
return undefined;
|
|
153
|
-
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
const out = [];
|
|
218
|
+
for (const pair of [...a, ...b]) {
|
|
219
|
+
const key = `${pair.left}\0${pair.right}`;
|
|
220
|
+
if (!seen.has(key)) {
|
|
221
|
+
seen.add(key);
|
|
222
|
+
out.push(pair);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
154
226
|
}
|
|
155
227
|
export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNestedRoots) {
|
|
156
228
|
const got = getPresetEntry(gitTop, presetName);
|
package/dist/server/roots.js
CHANGED
|
@@ -139,7 +139,19 @@ export function requireGitAndRoots(server, args, presetName) {
|
|
|
139
139
|
const trimmed = root?.trim();
|
|
140
140
|
if (trimmed === "*") {
|
|
141
141
|
const fileRoots = listFileRoots(server);
|
|
142
|
-
|
|
142
|
+
if (fileRoots.length === 0)
|
|
143
|
+
return defaultRoots(fileRoots);
|
|
144
|
+
if (fileRoots.length > MAX_ROOT_PATHS) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: {
|
|
148
|
+
error: ERROR_CODES.ROOT_LIST_TOO_MANY,
|
|
149
|
+
max: MAX_ROOT_PATHS,
|
|
150
|
+
count: fileRoots.length,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, roots: fileRoots };
|
|
143
155
|
}
|
|
144
156
|
if (trimmed) {
|
|
145
157
|
return { ok: true, roots: [resolve(trimmed)] };
|
package/dist/server/schemas.js
CHANGED
|
@@ -8,10 +8,17 @@ export const WorkspacePickSchema = z.object({
|
|
|
8
8
|
workspaceRoot: z.string().optional().describe("Repo path. Default: first MCP root / cwd."),
|
|
9
9
|
format: FormatSchema,
|
|
10
10
|
});
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Fan-out tools: one polymorphic routing param plus output format.
|
|
13
|
+
*
|
|
14
|
+
* Array length is intentionally uncapped here so `resolveRootPathList` can
|
|
15
|
+
* return the structured `{ error: root_list_too_many, max, count }` JSON
|
|
16
|
+
* payload. Zod `.max(MAX_ROOT_PATHS)` would reject with `too_big` before execute.
|
|
17
|
+
* The `"*"` sentinel is a plain string (no redundant `z.literal("*")`).
|
|
18
|
+
*/
|
|
12
19
|
export const RootPickSchema = z.object({
|
|
13
20
|
root: z
|
|
14
|
-
.union([z.string(), z.array(z.string())
|
|
21
|
+
.union([z.string(), z.array(z.string())])
|
|
15
22
|
.optional()
|
|
16
23
|
.describe('Repo path, array of paths, or "*" for all MCP roots.'),
|
|
17
24
|
format: FormatSchema,
|