@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.
- package/AGENTS.md +23 -19
- package/CHANGELOG.md +145 -39
- package/HUMANS.md +23 -42
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +203 -53
- package/dist/server/error-codes.js +31 -16
- package/dist/server/git-blame-tool.js +66 -19
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +221 -12
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +39 -28
- package/dist/server/git-diff-tool.js +56 -31
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +30 -10
- package/dist/server/git-log-tool.js +37 -6
- package/dist/server/git-merge-tool.js +71 -13
- package/dist/server/git-parity-tool.js +15 -6
- package/dist/server/git-push-tool.js +4 -2
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-reset-soft-tool.js +1 -1
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +5 -10
- package/dist/server/git-stash-tool.js +112 -78
- package/dist/server/git-status-tool.js +2 -2
- package/dist/server/git-tag-tool.js +8 -13
- package/dist/server/git-worktree-tool.js +67 -59
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/list-presets-tool.js +2 -8
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +52 -79
- package/dist/server/schemas.js +18 -19
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +47 -58
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +472 -284
- package/package.json +6 -6
- package/schemas/batch_commit.json +5 -17
- package/schemas/git_blame.json +13 -11
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +13 -15
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/git_conflicts.json +38 -0
- package/schemas/git_diff.json +12 -10
- package/schemas/git_diff_summary.json +1 -15
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +32 -23
- package/schemas/git_log.json +20 -24
- package/schemas/git_merge.json +3 -15
- package/schemas/git_parity.json +13 -23
- package/schemas/git_push.json +1 -13
- package/schemas/git_reset_soft.json +1 -13
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +2 -8
- package/schemas/git_stash_apply.json +2 -8
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +13 -23
- package/schemas/git_tag.json +1 -7
- package/schemas/git_worktree_add.json +2 -14
- package/schemas/git_worktree_remove.json +2 -14
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +13 -23
- package/tool-parameters.schema.json +407 -423
- package/dist/server/git-branch-list-tool.js +0 -138
- package/dist/server/git-fetch-tool.js +0 -266
- package/dist/server/git-reflog-tool.js +0 -126
- package/schemas/git_branch_list.json +0 -36
- package/schemas/git_fetch.json +0 -52
- package/schemas/git_reflog.json +0 -44
- package/schemas/git_stash_list.json +0 -30
- 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 {
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
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: "
|
|
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.
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.",
|
|
@@ -104,7 +112,7 @@ export function registerGitCherryPickTool(server) {
|
|
|
104
112
|
destructiveHint: false,
|
|
105
113
|
idempotentHint: false,
|
|
106
114
|
},
|
|
107
|
-
parameters: WorkspacePickSchema.
|
|
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
|
-
|
|
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
|
+
}
|