@rethunk/mcp-multi-root-git 2.9.1 → 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 (75) hide show
  1. package/AGENTS.md +23 -19
  2. package/CHANGELOG.md +145 -39
  3. package/HUMANS.md +23 -42
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +203 -53
  7. package/dist/server/error-codes.js +31 -16
  8. package/dist/server/git-blame-tool.js +66 -19
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +221 -12
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +39 -28
  13. package/dist/server/git-diff-tool.js +56 -31
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +30 -10
  16. package/dist/server/git-log-tool.js +37 -6
  17. package/dist/server/git-merge-tool.js +71 -13
  18. package/dist/server/git-parity-tool.js +15 -6
  19. package/dist/server/git-push-tool.js +4 -2
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-reset-soft-tool.js +1 -1
  22. package/dist/server/git-revert-tool.js +160 -0
  23. package/dist/server/git-show-tool.js +5 -10
  24. package/dist/server/git-stash-tool.js +112 -78
  25. package/dist/server/git-status-tool.js +2 -2
  26. package/dist/server/git-tag-tool.js +8 -13
  27. package/dist/server/git-worktree-tool.js +67 -59
  28. package/dist/server/git.js +116 -24
  29. package/dist/server/inventory.js +90 -32
  30. package/dist/server/list-presets-tool.js +2 -8
  31. package/dist/server/presets-resource.js +37 -20
  32. package/dist/server/presets.js +87 -15
  33. package/dist/server/roots.js +52 -79
  34. package/dist/server/schemas.js +18 -19
  35. package/dist/server/test-harness.js +11 -4
  36. package/dist/server/tool-parameter-schemas.js +47 -58
  37. package/dist/server/tools.js +36 -17
  38. package/dist/server.js +1 -1
  39. package/docs/install.md +52 -5
  40. package/docs/mcp-tools.md +472 -284
  41. package/package.json +6 -6
  42. package/schemas/batch_commit.json +5 -17
  43. package/schemas/git_blame.json +13 -11
  44. package/schemas/git_branch.json +54 -0
  45. package/schemas/git_cherry_pick.json +13 -15
  46. package/schemas/git_cherry_pick_continue.json +34 -0
  47. package/schemas/git_conflicts.json +38 -0
  48. package/schemas/git_diff.json +12 -10
  49. package/schemas/git_diff_summary.json +1 -15
  50. package/schemas/git_grep.json +85 -0
  51. package/schemas/git_inventory.json +32 -23
  52. package/schemas/git_log.json +20 -24
  53. package/schemas/git_merge.json +3 -15
  54. package/schemas/git_parity.json +13 -23
  55. package/schemas/git_push.json +1 -13
  56. package/schemas/git_reset_soft.json +1 -13
  57. package/schemas/git_revert.json +47 -0
  58. package/schemas/git_show.json +2 -8
  59. package/schemas/git_stash_apply.json +2 -8
  60. package/schemas/git_stash_push.json +47 -0
  61. package/schemas/git_status.json +13 -23
  62. package/schemas/git_tag.json +1 -7
  63. package/schemas/git_worktree_add.json +2 -14
  64. package/schemas/git_worktree_remove.json +2 -14
  65. package/schemas/index.json +28 -23
  66. package/schemas/list_presets.json +13 -23
  67. package/tool-parameters.schema.json +407 -423
  68. package/dist/server/git-branch-list-tool.js +0 -138
  69. package/dist/server/git-fetch-tool.js +0 -266
  70. package/dist/server/git-reflog-tool.js +0 -126
  71. package/schemas/git_branch_list.json +0 -36
  72. package/schemas/git_fetch.json +0 -52
  73. package/schemas/git_reflog.json +0 -44
  74. package/schemas/git_stash_list.json +0 -30
  75. package/schemas/git_worktree_list.json +0 -30
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
4
  import { spawnGitAsync } from "./git.js";
5
- import { isSafeGitRefToken } from "./git-refs.js";
6
- import { jsonRespond, spreadDefined } from "./json.js";
5
+ import { isSafeGitCommitIsh } from "./git-refs.js";
6
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
7
7
  import { requireSingleRepo } from "./roots.js";
8
8
  import { WorkspacePickSchema } from "./schemas.js";
