@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
|
@@ -5,6 +5,8 @@ import { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalent
|
|
|
5
5
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
6
|
import { requireSingleRepo } from "./roots.js";
|
|
7
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
/** Hard cap on SHAs fed to a single `git cherry-pick` (ARG_MAX / runtime guard). */
|
|
9
|
+
export const MAX_CHERRY_PICK_COMMITS = 100;
|
|
8
10
|
// ---------------------------------------------------------------------------
|
|
9
11
|
// Helpers
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
@@ -15,8 +17,13 @@ async function cherryPickHead(gitTop) {
|
|
|
15
17
|
const sha = r.stdout.trim();
|
|
16
18
|
return sha === "" ? undefined : sha;
|
|
17
19
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
/** Result of `git cherry-pick --abort` — callers must check `ok` before claiming a clean abort. */
|
|
21
|
+
export async function abortCherryPick(gitTop) {
|
|
22
|
+
const r = await spawnGitAsync(gitTop, ["cherry-pick", "--abort"]);
|
|
23
|
+
if (r.ok)
|
|
24
|
+
return { ok: true };
|
|
25
|
+
const detail = (r.stderr || r.stdout).trim();
|
|
26
|
+
return detail === "" ? { ok: false } : { ok: false, detail };
|
|
20
27
|
}
|
|
21
28
|
async function branchExists(gitTop, name) {
|
|
22
29
|
const r = await spawnGitAsync(gitTop, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
@@ -96,6 +103,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
96
103
|
name: "git_cherry_pick",
|
|
97
104
|
description: "Cherry-pick commits from one or more sources onto a destination. Sources: SHAs, `A..B` ranges, " +
|
|
98
105
|
"or branch names (expanded to `onto..<branch>`, oldest-first). Already-reachable commits skipped. " +
|
|
106
|
+
`Hard-capped at ${MAX_CHERRY_PICK_COMMITS} commits per call (after dedupe). ` +
|
|
99
107
|
"Refuses on dirty tree; stops on first conflict. Optional flags delete source branches/worktrees " +
|
|
100
108
|
"after success using patch-id equivalence (set `strictMergedRefEquality: true` for strict ancestry). " +
|
|
101
109
|
"Protected names always skipped.",
|
|
@@ -123,19 +131,39 @@ export function registerGitCherryPickTool(server) {
|
|
|
123
131
|
.boolean()
|
|
124
132
|
.optional()
|
|
125
133
|
.default(false)
|
|
126
|
-
.describe("Remove local worktrees on branch-kind sources after success. Protected tails skipped."),
|
|
134
|
+
.describe("Remove local worktrees on branch-kind sources after success. Protected names and path tails skipped."),
|
|
127
135
|
strictMergedRefEquality: z
|
|
128
136
|
.boolean()
|
|
129
137
|
.optional()
|
|
130
138
|
.default(false)
|
|
131
139
|
.describe("false (default): delete branch when every commit is content-equivalent on destination (patch-id, normal cherry-pick outcome). " +
|
|
132
140
|
"true: require strict ref ancestry (`git branch -d` semantics — will refuse after cherry-pick due to SHA mismatch)."),
|
|
141
|
+
onConflict: z
|
|
142
|
+
.enum(["abort", "pause"])
|
|
143
|
+
.optional()
|
|
144
|
+
.default("abort")
|
|
145
|
+
.describe("`abort` (default): on conflict, run `cherry-pick --abort` and roll back the whole range " +
|
|
146
|
+
"(unchanged behavior). `pause`: on conflict, leave the conflict and native cherry-pick " +
|
|
147
|
+
"sequencer state in place — commits already applied stay applied — so it can be resolved " +
|
|
148
|
+
"and resumed via `git_cherry_pick_continue`."),
|
|
133
149
|
}),
|
|
134
150
|
execute: async (args) => {
|
|
135
151
|
const pre = requireSingleRepo(server, args);
|
|
136
152
|
if (!pre.ok)
|
|
137
153
|
return jsonRespond(pre.error);
|
|
138
154
|
const gitTop = pre.gitTop;
|
|
155
|
+
// --- Guard: refuse when a cherry-pick is already in progress (native sequencer state,
|
|
156
|
+
// read live off CHERRY_PICK_HEAD — this server is stateless per call). Checked before the
|
|
157
|
+
// dirty-tree refusal below so callers get a specific, actionable error instead of the
|
|
158
|
+
// generic working_tree_dirty. ---
|
|
159
|
+
const alreadyInProgress = await cherryPickHead(gitTop);
|
|
160
|
+
if (alreadyInProgress) {
|
|
161
|
+
return jsonRespond({
|
|
162
|
+
error: ERROR_CODES.CHERRY_PICK_IN_PROGRESS,
|
|
163
|
+
commit: alreadyInProgress,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const onConflict = args.onConflict ?? "abort";
|
|
139
167
|
// --- Resolve destination ---
|
|
140
168
|
const startBranch = await getCurrentBranch(gitTop);
|
|
141
169
|
const onto = args.onto?.trim() || startBranch;
|
|
@@ -176,6 +204,13 @@ export function registerGitCherryPickTool(server) {
|
|
|
176
204
|
}
|
|
177
205
|
// --- Dedupe + skip already-present ---
|
|
178
206
|
const { picks, perSourceKept } = await filterAndDedupe(gitTop, onto, resolved);
|
|
207
|
+
if (picks.length > MAX_CHERRY_PICK_COMMITS) {
|
|
208
|
+
return jsonRespond({
|
|
209
|
+
error: ERROR_CODES.CHERRY_PICK_TOO_MANY_COMMITS,
|
|
210
|
+
picked: picks.length,
|
|
211
|
+
max: MAX_CHERRY_PICK_COMMITS,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
179
214
|
// --- Apply cherry-pick (single atomic call) ---
|
|
180
215
|
// `--empty=drop` silently drops commits that would produce no change against the
|
|
181
216
|
// current tip — makes the tool idempotent when the same patch is re-applied.
|
|
@@ -188,13 +223,33 @@ export function registerGitCherryPickTool(server) {
|
|
|
188
223
|
if (!r.ok) {
|
|
189
224
|
const failedSha = await cherryPickHead(gitTop);
|
|
190
225
|
const paths = await conflictPaths(gitTop);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
if (onConflict === "pause") {
|
|
227
|
+
// Leave the conflict + native sequencer state in place. Commits already
|
|
228
|
+
// applied before the conflicting one stay applied — compute that count
|
|
229
|
+
// cheaply from the HEAD advance so far (resumable via git_cherry_pick_continue).
|
|
230
|
+
const adv = await spawnGitAsync(gitTop, ["rev-list", "--count", `${preHead}..HEAD`]);
|
|
231
|
+
appliedCount = adv.ok ? parseInt(adv.stdout.trim(), 10) || 0 : 0;
|
|
232
|
+
conflict = {
|
|
233
|
+
stage: "cherry-pick",
|
|
234
|
+
paused: true,
|
|
235
|
+
...spreadDefined("commit", failedSha),
|
|
236
|
+
paths,
|
|
237
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const abort = await abortCherryPick(gitTop);
|
|
242
|
+
conflict = {
|
|
243
|
+
stage: "cherry-pick",
|
|
244
|
+
...spreadDefined("commit", failedSha),
|
|
245
|
+
paths,
|
|
246
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
247
|
+
...spreadWhen(!abort.ok, {
|
|
248
|
+
abortFailed: true,
|
|
249
|
+
...spreadDefined("abortDetail", abort.detail),
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
198
253
|
}
|
|
199
254
|
else {
|
|
200
255
|
// Actual commits written = HEAD advance count (empty-drop may skip some).
|
|
@@ -265,18 +320,29 @@ export function registerGitCherryPickTool(server) {
|
|
|
265
320
|
...spreadWhen(conflict !== undefined, {
|
|
266
321
|
conflict: {
|
|
267
322
|
stage: conflict?.stage ?? "cherry-pick",
|
|
323
|
+
...spreadWhen(conflict?.paused === true, { paused: true }),
|
|
268
324
|
...spreadDefined("commit", conflict?.commit),
|
|
269
325
|
paths: conflict?.paths ?? [],
|
|
270
326
|
...spreadDefined("detail", conflict?.detail),
|
|
327
|
+
...spreadWhen(conflict?.abortFailed === true, {
|
|
328
|
+
abortFailed: true,
|
|
329
|
+
...spreadDefined("abortDetail", conflict?.abortDetail),
|
|
330
|
+
}),
|
|
271
331
|
},
|
|
272
332
|
}),
|
|
333
|
+
...spreadWhen(conflict?.abortFailed === true, {
|
|
334
|
+
error: ERROR_CODES.CHERRY_PICK_ABORT_FAILED,
|
|
335
|
+
...spreadDefined("abortDetail", conflict?.abortDetail),
|
|
336
|
+
}),
|
|
273
337
|
});
|
|
274
338
|
}
|
|
275
339
|
// --- Markdown ---
|
|
276
340
|
const lines = [];
|
|
277
341
|
const header = allOk
|
|
278
342
|
? `# Cherry-pick onto \`${onto}\`: ${appliedCount} commit(s) applied`
|
|
279
|
-
:
|
|
343
|
+
: conflict?.paused
|
|
344
|
+
? `# Cherry-pick onto \`${onto}\`: paused on conflict after ${appliedCount} commit(s)`
|
|
345
|
+
: `# Cherry-pick onto \`${onto}\`: stopped on conflict after ${appliedCount} commit(s)`;
|
|
280
346
|
lines.push(header, "");
|
|
281
347
|
for (const s of perSourceReport) {
|
|
282
348
|
const kept = perSourceKept.get(s.raw)?.length ?? 0;
|
|
@@ -293,8 +359,151 @@ export function registerGitCherryPickTool(server) {
|
|
|
293
359
|
lines.push(` conflict: ${p}`);
|
|
294
360
|
if (conflict.detail)
|
|
295
361
|
lines.push(` detail: ${conflict.detail}`);
|
|
362
|
+
if (conflict.paused) {
|
|
363
|
+
lines.push(" Paused: cherry-pick left in progress. Resolve the conflict, then call `git_cherry_pick_continue`.");
|
|
364
|
+
}
|
|
365
|
+
if (conflict.abortFailed) {
|
|
366
|
+
lines.push(` Error: ${ERROR_CODES.CHERRY_PICK_ABORT_FAILED}${conflict.abortDetail ? ` — ${conflict.abortDetail}` : ""}`);
|
|
367
|
+
}
|
|
296
368
|
}
|
|
297
369
|
return lines.join("\n");
|
|
298
370
|
},
|
|
299
371
|
});
|
|
300
372
|
}
|
|
373
|
+
function renderCherryPickContinueMarkdown(action, ok, applied, headSha, conflict) {
|
|
374
|
+
if (action === "abort") {
|
|
375
|
+
return ok
|
|
376
|
+
? `# Cherry-pick abort\nAborted. HEAD restored to \`${headSha ?? "?"}\`.`
|
|
377
|
+
: "# Cherry-pick abort\nAbort failed — see error.";
|
|
378
|
+
}
|
|
379
|
+
if (conflict) {
|
|
380
|
+
const lines = [
|
|
381
|
+
`# Cherry-pick continue: paused on conflict after ${applied} commit(s)`,
|
|
382
|
+
"",
|
|
383
|
+
`Conflict at commit \`${conflict.commit ?? "?"}\` (${conflict.stage}):`,
|
|
384
|
+
];
|
|
385
|
+
for (const p of conflict.paths)
|
|
386
|
+
lines.push(` conflict: ${p}`);
|
|
387
|
+
if (conflict.detail)
|
|
388
|
+
lines.push(` detail: ${conflict.detail}`);
|
|
389
|
+
lines.push(" Paused: cherry-pick still in progress. Resolve the conflict, then call `git_cherry_pick_continue` again.");
|
|
390
|
+
return lines.join("\n");
|
|
391
|
+
}
|
|
392
|
+
return `# Cherry-pick continue: ${applied} commit(s) applied\nHEAD now \`${headSha ?? "?"}\`.`;
|
|
393
|
+
}
|
|
394
|
+
export function registerGitCherryPickContinueTool(server) {
|
|
395
|
+
server.addTool({
|
|
396
|
+
name: "git_cherry_pick_continue",
|
|
397
|
+
description: "Resume or abort a cherry-pick left in progress — typically by `git_cherry_pick`'s " +
|
|
398
|
+
'`onConflict: "pause"`, but this tool is stateless and reads `CHERRY_PICK_HEAD` / the native ' +
|
|
399
|
+
"sequencer live off `.git`, so it works regardless of how the in-progress state was left. " +
|
|
400
|
+
'`action: "continue"` (default) requires every previously conflicted path to be staged (no ' +
|
|
401
|
+
"remaining unmerged entries — `cherry_pick_unresolved_paths` otherwise), then runs " +
|
|
402
|
+
"`git -c core.editor=true cherry-pick --continue` so git's sequencer both commits the resolved " +
|
|
403
|
+
"pick and resumes through any remaining picks in the same range. If a *later* pick then " +
|
|
404
|
+
"conflicts, the response reports it the same way as a paused `git_cherry_pick` call (`conflict." +
|
|
405
|
+
'paused: true`) so this tool can be called again to keep walking the range. `action: "abort"` ' +
|
|
406
|
+
"rolls back the whole in-progress cherry-pick via `git cherry-pick --abort`.",
|
|
407
|
+
annotations: {
|
|
408
|
+
readOnlyHint: false,
|
|
409
|
+
destructiveHint: false,
|
|
410
|
+
idempotentHint: false,
|
|
411
|
+
},
|
|
412
|
+
parameters: WorkspacePickSchema.extend({
|
|
413
|
+
action: z
|
|
414
|
+
.enum(["continue", "abort"])
|
|
415
|
+
.optional()
|
|
416
|
+
.default("continue")
|
|
417
|
+
.describe('"continue" (default): resolve conflicts, stage them, then resume the sequencer. ' +
|
|
418
|
+
'"abort": roll back to the pre-cherry-pick HEAD.'),
|
|
419
|
+
}),
|
|
420
|
+
execute: async (args) => {
|
|
421
|
+
const pre = requireSingleRepo(server, args);
|
|
422
|
+
if (!pre.ok)
|
|
423
|
+
return jsonRespond(pre.error);
|
|
424
|
+
const gitTop = pre.gitTop;
|
|
425
|
+
const action = args.action ?? "continue";
|
|
426
|
+
const inProgressSha = await cherryPickHead(gitTop);
|
|
427
|
+
if (!inProgressSha) {
|
|
428
|
+
return jsonRespond({ error: ERROR_CODES.NO_CHERRY_PICK_IN_PROGRESS });
|
|
429
|
+
}
|
|
430
|
+
const preHeadProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
431
|
+
const preHead = preHeadProbe.ok ? preHeadProbe.stdout.trim() : "";
|
|
432
|
+
// --- abort: reuse the same hardened abort helper/reporting as git_cherry_pick ---
|
|
433
|
+
if (action === "abort") {
|
|
434
|
+
const abort = await abortCherryPick(gitTop);
|
|
435
|
+
if (!abort.ok) {
|
|
436
|
+
return jsonRespond({
|
|
437
|
+
ok: false,
|
|
438
|
+
action: "abort",
|
|
439
|
+
error: ERROR_CODES.CHERRY_PICK_ABORT_FAILED,
|
|
440
|
+
...spreadDefined("abortDetail", abort.detail),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
444
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
445
|
+
if (args.format === "json") {
|
|
446
|
+
return jsonRespond({ ok: true, action: "abort", ...spreadDefined("headSha", headSha) });
|
|
447
|
+
}
|
|
448
|
+
return renderCherryPickContinueMarkdown("abort", true, 0, headSha);
|
|
449
|
+
}
|
|
450
|
+
// --- continue: precheck no unmerged paths remain ---
|
|
451
|
+
const unmerged = await conflictPaths(gitTop);
|
|
452
|
+
if (unmerged.length > 0) {
|
|
453
|
+
return jsonRespond({
|
|
454
|
+
error: ERROR_CODES.CHERRY_PICK_UNRESOLVED_PATHS,
|
|
455
|
+
paths: unmerged,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
// `-c core.editor=true` avoids launching an interactive editor for the reused commit message.
|
|
459
|
+
const r = await spawnGitAsync(gitTop, [
|
|
460
|
+
"-c",
|
|
461
|
+
"core.editor=true",
|
|
462
|
+
"cherry-pick",
|
|
463
|
+
"--continue",
|
|
464
|
+
]);
|
|
465
|
+
if (!r.ok) {
|
|
466
|
+
const failedSha = await cherryPickHead(gitTop);
|
|
467
|
+
const paths = failedSha ? await conflictPaths(gitTop) : [];
|
|
468
|
+
if (failedSha && paths.length > 0) {
|
|
469
|
+
// A later commit in the same range conflicted — report it the same shape as a
|
|
470
|
+
// paused git_cherry_pick call so the caller can loop this tool to resolution.
|
|
471
|
+
const adv = await spawnGitAsync(gitTop, ["rev-list", "--count", `${preHead}..HEAD`]);
|
|
472
|
+
const applied = adv.ok ? parseInt(adv.stdout.trim(), 10) || 0 : 0;
|
|
473
|
+
const conflict = {
|
|
474
|
+
stage: "cherry-pick",
|
|
475
|
+
paused: true,
|
|
476
|
+
...spreadDefined("commit", failedSha),
|
|
477
|
+
paths,
|
|
478
|
+
...spreadDefined("detail", (r.stderr || r.stdout).trim() || undefined),
|
|
479
|
+
};
|
|
480
|
+
if (args.format === "json") {
|
|
481
|
+
return jsonRespond({ ok: false, action: "continue", applied, conflict });
|
|
482
|
+
}
|
|
483
|
+
return renderCherryPickContinueMarkdown("continue", false, applied, undefined, conflict);
|
|
484
|
+
}
|
|
485
|
+
// Not a new conflict (e.g. the resolved pick would produce an empty commit) —
|
|
486
|
+
// surface a generic, non-resumable-loop error with whatever detail git gave.
|
|
487
|
+
return jsonRespond({
|
|
488
|
+
error: ERROR_CODES.CHERRY_PICK_CONTINUE_FAILED,
|
|
489
|
+
...spreadDefined("commit", failedSha),
|
|
490
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
// --- success: sequencer completed the resolved pick and any remaining ones ---
|
|
494
|
+
const adv = await spawnGitAsync(gitTop, ["rev-list", "--count", `${preHead}..HEAD`]);
|
|
495
|
+
const applied = adv.ok ? parseInt(adv.stdout.trim(), 10) || 0 : 0;
|
|
496
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
497
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
498
|
+
if (args.format === "json") {
|
|
499
|
+
return jsonRespond({
|
|
500
|
+
ok: true,
|
|
501
|
+
action: "continue",
|
|
502
|
+
applied,
|
|
503
|
+
...spreadDefined("headSha", headSha),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return renderCherryPickContinueMarkdown("continue", true, applied, headSha);
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
5
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
6
|
+
import { spawnGitAsync } from "./git.js";
|
|
7
|
+
import { conflictPaths } from "./git-refs.js";
|
|
8
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
9
|
+
import { requireSingleRepo } from "./roots.js";
|
|
10
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Operation-state detection
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/** Resolve the repo's git directory (handles worktrees, where it is not literally `<top>/.git`). */
|
|
15
|
+
async function resolveGitDir(gitTop) {
|
|
16
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--git-dir"]);
|
|
17
|
+
if (!r.ok)
|
|
18
|
+
return null;
|
|
19
|
+
const raw = r.stdout.trim();
|
|
20
|
+
if (!raw)
|
|
21
|
+
return null;
|
|
22
|
+
return isAbsolute(raw) ? raw : join(gitTop, raw);
|
|
23
|
+
}
|
|
24
|
+
/** Detect the in-progress operation, if any, via marker files/dirs under the git dir. */
|
|
25
|
+
export async function detectConflictState(gitTop) {
|
|
26
|
+
const gitDir = await resolveGitDir(gitTop);
|
|
27
|
+
if (!gitDir)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (existsSync(join(gitDir, "MERGE_HEAD")))
|
|
30
|
+
return "merge";
|
|
31
|
+
if (existsSync(join(gitDir, "CHERRY_PICK_HEAD")))
|
|
32
|
+
return "cherry-pick";
|
|
33
|
+
if (existsSync(join(gitDir, "REVERT_HEAD")))
|
|
34
|
+
return "revert";
|
|
35
|
+
if (existsSync(join(gitDir, "rebase-merge")) || existsSync(join(gitDir, "rebase-apply"))) {
|
|
36
|
+
return "rebase";
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Conflict marker parsing
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const OURS_MARKER = "<<<<<<<";
|
|
44
|
+
const BASE_MARKER = "|||||||";
|
|
45
|
+
const SPLIT_MARKER = "=======";
|
|
46
|
+
const THEIRS_MARKER = ">>>>>>>";
|
|
47
|
+
function labelAfterMarker(line, marker) {
|
|
48
|
+
const rest = line.slice(marker.length).trim();
|
|
49
|
+
return rest.length > 0 ? rest : undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `<<<<<<<`/`|||||||`/`=======`/`>>>>>>>` conflict markers out of file text.
|
|
53
|
+
* Only the first `maxLinesPerFile` lines are scanned; when the file is longer,
|
|
54
|
+
* `truncated: true` is reported and any hunk still open at the cutoff is dropped
|
|
55
|
+
* rather than emitted half-formed. An incomplete hunk at EOF (corrupt/missing
|
|
56
|
+
* closing marker within the scan window) also sets `truncated: true`.
|
|
57
|
+
*/
|
|
58
|
+
export function parseConflictHunks(text, maxLinesPerFile) {
|
|
59
|
+
const allLines = text.split("\n");
|
|
60
|
+
const truncatedByCap = allLines.length > maxLinesPerFile;
|
|
61
|
+
const lines = truncatedByCap ? allLines.slice(0, maxLinesPerFile) : allLines;
|
|
62
|
+
const hunks = [];
|
|
63
|
+
let state = "outside";
|
|
64
|
+
let cur = null;
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
const line = lines[i] ?? "";
|
|
67
|
+
const lineNo = i + 1;
|
|
68
|
+
if (line.startsWith(OURS_MARKER)) {
|
|
69
|
+
cur = {
|
|
70
|
+
startLine: lineNo,
|
|
71
|
+
oursLines: [],
|
|
72
|
+
baseLines: [],
|
|
73
|
+
theirsLines: [],
|
|
74
|
+
oursLabel: labelAfterMarker(line, OURS_MARKER),
|
|
75
|
+
};
|
|
76
|
+
state = "ours";
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (state === "ours" && line.startsWith(BASE_MARKER)) {
|
|
80
|
+
state = "base";
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if ((state === "ours" || state === "base") && line.startsWith(SPLIT_MARKER)) {
|
|
84
|
+
state = "theirs";
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (state === "theirs" && line.startsWith(THEIRS_MARKER) && cur) {
|
|
88
|
+
cur.theirsLabel = labelAfterMarker(line, THEIRS_MARKER);
|
|
89
|
+
hunks.push({
|
|
90
|
+
startLine: cur.startLine,
|
|
91
|
+
ours: cur.oursLines.join("\n"),
|
|
92
|
+
theirs: cur.theirsLines.join("\n"),
|
|
93
|
+
...spreadWhen(cur.baseLines.length > 0, { base: cur.baseLines.join("\n") }),
|
|
94
|
+
...spreadDefined("oursLabel", cur.oursLabel),
|
|
95
|
+
...spreadDefined("theirsLabel", cur.theirsLabel),
|
|
96
|
+
});
|
|
97
|
+
cur = null;
|
|
98
|
+
state = "outside";
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (!cur)
|
|
102
|
+
continue;
|
|
103
|
+
if (state === "ours")
|
|
104
|
+
cur.oursLines.push(line);
|
|
105
|
+
else if (state === "base")
|
|
106
|
+
cur.baseLines.push(line);
|
|
107
|
+
else if (state === "theirs")
|
|
108
|
+
cur.theirsLines.push(line);
|
|
109
|
+
}
|
|
110
|
+
// Incomplete open hunk at EOF (or at the line-cap) → flag truncated so callers
|
|
111
|
+
// know markers were present but not fully parsed.
|
|
112
|
+
const truncated = truncatedByCap || cur !== null;
|
|
113
|
+
return { hunks, truncated };
|
|
114
|
+
}
|
|
115
|
+
/** Conservative binary sniff: a NUL byte in the first 8000 bytes. */
|
|
116
|
+
function isLikelyBinary(buf) {
|
|
117
|
+
const len = Math.min(buf.length, 8000);
|
|
118
|
+
for (let i = 0; i < len; i++) {
|
|
119
|
+
if (buf[i] === 0)
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Per-file resolution
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
export function readConflictFile(gitTop, relPath, maxLinesPerFile) {
|
|
128
|
+
const resolved = resolvePathForRepo(relPath, gitTop);
|
|
129
|
+
if (!assertRelativePathUnderTop(relPath, resolved, gitTop)) {
|
|
130
|
+
return { path: relPath, error: ERROR_CODES.PATH_ESCAPES_REPO };
|
|
131
|
+
}
|
|
132
|
+
let buf;
|
|
133
|
+
try {
|
|
134
|
+
buf = readFileSync(resolved);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return { path: relPath };
|
|
138
|
+
}
|
|
139
|
+
if (isLikelyBinary(buf)) {
|
|
140
|
+
return { path: relPath };
|
|
141
|
+
}
|
|
142
|
+
const { hunks, truncated } = parseConflictHunks(buf.toString("utf8"), maxLinesPerFile);
|
|
143
|
+
return {
|
|
144
|
+
path: relPath,
|
|
145
|
+
...spreadWhen(hunks.length > 0, { hunks }),
|
|
146
|
+
...spreadWhen(truncated, { truncated: true }),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Markdown rendering
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
function renderConflictsMarkdown(result) {
|
|
153
|
+
const lines = ["# Git conflicts"];
|
|
154
|
+
if (result.state)
|
|
155
|
+
lines.push(`_state: ${result.state}_`);
|
|
156
|
+
lines.push("");
|
|
157
|
+
if (result.files.length === 0) {
|
|
158
|
+
lines.push("_(no conflicts)_");
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
161
|
+
for (const f of result.files) {
|
|
162
|
+
lines.push(`## ${f.path}`);
|
|
163
|
+
if (f.error)
|
|
164
|
+
lines.push(`_error: ${f.error}_`);
|
|
165
|
+
if (f.truncated)
|
|
166
|
+
lines.push("_(truncated)_");
|
|
167
|
+
if (!f.hunks || f.hunks.length === 0) {
|
|
168
|
+
lines.push("_(no parsed hunks — unreadable, binary, or no markers found)_", "");
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (const h of f.hunks) {
|
|
172
|
+
lines.push(`### hunk @ line ${h.startLine}`);
|
|
173
|
+
lines.push(`**ours${h.oursLabel ? ` (${h.oursLabel})` : ""}:**`, "```", h.ours, "```");
|
|
174
|
+
if (h.base !== undefined) {
|
|
175
|
+
lines.push("**base:**", "```", h.base, "```");
|
|
176
|
+
}
|
|
177
|
+
lines.push(`**theirs${h.theirsLabel ? ` (${h.theirsLabel})` : ""}:**`, "```", h.theirs, "```");
|
|
178
|
+
}
|
|
179
|
+
lines.push("");
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n").trimEnd();
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Tool registration
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
export function registerGitConflictsTool(server) {
|
|
187
|
+
server.addTool({
|
|
188
|
+
name: "git_conflicts",
|
|
189
|
+
description: "Inspect unresolved merge conflicts after git_merge/git_cherry_pick reports them. " +
|
|
190
|
+
"Reports the in-progress operation (merge/cherry-pick/revert/rebase, when detectable) and, " +
|
|
191
|
+
"per conflicted file, the parsed ours/theirs (and base, for diff3-style markers) hunks.",
|
|
192
|
+
annotations: {
|
|
193
|
+
readOnlyHint: true,
|
|
194
|
+
},
|
|
195
|
+
parameters: WorkspacePickSchema.extend({
|
|
196
|
+
withHunks: z
|
|
197
|
+
.boolean()
|
|
198
|
+
.optional()
|
|
199
|
+
.default(true)
|
|
200
|
+
.describe("Parse conflict-marker hunks per file. Set false for just the path list."),
|
|
201
|
+
maxLinesPerFile: z
|
|
202
|
+
.number()
|
|
203
|
+
.int()
|
|
204
|
+
.min(1)
|
|
205
|
+
.max(2000)
|
|
206
|
+
.optional()
|
|
207
|
+
.default(200)
|
|
208
|
+
.describe("Cap on lines scanned per file before marking `truncated: true`."),
|
|
209
|
+
}),
|
|
210
|
+
execute: async (args) => {
|
|
211
|
+
const pre = requireSingleRepo(server, args);
|
|
212
|
+
if (!pre.ok)
|
|
213
|
+
return jsonRespond(pre.error);
|
|
214
|
+
const gitTop = pre.gitTop;
|
|
215
|
+
const state = await detectConflictState(gitTop);
|
|
216
|
+
const paths = await conflictPaths(gitTop);
|
|
217
|
+
const withHunks = args.withHunks !== false;
|
|
218
|
+
const maxLinesPerFile = typeof args.maxLinesPerFile === "number" ? args.maxLinesPerFile : 200;
|
|
219
|
+
const files = paths.map((p) => withHunks ? readConflictFile(gitTop, p, maxLinesPerFile) : { path: p });
|
|
220
|
+
const result = {
|
|
221
|
+
...spreadDefined("state", state),
|
|
222
|
+
files,
|
|
223
|
+
};
|
|
224
|
+
if (args.format === "json") {
|
|
225
|
+
return jsonRespond(result);
|
|
226
|
+
}
|
|
227
|
+
return renderConflictsMarkdown(result);
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
}
|