@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.
Files changed (66) hide show
  1. package/AGENTS.md +21 -15
  2. package/CHANGELOG.md +114 -39
  3. package/HUMANS.md +20 -39
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +187 -46
  7. package/dist/server/error-codes.js +25 -7
  8. package/dist/server/git-blame-tool.js +6 -3
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +220 -11
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +36 -25
  13. package/dist/server/git-diff-tool.js +55 -27
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +26 -6
  16. package/dist/server/git-log-tool.js +35 -4
  17. package/dist/server/git-merge-tool.js +70 -12
  18. package/dist/server/git-parity-tool.js +13 -4
  19. package/dist/server/git-push-tool.js +3 -1
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-revert-tool.js +160 -0
  22. package/dist/server/git-show-tool.js +7 -3
  23. package/dist/server/git-stash-tool.js +110 -67
  24. package/dist/server/git-tag-tool.js +7 -6
  25. package/dist/server/git-worktree-tool.js +65 -53
  26. package/dist/server/git.js +116 -24
  27. package/dist/server/inventory.js +90 -32
  28. package/dist/server/presets-resource.js +37 -20
  29. package/dist/server/presets.js +87 -15
  30. package/dist/server/roots.js +13 -1
  31. package/dist/server/schemas.js +9 -2
  32. package/dist/server/test-harness.js +11 -4
  33. package/dist/server/tool-parameter-schemas.js +44 -55
  34. package/dist/server/tools.js +36 -17
  35. package/dist/server.js +1 -1
  36. package/docs/install.md +52 -5
  37. package/docs/mcp-tools.md +385 -178
  38. package/package.json +6 -6
  39. package/schemas/batch_commit.json +2 -2
  40. package/schemas/git_blame.json +1 -1
  41. package/schemas/git_branch.json +54 -0
  42. package/schemas/git_cherry_pick.json +12 -2
  43. package/schemas/git_cherry_pick_continue.json +34 -0
  44. package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
  45. package/schemas/git_diff.json +11 -3
  46. package/schemas/git_grep.json +85 -0
  47. package/schemas/git_inventory.json +19 -5
  48. package/schemas/git_log.json +7 -6
  49. package/schemas/git_merge.json +2 -2
  50. package/schemas/git_parity.json +0 -5
  51. package/schemas/git_revert.json +47 -0
  52. package/schemas/git_show.json +1 -1
  53. package/schemas/git_stash_push.json +47 -0
  54. package/schemas/git_status.json +0 -5
  55. package/schemas/git_worktree_add.json +1 -1
  56. package/schemas/git_worktree_remove.json +1 -1
  57. package/schemas/index.json +28 -23
  58. package/schemas/list_presets.json +0 -5
  59. package/tool-parameters.schema.json +326 -167
  60. package/dist/server/git-branch-list-tool.js +0 -132
  61. package/dist/server/git-fetch-tool.js +0 -257
  62. package/dist/server/git-reflog-tool.js +0 -120
  63. package/schemas/git_branch_list.json +0 -30
  64. package/schemas/git_fetch.json +0 -46
  65. package/schemas/git_stash_list.json +0 -24
  66. 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
- async function abortCherryPick(gitTop) {
19
- await spawnGitAsync(gitTop, ["cherry-pick", "--abort"]);
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
- await abortCherryPick(gitTop);
192
- conflict = {
193
- stage: "cherry-pick",
194
- ...spreadDefined("commit", failedSha),
195
- paths,
196
- detail: (r.stderr || r.stdout).trim(),
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
- : `# Cherry-pick onto \`${onto}\`: stopped on conflict after ${appliedCount} commit(s)`;
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
+ }