9
9
  /**
@@ -125,17 +125,50 @@ function parsePorcelain(output) {
125
125
  return result;
126
126
  }
127
127
  // ---------------------------------------------------------------------------
128
+ // Run-length grouping
129
+ // ---------------------------------------------------------------------------
130
+ /**
131
+ * Collapse per-line blame records into one group per contiguous same-commit
132
+ * run, so commit metadata (sha/author/date/summary) is emitted once per run
133
+ * instead of once per line.
134
+ */
135
+ function groupBlameLines(lines) {
136
+ const groups = [];
137
+ let current;
138
+ for (const l of lines) {
139
+ if (current && current.sha === l.sha && current.endLine === l.line - 1) {
140
+ current.endLine = l.line;
141
+ current.lines.push({ line: l.line, content: l.content });
142
+ continue;
143
+ }
144
+ current = {
145
+ sha: l.sha,
146
+ author: l.author,
147
+ date: l.date,
148
+ summary: l.summary,
149
+ startLine: l.line,
150
+ endLine: l.line,
151
+ lines: [{ line: l.line, content: l.content }],
152
+ };
153
+ groups.push(current);
154
+ }
155
+ return groups;
156
+ }
157
+ // ---------------------------------------------------------------------------
128
158
  // Markdown rendering
129
159
  // ---------------------------------------------------------------------------
130
160
  function renderBlameMarkdown(result) {
131
161
  const header = result.ref !== undefined
132
162
  ? `# git blame ${result.ref} -- ${result.path}`
133
163
  : `# git blame ${result.path}`;
134
- const rows = result.lines.map((l) => {
135
- const sha7 = l.sha.slice(0, 7);
136
- return `${sha7} (${l.author} ${l.date}) ${l.content}`;
137
- });
138
- return [`${header}`, "", "```", ...rows, "```"].join("\n");
164
+ const out = [header];
165
+ for (const g of result.groups) {
166
+ out.push("", `## ${g.sha.slice(0, 7)} ${g.author} ${g.date} — ${g.summary} (lines ${g.startLine}–${g.endLine})`, "```", ...g.lines.map((l) => `${l.line}: ${l.content}`), "```");
167
+ }
168
+ if (result.truncated) {
169
+ out.push("", `_(truncated — ${result.omittedLines} more line(s) not shown; raise maxLines)_`);
170
+ }
171
+ return out.join("\n");
139
172
  }
140
173
  // ---------------------------------------------------------------------------
141
174
  // Tool registration
