@rethunk/mcp-multi-root-git 2.0.1 → 2.2.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 +9 -6
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/dist/repo-paths.js +1 -1
- package/dist/server/batch-commit-tool.js +209 -0
- package/dist/server/git-cherry-pick-tool.js +299 -0
- package/dist/server/git-diff-summary-tool.js +319 -0
- package/dist/server/git-log-tool.js +276 -0
- package/dist/server/git-merge-tool.js +393 -0
- package/dist/server/git-refs.js +151 -0
- package/dist/server/json.js +1 -2
- package/dist/server/presets.js +4 -4
- package/dist/server/roots.js +5 -5
- package/dist/server/test-harness.js +64 -0
- package/dist/server/tools.js +10 -0
- package/docs/mcp-tools.md +296 -0
- package/package.json +5 -4
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
+
import { requireGitAndRoots } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Schemas
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const StrategySchema = z
|
|
11
|
+
.enum(["auto", "ff-only", "rebase", "merge"])
|
|
12
|
+
.optional()
|
|
13
|
+
.default("auto")
|
|
14
|
+
.describe("`auto` (default): cascade fast-forward → rebase → merge-commit per source. " +
|
|
15
|
+
"`ff-only`: only fast-forward, fail if diverged. " +
|
|
16
|
+
"`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
|
|
17
|
+
"`merge`: always create a merge commit (no fast-forward).");
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Conflict helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
async function conflictPaths(gitTop) {
|
|
22
|
+
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return [];
|
|
25
|
+
return r.stdout
|
|
26
|
+
.split("\n")
|
|
27
|
+
.map((l) => l.trim())
|
|
28
|
+
.filter((l) => l.length > 0);
|
|
29
|
+
}
|
|
30
|
+
async function abortMerge(gitTop) {
|
|
31
|
+
await spawnGitAsync(gitTop, ["merge", "--abort"]);
|
|
32
|
+
}
|
|
33
|
+
async function abortRebase(gitTop) {
|
|
34
|
+
await spawnGitAsync(gitTop, ["rebase", "--abort"]);
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Per-source merge logic
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
|
|
41
|
+
* On conflict the repo state is cleaned (merge/rebase aborted, HEAD restored to `into`).
|
|
42
|
+
*/
|
|
43
|
+
async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
44
|
+
// --- Classify state via merge-base ---
|
|
45
|
+
const intoSha = await resolveRef(gitTop, into);
|
|
46
|
+
const sourceSha = await resolveRef(gitTop, source);
|
|
47
|
+
if (!sourceSha) {
|
|
48
|
+
return { source, ok: false, error: "source_not_found" };
|
|
49
|
+
}
|
|
50
|
+
if (!intoSha) {
|
|
51
|
+
return { source, ok: false, error: "destination_not_found" };
|
|
52
|
+
}
|
|
53
|
+
const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
|
|
54
|
+
if (!mb.ok) {
|
|
55
|
+
return {
|
|
56
|
+
source,
|
|
57
|
+
ok: false,
|
|
58
|
+
error: "merge_base_failed",
|
|
59
|
+
detail: (mb.stderr || mb.stdout).trim(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const mergeBase = mb.stdout.trim();
|
|
63
|
+
// source fully contained in into → noop
|
|
64
|
+
if (mergeBase === sourceSha) {
|
|
65
|
+
return { source, ok: true, outcome: "up_to_date", mergedSha: intoSha };
|
|
66
|
+
}
|
|
67
|
+
// into fully contained in source → fast-forward is the right move
|
|
68
|
+
const canFastForward = mergeBase === intoSha;
|
|
69
|
+
// --- ff-only strategy ---
|
|
70
|
+
if (strategy === "ff-only") {
|
|
71
|
+
if (!canFastForward) {
|
|
72
|
+
return {
|
|
73
|
+
source,
|
|
74
|
+
ok: false,
|
|
75
|
+
error: "cannot_fast_forward",
|
|
76
|
+
detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return fastForward(gitTop, source);
|
|
80
|
+
}
|
|
81
|
+
// --- Fast-forward path for auto/rebase when applicable ---
|
|
82
|
+
if (canFastForward && (strategy === "auto" || strategy === "rebase")) {
|
|
83
|
+
return fastForward(gitTop, source);
|
|
84
|
+
}
|
|
85
|
+
// --- merge strategy: always merge commit ---
|
|
86
|
+
if (strategy === "merge") {
|
|
87
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
88
|
+
}
|
|
89
|
+
// --- rebase or auto (diverged case) ---
|
|
90
|
+
const rebased = await rebaseSourceOntoInto(gitTop, into, source);
|
|
91
|
+
if (rebased.ok) {
|
|
92
|
+
// Rebase succeeded; FF destination up to the now-rebased source tip.
|
|
93
|
+
const ff = await fastForward(gitTop, source);
|
|
94
|
+
if (!ff.ok)
|
|
95
|
+
return ff;
|
|
96
|
+
return { ...ff, outcome: "rebase_then_ff" };
|
|
97
|
+
}
|
|
98
|
+
if (strategy === "rebase") {
|
|
99
|
+
return rebased; // caller opted out of merge-commit fallback
|
|
100
|
+
}
|
|
101
|
+
// auto: fall through to merge commit
|
|
102
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
103
|
+
}
|
|
104
|
+
async function fastForward(gitTop, source) {
|
|
105
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
await abortMerge(gitTop);
|
|
108
|
+
return {
|
|
109
|
+
source,
|
|
110
|
+
ok: false,
|
|
111
|
+
outcome: "conflicts",
|
|
112
|
+
conflictStage: "merge",
|
|
113
|
+
conflictPaths: [],
|
|
114
|
+
error: "merge_failed",
|
|
115
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
119
|
+
return {
|
|
120
|
+
source,
|
|
121
|
+
ok: true,
|
|
122
|
+
outcome: "fast_forward",
|
|
123
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function mergeCommit(gitTop, source, message, into) {
|
|
127
|
+
const msg = message?.trim() || `Merge branch '${source}' into ${into}`;
|
|
128
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
const paths = await conflictPaths(gitTop);
|
|
131
|
+
await abortMerge(gitTop);
|
|
132
|
+
return {
|
|
133
|
+
source,
|
|
134
|
+
ok: false,
|
|
135
|
+
outcome: "conflicts",
|
|
136
|
+
conflictStage: "merge",
|
|
137
|
+
conflictPaths: paths,
|
|
138
|
+
error: "merge_conflicts",
|
|
139
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
143
|
+
return {
|
|
144
|
+
source,
|
|
145
|
+
ok: true,
|
|
146
|
+
outcome: "merge_commit",
|
|
147
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Rebase `source` onto `into`, then return to `into`.
|
|
152
|
+
* On failure: abort rebase, check out `into` again, return structured conflict.
|
|
153
|
+
*/
|
|
154
|
+
async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
155
|
+
// `git rebase <upstream> <branch>` first switches to <branch>.
|
|
156
|
+
const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
|
|
157
|
+
if (!r.ok) {
|
|
158
|
+
const paths = await conflictPaths(gitTop);
|
|
159
|
+
await abortRebase(gitTop);
|
|
160
|
+
// Ensure we're back on `into` regardless of rebase state.
|
|
161
|
+
await spawnGitAsync(gitTop, ["checkout", into]);
|
|
162
|
+
return {
|
|
163
|
+
source,
|
|
164
|
+
ok: false,
|
|
165
|
+
outcome: "conflicts",
|
|
166
|
+
conflictStage: "rebase",
|
|
167
|
+
conflictPaths: paths,
|
|
168
|
+
error: "rebase_conflicts",
|
|
169
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Rebase succeeded; switch back to destination so the caller can FF.
|
|
173
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
174
|
+
if (!co.ok) {
|
|
175
|
+
return {
|
|
176
|
+
source,
|
|
177
|
+
ok: false,
|
|
178
|
+
error: "checkout_failed",
|
|
179
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { source, ok: true }; // caller FFs
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Cleanup helpers
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
async function maybeRemoveWorktree(gitTop, source, enabled) {
|
|
188
|
+
if (!enabled)
|
|
189
|
+
return undefined;
|
|
190
|
+
const path = await worktreeForBranch(gitTop, source);
|
|
191
|
+
if (!path)
|
|
192
|
+
return undefined;
|
|
193
|
+
// Re-check protected names against the worktree path's trailing segment too.
|
|
194
|
+
const tail = path.split("/").pop() ?? "";
|
|
195
|
+
if (isProtectedBranch(tail))
|
|
196
|
+
return undefined;
|
|
197
|
+
const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
|
|
198
|
+
return r.ok ? path : undefined;
|
|
199
|
+
}
|
|
200
|
+
async function maybeDeleteBranch(gitTop, source, enabled, into) {
|
|
201
|
+
if (!enabled)
|
|
202
|
+
return false;
|
|
203
|
+
if (isProtectedBranch(source))
|
|
204
|
+
return false;
|
|
205
|
+
// Safety double-check: source must be fully merged into destination.
|
|
206
|
+
const merged = await isFullyMergedInto(gitTop, source, into);
|
|
207
|
+
if (!merged)
|
|
208
|
+
return false;
|
|
209
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", source]);
|
|
210
|
+
return r.ok;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Tool registration
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
export function registerGitMergeTool(server) {
|
|
216
|
+
server.addTool({
|
|
217
|
+
name: "git_merge",
|
|
218
|
+
description: "Merge one or more source branches into a destination. Default strategy `auto` " +
|
|
219
|
+
"cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
|
|
220
|
+
"Refuses if the working tree is dirty. Stops on the first conflict and reports " +
|
|
221
|
+
"the affected paths. Optional flags auto-delete merged branches and worktrees, " +
|
|
222
|
+
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
223
|
+
"production, release/*, hotfix/*). See docs/mcp-tools.md.",
|
|
224
|
+
annotations: {
|
|
225
|
+
readOnlyHint: false,
|
|
226
|
+
destructiveHint: false,
|
|
227
|
+
idempotentHint: false,
|
|
228
|
+
},
|
|
229
|
+
parameters: WorkspacePickSchema.extend({
|
|
230
|
+
sources: z
|
|
231
|
+
.array(z.string().min(1))
|
|
232
|
+
.min(1)
|
|
233
|
+
.max(20)
|
|
234
|
+
.describe("Branches to merge into the destination, in order."),
|
|
235
|
+
into: z
|
|
236
|
+
.string()
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("Destination branch. Defaults to the currently checked-out branch."),
|
|
239
|
+
strategy: StrategySchema,
|
|
240
|
+
message: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Merge commit message (used only when a merge commit is created)."),
|
|
244
|
+
deleteMergedBranches: z
|
|
245
|
+
.boolean()
|
|
246
|
+
.optional()
|
|
247
|
+
.default(false)
|
|
248
|
+
.describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
|
|
249
|
+
"Protected names (main, master, dev, develop, stable, trunk, prod, production, " +
|
|
250
|
+
"release/*, hotfix/*) are always skipped. Never affects remote branches."),
|
|
251
|
+
deleteMergedWorktrees: z
|
|
252
|
+
.boolean()
|
|
253
|
+
.optional()
|
|
254
|
+
.default(false)
|
|
255
|
+
.describe("After all sources merge cleanly, remove any local worktree currently checked out " +
|
|
256
|
+
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
257
|
+
}),
|
|
258
|
+
execute: async (args) => {
|
|
259
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
260
|
+
if (!pre.ok)
|
|
261
|
+
return jsonRespond(pre.error);
|
|
262
|
+
const rootInput = pre.roots[0];
|
|
263
|
+
if (!rootInput)
|
|
264
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
265
|
+
const gitTop = gitTopLevel(rootInput);
|
|
266
|
+
if (!gitTop)
|
|
267
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
268
|
+
// --- Validate ref tokens early ---
|
|
269
|
+
for (const s of args.sources) {
|
|
270
|
+
if (!isSafeGitRefToken(s)) {
|
|
271
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: s });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
|
|
275
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.into });
|
|
276
|
+
}
|
|
277
|
+
// --- Resolve destination ---
|
|
278
|
+
const startBranch = await getCurrentBranch(gitTop);
|
|
279
|
+
const into = args.into?.trim() || startBranch;
|
|
280
|
+
if (!into) {
|
|
281
|
+
return jsonRespond({ error: "into_detached_head" });
|
|
282
|
+
}
|
|
283
|
+
// --- Refuse dirty tree ---
|
|
284
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
285
|
+
return jsonRespond({ error: "working_tree_dirty" });
|
|
286
|
+
}
|
|
287
|
+
// --- Ensure destination is checked out ---
|
|
288
|
+
if (into !== startBranch) {
|
|
289
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
290
|
+
if (!co.ok) {
|
|
291
|
+
return jsonRespond({
|
|
292
|
+
error: "checkout_failed",
|
|
293
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Verify destination exists after checkout.
|
|
298
|
+
const intoShaProbe = await resolveRef(gitTop, into);
|
|
299
|
+
if (!intoShaProbe) {
|
|
300
|
+
return jsonRespond({ error: "destination_not_found", ref: into });
|
|
301
|
+
}
|
|
302
|
+
// --- Merge each source sequentially ---
|
|
303
|
+
const strategy = args.strategy ?? "auto";
|
|
304
|
+
const results = [];
|
|
305
|
+
let firstConflict = false;
|
|
306
|
+
for (const source of args.sources) {
|
|
307
|
+
const r = await mergeOneSource(gitTop, into, source, strategy, args.message);
|
|
308
|
+
results.push(r);
|
|
309
|
+
if (!r.ok) {
|
|
310
|
+
firstConflict = true;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const allOk = !firstConflict && results.every((r) => r.ok);
|
|
315
|
+
// --- Cleanup (only on full success) ---
|
|
316
|
+
if (allOk) {
|
|
317
|
+
for (let i = 0; i < results.length; i++) {
|
|
318
|
+
const r = results[i];
|
|
319
|
+
if (!r || r.outcome === "up_to_date")
|
|
320
|
+
continue;
|
|
321
|
+
const worktreeRemoved = await maybeRemoveWorktree(gitTop, r.source, args.deleteMergedWorktrees ?? false);
|
|
322
|
+
const branchDeleted = await maybeDeleteBranch(gitTop, r.source, args.deleteMergedBranches ?? false, into);
|
|
323
|
+
results[i] = {
|
|
324
|
+
...r,
|
|
325
|
+
...spreadDefined("worktreeRemoved", worktreeRemoved),
|
|
326
|
+
...spreadWhen(branchDeleted, { branchDeleted: true }),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
331
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
332
|
+
if (args.format === "json") {
|
|
333
|
+
return jsonRespond({
|
|
334
|
+
ok: allOk,
|
|
335
|
+
into,
|
|
336
|
+
strategy,
|
|
337
|
+
...spreadDefined("headSha", headSha),
|
|
338
|
+
applied: results.filter((r) => r.ok).length,
|
|
339
|
+
total: args.sources.length,
|
|
340
|
+
results: results.map((r) => ({
|
|
341
|
+
source: r.source,
|
|
342
|
+
ok: r.ok,
|
|
343
|
+
...spreadDefined("outcome", r.outcome),
|
|
344
|
+
...spreadDefined("mergedSha", r.mergedSha),
|
|
345
|
+
...spreadDefined("conflictStage", r.conflictStage),
|
|
346
|
+
...spreadWhen((r.conflictPaths?.length ?? 0) > 0, {
|
|
347
|
+
conflictPaths: r.conflictPaths,
|
|
348
|
+
}),
|
|
349
|
+
...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
|
|
350
|
+
...spreadDefined("worktreeRemoved", r.worktreeRemoved),
|
|
351
|
+
...spreadDefined("skipReason", r.skipReason),
|
|
352
|
+
...spreadDefined("error", r.error),
|
|
353
|
+
...spreadDefined("detail", r.detail),
|
|
354
|
+
})),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// --- Markdown ---
|
|
358
|
+
const lines = [];
|
|
359
|
+
const applied = results.filter((r) => r.ok).length;
|
|
360
|
+
const header = allOk
|
|
361
|
+
? `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied`
|
|
362
|
+
: `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied (stopped on conflict)`;
|
|
363
|
+
lines.push(header, "");
|
|
364
|
+
for (const r of results) {
|
|
365
|
+
const icon = r.ok ? "✓" : "✗";
|
|
366
|
+
const tail = [];
|
|
367
|
+
if (r.outcome)
|
|
368
|
+
tail.push(r.outcome);
|
|
369
|
+
if (r.mergedSha)
|
|
370
|
+
tail.push(`\`${r.mergedSha.slice(0, 7)}\``);
|
|
371
|
+
if (r.branchDeleted)
|
|
372
|
+
tail.push("branch deleted");
|
|
373
|
+
if (r.worktreeRemoved)
|
|
374
|
+
tail.push(`worktree removed: ${r.worktreeRemoved}`);
|
|
375
|
+
lines.push(`${icon} ${r.source}${tail.length ? ` — ${tail.join(", ")}` : ""}`);
|
|
376
|
+
if (!r.ok) {
|
|
377
|
+
if (r.conflictPaths?.length) {
|
|
378
|
+
for (const p of r.conflictPaths)
|
|
379
|
+
lines.push(` conflict: ${p}`);
|
|
380
|
+
}
|
|
381
|
+
if (r.error)
|
|
382
|
+
lines.push(` Error: ${r.error}${r.detail ? ` — ${r.detail}` : ""}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!allOk) {
|
|
386
|
+
const skipped = args.sources.length - results.length;
|
|
387
|
+
if (skipped > 0)
|
|
388
|
+
lines.push("", `${skipped} remaining source(s) skipped.`);
|
|
389
|
+
}
|
|
390
|
+
return lines.join("\n");
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawnGitAsync } from "./git.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Protected branch names — never auto-delete, never cascade destructive ops onto
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const PROTECTED_EXACT = new Set([
|
|
6
|
+
"main",
|
|
7
|
+
"master",
|
|
8
|
+
"dev",
|
|
9
|
+
"develop",
|
|
10
|
+
"stable",
|
|
11
|
+
"trunk",
|
|
12
|
+
"prod",
|
|
13
|
+
"production",
|
|
14
|
+
"HEAD",
|
|
15
|
+
]);
|
|
16
|
+
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
|
|
17
|
+
/** True when a branch name is on the protected list and must not be auto-deleted. */
|
|
18
|
+
export function isProtectedBranch(name) {
|
|
19
|
+
const t = name.trim();
|
|
20
|
+
if (t === "")
|
|
21
|
+
return true;
|
|
22
|
+
if (PROTECTED_EXACT.has(t))
|
|
23
|
+
return true;
|
|
24
|
+
return PROTECTED_PATTERN.test(t);
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Conservative check for branch/ref names passed to git argv.
|
|
31
|
+
* Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
|
|
32
|
+
* sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
|
|
33
|
+
* and pathological tokens.
|
|
34
|
+
*/
|
|
35
|
+
export function isSafeGitRefToken(s) {
|
|
36
|
+
const t = s.trim();
|
|
37
|
+
if (t.length === 0 || t.length > 256)
|
|
38
|
+
return false;
|
|
39
|
+
if (t.startsWith("-"))
|
|
40
|
+
return false;
|
|
41
|
+
if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith("."))
|
|
42
|
+
return false;
|
|
43
|
+
if (t.includes(".."))
|
|
44
|
+
return false;
|
|
45
|
+
if (t.includes("@{"))
|
|
46
|
+
return false;
|
|
47
|
+
if (t.includes("//"))
|
|
48
|
+
return false;
|
|
49
|
+
return /^[A-Za-z0-9_./+-]+$/.test(t);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
|
|
53
|
+
* used by `git log` / `git cherry-pick`. Splits once and validates each side.
|
|
54
|
+
*/
|
|
55
|
+
export function isSafeGitRangeToken(s) {
|
|
56
|
+
const t = s.trim();
|
|
57
|
+
if (t.includes("...")) {
|
|
58
|
+
const parts = t.split("...");
|
|
59
|
+
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
|
|
60
|
+
}
|
|
61
|
+
if (t.includes("..")) {
|
|
62
|
+
const parts = t.split("..");
|
|
63
|
+
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
|
|
64
|
+
}
|
|
65
|
+
return isSafeGitRefToken(t);
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Async helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Current branch name; `null` if detached HEAD. */
|
|
71
|
+
export async function getCurrentBranch(cwd) {
|
|
72
|
+
const r = await spawnGitAsync(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
73
|
+
if (!r.ok)
|
|
74
|
+
return null;
|
|
75
|
+
const name = r.stdout.trim();
|
|
76
|
+
return name === "" ? null : name;
|
|
77
|
+
}
|
|
78
|
+
/** Resolve a ref to its full SHA; `null` if unknown. */
|
|
79
|
+
export async function resolveRef(cwd, ref) {
|
|
80
|
+
if (!isSafeGitRefToken(ref))
|
|
81
|
+
return null;
|
|
82
|
+
const r = await spawnGitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return null;
|
|
85
|
+
const sha = r.stdout.trim();
|
|
86
|
+
return sha === "" ? null : sha;
|
|
87
|
+
}
|
|
88
|
+
/** Working tree clean (no staged, no unstaged, no untracked). */
|
|
89
|
+
export async function isWorkingTreeClean(cwd) {
|
|
90
|
+
const r = await spawnGitAsync(cwd, ["status", "--porcelain"]);
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
return false;
|
|
93
|
+
return r.stdout.trim() === "";
|
|
94
|
+
}
|
|
95
|
+
/** True when every commit on `branch` is reachable from `target`. */
|
|
96
|
+
export async function isFullyMergedInto(cwd, branch, target) {
|
|
97
|
+
if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
|
|
98
|
+
return false;
|
|
99
|
+
const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
|
|
100
|
+
return r.ok;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
|
|
104
|
+
* Returns `null` on git failure.
|
|
105
|
+
*/
|
|
106
|
+
export async function commitListBetween(cwd, excludeRef, includeRef) {
|
|
107
|
+
if (!isSafeGitRefToken(excludeRef) || !isSafeGitRefToken(includeRef))
|
|
108
|
+
return null;
|
|
109
|
+
const r = await spawnGitAsync(cwd, ["rev-list", "--reverse", `${excludeRef}..${includeRef}`]);
|
|
110
|
+
if (!r.ok)
|
|
111
|
+
return null;
|
|
112
|
+
return r.stdout
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((l) => l.trim())
|
|
115
|
+
.filter((l) => l.length > 0);
|
|
116
|
+
}
|
|
117
|
+
/** Parse `git worktree list --porcelain` into structured entries. */
|
|
118
|
+
export async function listWorktrees(cwd) {
|
|
119
|
+
const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
|
|
120
|
+
if (!r.ok)
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
let cur = {};
|
|
124
|
+
for (const line of r.stdout.split("\n")) {
|
|
125
|
+
if (line.startsWith("worktree ")) {
|
|
126
|
+
if (cur.path)
|
|
127
|
+
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
|
|
128
|
+
cur = { path: line.slice("worktree ".length).trim() };
|
|
129
|
+
}
|
|
130
|
+
else if (line.startsWith("HEAD ")) {
|
|
131
|
+
cur.head = line.slice("HEAD ".length).trim();
|
|
132
|
+
}
|
|
133
|
+
else if (line.startsWith("branch ")) {
|
|
134
|
+
// e.g. `branch refs/heads/foo`
|
|
135
|
+
const ref = line.slice("branch ".length).trim();
|
|
136
|
+
cur.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
137
|
+
}
|
|
138
|
+
else if (line === "detached") {
|
|
139
|
+
cur.branch = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (cur.path)
|
|
143
|
+
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Path of the worktree currently checked out on `branch`; `null` if none. */
|
|
147
|
+
export async function worktreeForBranch(cwd, branch) {
|
|
148
|
+
const trees = await listWorktrees(cwd);
|
|
149
|
+
const hit = trees.find((t) => t.branch === branch);
|
|
150
|
+
return hit?.path ?? null;
|
|
151
|
+
}
|
package/dist/server/json.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
export function readPackageVersion() {
|
|
4
|
+
function readPackageVersion() {
|
|
6
5
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
6
|
const pkgPath = join(here, "..", "..", "package.json");
|
|
8
7
|
try {
|
package/dist/server/presets.js
CHANGED
|
@@ -24,7 +24,7 @@ export const PRESET_FILE_PATH = ".rethunk/git-mcp-presets.json";
|
|
|
24
24
|
* - Wrapped: `{ "schemaVersion": "1", "presets": { "name": { ... } } }`
|
|
25
25
|
* - Legacy: `{ "name": { ... }, ... }` with optional top-level `schemaVersion` / `$schema` (editor hints).
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
function splitPresetFileRaw(raw) {
|
|
28
28
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
29
29
|
throw new Error("invalid_root");
|
|
30
30
|
}
|
|
@@ -96,7 +96,7 @@ export function presetLoadErrorPayload(gitTop, fail) {
|
|
|
96
96
|
}
|
|
97
97
|
return { error: "preset_file_invalid", presetFile };
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
function getPresetEntry(gitTop, presetName) {
|
|
100
100
|
const loaded = loadPresetsFromGitTop(gitTop);
|
|
101
101
|
if (!loaded.ok) {
|
|
102
102
|
if (loaded.reason === "missing") {
|
|
@@ -124,7 +124,7 @@ export function getPresetEntry(gitTop, presetName) {
|
|
|
124
124
|
}
|
|
125
125
|
return { ok: true, entry, presetSchemaVersion: loaded.schemaVersion };
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
function mergeNestedRoots(preset, inline) {
|
|
128
128
|
const a = preset ?? [];
|
|
129
129
|
const b = inline ?? [];
|
|
130
130
|
if (a.length === 0 && b.length === 0)
|
|
@@ -139,7 +139,7 @@ export function mergeNestedRoots(preset, inline) {
|
|
|
139
139
|
}
|
|
140
140
|
return out;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
function mergePairs(preset, inline) {
|
|
143
143
|
const a = preset ?? [];
|
|
144
144
|
const b = inline ?? [];
|
|
145
145
|
if (a.length === 0 && b.length === 0)
|
package/dist/server/roots.js
CHANGED
|
@@ -2,7 +2,7 @@ import { basename, resolve } from "node:path";
|
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { gateGit, gitTopLevel } from "./git.js";
|
|
4
4
|
import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
|
|
5
|
-
|
|
5
|
+
function uriToPath(uri) {
|
|
6
6
|
if (!uri.startsWith("file://"))
|
|
7
7
|
return null;
|
|
8
8
|
try {
|
|
@@ -12,7 +12,7 @@ export function uriToPath(uri) {
|
|
|
12
12
|
return null;
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
function listFileRoots(server) {
|
|
16
16
|
const sessions = server.sessions;
|
|
17
17
|
const roots = sessions[0]?.roots ?? [];
|
|
18
18
|
const paths = [];
|
|
@@ -24,7 +24,7 @@ export function listFileRoots(server) {
|
|
|
24
24
|
return paths;
|
|
25
25
|
}
|
|
26
26
|
/** Basename or trailing path segment; compares using normalized slashes so Windows backslashes match. */
|
|
27
|
-
|
|
27
|
+
function pathMatchesWorkspaceRootHint(rootPath, hint) {
|
|
28
28
|
const h = hint.trim();
|
|
29
29
|
if (!h)
|
|
30
30
|
return true;
|
|
@@ -39,7 +39,7 @@ export function pathMatchesWorkspaceRootHint(rootPath, hint) {
|
|
|
39
39
|
return true;
|
|
40
40
|
return basename(rootPath) === h;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
function resolveWorkspaceRoots(server, args) {
|
|
43
43
|
if (args.workspaceRoot?.trim()) {
|
|
44
44
|
return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
|
|
45
45
|
}
|
|
@@ -69,7 +69,7 @@ export function resolveWorkspaceRoots(server, args) {
|
|
|
69
69
|
* When a preset name is requested and multiple MCP roots exist, pick the first root
|
|
70
70
|
* whose git toplevel loads a preset file containing that name.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
function resolveRootsForPreset(server, args, presetName) {
|
|
73
73
|
if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
|
|
74
74
|
return resolveWorkspaceRoots(server, args);
|
|
75
75
|
}
|