@@ -143,31 +176,38 @@ function renderBlameMarkdown(result) {
143
176
  export function registerGitBlameTool(server) {
144
177
  server.addTool({
145
178
  name: "git_blame",
146
- description: "Annotate each line of a file with the commit SHA, author, date, and summary that last modified it. Optionally restrict to a commit-ish ref and/or a line range.",
179
+ description: "File authorship, grouped into contiguous same-commit line runs (sha, author, date, summary once per run). Optionally restrict to a commit-ish ref and/or a line range.",
147
180
  annotations: {
148
181
  readOnlyHint: true,
149
182
  },
150
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
151
- .pick({
152
- workspaceRoot: true,
153
- rootIndex: true,
154
- format: true,
155
- })
156
- .extend({
183
+ parameters: WorkspacePickSchema.extend({
157
184
  path: z.string().min(1).describe("Repo-relative path to the file to blame."),
158
- ref: z.string().optional().describe("Optional commit-ish (SHA, branch, tag) to blame at."),
185
+ ref: z
186
+ .string()
187
+ .optional()
188
+ .describe('Optional commit-ish (SHA, branch, tag) to blame at. Ancestor notation is accepted (e.g. "HEAD~3", "main^2").'),
159
189
  startLine: z
160
190
  .number()
161
191
  .int()
162
192
  .min(1)
193
+ .max(1000000)
163
194
  .optional()
164
195
  .describe("First line of the range to blame (1-based). Requires endLine."),
165
196
  endLine: z
166
197
  .number()
167
198
  .int()
168
199
  .min(1)
200
+ .max(1000000)
169
201
  .optional()
170
202
  .describe("Last line of the range to blame (1-based, inclusive). Requires startLine."),
203
+ maxLines: z
204
+ .number()
205
+ .int()
206
+ .min(1)
207
+ .max(10000)
208
+ .optional()
209
+ .default(2000)
210
+ .describe("Max blamed lines to return. Default: 2000."),
171
211
  }),
172
212
  execute: async (args) => {
173
213
  const pre = requireSingleRepo(server, args);
@@ -181,7 +221,7 @@ export function registerGitBlameTool(server) {
181
221
  }
182
222
  // Ref validation
183
223
  if (args.ref !== undefined) {
184
- if (!isSafeGitRefToken(args.ref)) {
224
+ if (!isSafeGitCommitIsh(args.ref)) {
185
225
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
186
226
  }
187
227
  }
@@ -212,11 +252,18 @@ export function registerGitBlameTool(server) {
212
252
  detail: (r.stderr || r.stdout).trim(),
213
253
  });
214
254
  }
215
- const blameLines = parsePorcelain(r.stdout);
255
+ const allLines = parsePorcelain(r.stdout);
256
+ const maxLines = args.maxLines ?? 2000;
257
+ const truncated = allLines.length > maxLines;
258
+ const blameLines = truncated ? allLines.slice(0, maxLines) : allLines;
216
259
  const blameJson = {
217
260
  ...spreadDefined("ref", args.ref),
218
261
  path: args.path,
219
- lines: blameLines,
262
+ groups: groupBlameLines(blameLines),
263
+ ...spreadWhen(truncated, {
264
+ truncated: true,
265
+ omittedLines: allLines.length - maxLines,
266
+ }),
220
267
  };
221
268
  if (args.format === "json") {
222
269
  return jsonRespond(blameJson);
@@ -0,0 +1,151 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { isProtectedBranch, isSafeGitAncestorRef, isSafeGitRefToken } from "./git-refs.js";
5
+ import { jsonRespond } from "./json.js";
6
+ import { requireSingleRepo } from "./roots.js";
7
+ import { WorkspacePickSchema } from "./schemas.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+ /** Resolve a ref (branch/commit-ish) to its full SHA; `null` if unresolvable. */
12
+ async function getRefSha(gitTop, ref) {
13
+ const result = await spawnGitAsync(gitTop, [
14
+ "rev-parse",
15
+ "--verify",
16
+ "--quiet",
17
+ `${ref}^{commit}`,
18
+ ]);
19
+ if (!result.ok)
20
+ return null;
21
+ const sha = result.stdout.trim();
22
+ return sha === "" ? null : sha;
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Tool registration
26
+ // ---------------------------------------------------------------------------
27
+ export function registerGitBranchTool(server) {
28
+ server.addTool({
29
+ name: "git_branch",
30
+ description: "Create, delete, or rename a local git branch. `action: 'create'` makes `name` from `from` " +
31
+ "(default HEAD); `action: 'delete'` removes `name` (`force: true` for an unmerged branch, `-D`); " +
32
+ "`action: 'rename'` renames `name` to `newName`. Refuses protected branch names " +
33
+ "(main/master/dev/develop/stable/trunk/prod/production/release*/hotfix*) in any role.",
34
+ annotations: {
35
+ readOnlyHint: false,
36
+ destructiveHint: true,
37
+ idempotentHint: false,
38
+ },
39
+ parameters: WorkspacePickSchema.extend({
40
+ action: z.enum(["create", "delete", "rename"]).describe("Which branch operation to perform."),
41
+ name: z
42
+ .string()
43
+ .min(1)
44
+ .describe("Branch name to create/delete, or the existing branch name for rename."),
45
+ from: z
46
+ .string()
47
+ .optional()
48
+ .describe("Commit-ish to base a new branch on (create only). Default: HEAD."),
49
+ newName: z.string().optional().describe("New branch name (required for rename)."),
50
+ force: z
51
+ .boolean()
52
+ .optional()
53
+ .default(false)
54
+ .describe("Force-delete an unmerged branch (`git branch -D`). Delete only; never overrides protected-branch rejection."),
55
+ }),
56
+ execute: async (args) => {
57
+ const pre = requireSingleRepo(server, args);
58
+ if (!pre.ok)
59
+ return jsonRespond(pre.error);
60
+ const { gitTop } = pre;
61
+ const name = args.name.trim();
62
+ if (!isSafeGitRefToken(name)) {
63
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: name });
64
+ }
65
+ if (isProtectedBranch(name)) {
66
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: name });
67
+ }
68
+ if (args.action === "create") {
69
+ const fromRef = (args.from ?? "HEAD").trim();
70
+ if (!isSafeGitAncestorRef(fromRef)) {
71
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: fromRef });
72
+ }
73
+ const sha = await getRefSha(gitTop, fromRef);
74
+ if (!sha) {
75
+ return jsonRespond({ error: ERROR_CODES.REF_NOT_FOUND, ref: fromRef });
76
+ }
77
+ const createResult = await spawnGitAsync(gitTop, ["branch", name, fromRef]);
78
+ if (!createResult.ok) {
79
+ return jsonRespond({
80
+ error: ERROR_CODES.BRANCH_CREATE_FAILED,
81
+ detail: (createResult.stderr || createResult.stdout).trim(),
82
+ });
83
+ }
84
+ return respond(args.format, { action: "create", branch: name, sha }, fromRef);
85
+ }
86
+ if (args.action === "delete") {
87
+ const sha = await getRefSha(gitTop, name);
88
+ if (!sha) {
89
+ return jsonRespond({ error: ERROR_CODES.REF_NOT_FOUND, ref: name });
90
+ }
91
+ const deleteArgs = ["branch", args.force ? "-D" : "-d", name];
92
+ const deleteResult = await spawnGitAsync(gitTop, deleteArgs);
93
+ if (!deleteResult.ok) {
94
+ return jsonRespond({
95
+ error: ERROR_CODES.BRANCH_DELETE_FAILED,
96
+ detail: (deleteResult.stderr || deleteResult.stdout).trim(),
97
+ });
98
+ }
99
+ return respond(args.format, { action: "delete", branch: name, sha });
100
+ }
101
+ // action === "rename"
102
+ const newName = args.newName?.trim();
103
+ if (!newName) {
104
+ return jsonRespond({ error: ERROR_CODES.MISSING_NEW_NAME });
105
+ }
106
+ if (!isSafeGitRefToken(newName)) {
107
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: newName });
108
+ }
109
+ if (isProtectedBranch(newName)) {
110
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: newName });
111
+ }
112
+ const renameResult = await spawnGitAsync(gitTop, ["branch", "-m", name, newName]);
113
+ if (!renameResult.ok) {
114
+ return jsonRespond({
115
+ error: ERROR_CODES.BRANCH_RENAME_FAILED,
116
+ detail: (renameResult.stderr || renameResult.stdout).trim(),
117
+ });
118
+ }
119
+ const sha = await getRefSha(gitTop, newName);
120
+ if (!sha) {
121
+ return jsonRespond({
122
+ error: ERROR_CODES.BRANCH_RENAME_FAILED,
123
+ detail: `renamed '${name}' to '${newName}' but could not resolve its SHA`,
124
+ });
125
+ }
126
+ return respond(args.format, { action: "rename", branch: newName, sha }, undefined, name);
127
+ },
128
+ });
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Output rendering
132
+ // ---------------------------------------------------------------------------
133
+ function respond(format, result, from, renamedFrom) {
134
+ if (format === "json") {
135
+ return jsonRespond(result);
136
+ }
137
+ const lines = [];
138
+ if (result.action === "create") {
139
+ lines.push(`# Branch created: ${result.branch}`);
140
+ lines.push("");
141
+ lines.push(`**From:** ${from ?? "HEAD"}`);
142
+ }
143
+ else if (result.action === "delete") {
144
+ lines.push(`# Branch deleted: ${result.branch}`);
145
+ }
146
+ else {
147
+ lines.push(`# Branch renamed: ${renamedFrom ?? "?"} → ${result.branch}`);
148
+ }
149
+ lines.push(`**SHA:** \`${result.sha}\``);
150
+ return lines.join("\n");
151
+ }
@@ -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.",
@@ -104,7 +112,7 @@ export function registerGitCherryPickTool(server) {
104
112
  destructiveHint: false,
105
113
  idempotentHint: false,
106
114
  },
107
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
115
+ parameters: WorkspacePickSchema.extend({
108
116
  sources: z
109
117
  .array(z.string().min(1))
110
118
  .min(1)
@@ -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
+ }