@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
4
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
5
|
import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
|
|
5
6
|
import { isSafeGitAncestorRef } from "./git-refs.js";
|
|
@@ -21,7 +22,7 @@ const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-STAR
|
|
|
21
22
|
// %x02 is placed at the START of each record (tformat adds \n as terminator after each).
|
|
22
23
|
// Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
|
|
23
24
|
// each structured as: <fields>\x01\n\n <shortstat text>\n
|
|
24
|
-
// Fields are separated by %x01; the trailing
|
|
25
|
+
// Fields are separated by %x01; the trailing %x01 before \n leaves one empty last field (ignored).
|
|
25
26
|
const PRETTY_FORMAT = "%x02%H%x01%s%x01%aN%x01%aE%x01%aI%x01";
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
// Helpers
|
|
@@ -55,7 +56,7 @@ async function gitCurrentBranch(cwd, branchArg) {
|
|
|
55
56
|
* Run git log for a single repo root and return structured data.
|
|
56
57
|
*/
|
|
57
58
|
async function runGitLog(opts) {
|
|
58
|
-
const { top, since, paths, grep, author, maxCommits, branch } = opts;
|
|
59
|
+
const { top, since, paths, grep, author, maxCommits, branch, follow } = opts;
|
|
59
60
|
// Resolve branch first (needed for output metadata).
|
|
60
61
|
const resolvedBranch = await gitCurrentBranch(top, branch);
|
|
61
62
|
// Fetch one extra commit to detect truncation.
|
|
@@ -68,6 +69,9 @@ async function runGitLog(opts) {
|
|
|
68
69
|
String(fetchLimit),
|
|
69
70
|
`--since=${since}`,
|
|
70
71
|
];
|
|
72
|
+
if (follow) {
|
|
73
|
+
logArgs.push("--follow");
|
|
74
|
+
}
|
|
71
75
|
if (branch?.trim()) {
|
|
72
76
|
logArgs.push(branch.trim());
|
|
73
77
|
}
|
|
@@ -176,7 +180,8 @@ function renderLogMarkdown(group, filterSummary) {
|
|
|
176
180
|
export function registerGitLogTool(server) {
|
|
177
181
|
server.addTool({
|
|
178
182
|
name: "git_log",
|
|
179
|
-
description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats."
|
|
183
|
+
description: "Read-only `git log` across one or more roots. Returns author, date, subject, optional diff stats. " +
|
|
184
|
+
"`follow: true` passes `--follow` for rename-aware history (requires exactly one `paths` entry).",
|
|
180
185
|
annotations: {
|
|
181
186
|
readOnlyHint: true,
|
|
182
187
|
},
|
|
@@ -209,6 +214,11 @@ export function registerGitLogTool(server) {
|
|
|
209
214
|
.default(DEFAULT_MAX_COMMITS)
|
|
210
215
|
.describe(`Max commits per root (hard cap ${MAX_COMMITS_HARD_CAP}, default ${DEFAULT_MAX_COMMITS}).`),
|
|
211
216
|
branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
|
|
217
|
+
follow: z
|
|
218
|
+
.boolean()
|
|
219
|
+
.optional()
|
|
220
|
+
.default(false)
|
|
221
|
+
.describe("Pass `git log --follow` for rename-aware history. Requires exactly one entry in `paths`."),
|
|
212
222
|
}),
|
|
213
223
|
execute: async (args) => {
|
|
214
224
|
const pre = requireGitAndRoots(server, args, undefined);
|
|
@@ -227,12 +237,20 @@ export function registerGitLogTool(server) {
|
|
|
227
237
|
return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
|
|
228
238
|
}
|
|
229
239
|
}
|
|
240
|
+
const follow = args.follow ?? false;
|
|
241
|
+
if (follow && rawPaths.length !== 1) {
|
|
242
|
+
return jsonRespond({
|
|
243
|
+
error: ERROR_CODES.INVALID_PATHS,
|
|
244
|
+
detail: "follow requires exactly one path",
|
|
245
|
+
pathCount: rawPaths.length,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
230
248
|
// Validate branch — reject leading-dash and other injection attempts.
|
|
231
249
|
if (args.branch && !isSafeGitAncestorRef(args.branch)) {
|
|
232
250
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
|
|
233
251
|
}
|
|
234
252
|
const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
|
|
235
|
-
// Fan out across roots.
|
|
253
|
+
// Fan out across roots. Path confinement is per-root (each has its own toplevel).
|
|
236
254
|
const jobs = pre.roots.map((rootInput) => ({ rootInput }));
|
|
237
255
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
238
256
|
const top = gitTopLevel(rootInput);
|
|
@@ -243,6 +261,16 @@ export function registerGitLogTool(server) {
|
|
|
243
261
|
error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
|
|
244
262
|
};
|
|
245
263
|
}
|
|
264
|
+
for (const p of rawPaths) {
|
|
265
|
+
const resolved = resolvePathForRepo(p, top);
|
|
266
|
+
if (!assertRelativePathUnderTop(p, resolved, top)) {
|
|
267
|
+
return {
|
|
268
|
+
_error: true,
|
|
269
|
+
workspaceRoot: rootInput,
|
|
270
|
+
error: ERROR_CODES.PATH_ESCAPES_REPO,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
}
|
|
246
274
|
const r = await runGitLog({
|
|
247
275
|
top,
|
|
248
276
|
since: rawSince,
|
|
@@ -251,6 +279,7 @@ export function registerGitLogTool(server) {
|
|
|
251
279
|
author: args.author,
|
|
252
280
|
maxCommits,
|
|
253
281
|
branch: args.branch,
|
|
282
|
+
follow,
|
|
254
283
|
});
|
|
255
284
|
if ("error" in r) {
|
|
256
285
|
return { _error: true, workspaceRoot: rootInput, error: r.error };
|
|
@@ -261,6 +290,8 @@ export function registerGitLogTool(server) {
|
|
|
261
290
|
const filterParts = [`since: ${rawSince}`];
|
|
262
291
|
if (rawPaths.length > 0)
|
|
263
292
|
filterParts.push(`paths: ${rawPaths.join(", ")}`);
|
|
293
|
+
if (follow)
|
|
294
|
+
filterParts.push("follow");
|
|
264
295
|
if (args.grep)
|
|
265
296
|
filterParts.push(`grep: ${args.grep}`);
|
|
266
297
|
if (args.author)
|
|
@@ -15,22 +15,39 @@ const StrategySchema = z
|
|
|
15
15
|
.describe("`auto` (default): cascade fast-forward → rebase → merge-commit per source. " +
|
|
16
16
|
"`ff-only`: only fast-forward, fail if diverged. " +
|
|
17
17
|
"`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
|
|
18
|
-
"`merge`: always create a merge commit (no fast-forward)."
|
|
18
|
+
"`merge`: always create a merge commit (no fast-forward). " +
|
|
19
|
+
"Note: `auto`/`rebase` rewrite the source branch tip in place when rebasing (new SHAs).");
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
// Abort helpers
|
|
21
22
|
// ---------------------------------------------------------------------------
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
/** Result of `git merge --abort` — callers must check `ok` before claiming a clean abort. */
|
|
24
|
+
export async function abortMerge(gitTop) {
|
|
25
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--abort"]);
|
|
26
|
+
if (r.ok)
|
|
27
|
+
return { ok: true };
|
|
28
|
+
const detail = (r.stderr || r.stdout).trim();
|
|
29
|
+
return detail === "" ? { ok: false } : { ok: false, detail };
|
|
24
30
|
}
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
/** Result of `git rebase --abort` — callers must check `ok` before claiming a clean abort. */
|
|
32
|
+
export async function abortRebase(gitTop) {
|
|
33
|
+
const r = await spawnGitAsync(gitTop, ["rebase", "--abort"]);
|
|
34
|
+
if (r.ok)
|
|
35
|
+
return { ok: true };
|
|
36
|
+
const detail = (r.stderr || r.stdout).trim();
|
|
37
|
+
return detail === "" ? { ok: false } : { ok: false, detail };
|
|
38
|
+
}
|
|
39
|
+
async function mergeInProgress(gitTop) {
|
|
40
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
|
|
41
|
+
return r.ok;
|
|
27
42
|
}
|
|
28
43
|
// ---------------------------------------------------------------------------
|
|
29
44
|
// Per-source merge logic
|
|
30
45
|
// ---------------------------------------------------------------------------
|
|
31
46
|
/**
|
|
32
47
|
* Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
|
|
33
|
-
* On conflict the repo
|
|
48
|
+
* On conflict the repo is aborted when possible; if `--abort` itself fails the
|
|
49
|
+
* result carries `merge_abort_failed` / `rebase_abort_failed` and the tree may
|
|
50
|
+
* still be mid-merge/rebase.
|
|
34
51
|
*/
|
|
35
52
|
async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
36
53
|
// --- Classify state via merge-base ---
|
|
@@ -79,6 +96,7 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
|
79
96
|
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
80
97
|
}
|
|
81
98
|
// --- rebase or auto (diverged case) ---
|
|
99
|
+
// Warning: rebaseSourceOntoInto rewrites the source branch tip in place.
|
|
82
100
|
const rebased = await rebaseSourceOntoInto(gitTop, into, source);
|
|
83
101
|
if (rebased.ok) {
|
|
84
102
|
// Rebase succeeded; FF destination up to the now-rebased source tip.
|
|
@@ -96,7 +114,21 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
|
96
114
|
async function fastForward(gitTop, source) {
|
|
97
115
|
const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
|
|
98
116
|
if (!r.ok) {
|
|
99
|
-
|
|
117
|
+
// ff-only refusal normally leaves no MERGE_HEAD — only abort when mid-merge.
|
|
118
|
+
if (await mergeInProgress(gitTop)) {
|
|
119
|
+
const abort = await abortMerge(gitTop);
|
|
120
|
+
if (!abort.ok) {
|
|
121
|
+
return {
|
|
122
|
+
source,
|
|
123
|
+
ok: false,
|
|
124
|
+
outcome: "conflicts",
|
|
125
|
+
conflictStage: "merge",
|
|
126
|
+
conflictPaths: [],
|
|
127
|
+
error: ERROR_CODES.MERGE_ABORT_FAILED,
|
|
128
|
+
detail: abort.detail ?? (r.stderr || r.stdout).trim(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
100
132
|
return {
|
|
101
133
|
source,
|
|
102
134
|
ok: false,
|
|
@@ -120,7 +152,18 @@ async function mergeCommit(gitTop, source, message, into) {
|
|
|
120
152
|
const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
|
|
121
153
|
if (!r.ok) {
|
|
122
154
|
const paths = await conflictPaths(gitTop);
|
|
123
|
-
await abortMerge(gitTop);
|
|
155
|
+
const abort = await abortMerge(gitTop);
|
|
156
|
+
if (!abort.ok) {
|
|
157
|
+
return {
|
|
158
|
+
source,
|
|
159
|
+
ok: false,
|
|
160
|
+
outcome: "conflicts",
|
|
161
|
+
conflictStage: "merge",
|
|
162
|
+
conflictPaths: paths,
|
|
163
|
+
error: ERROR_CODES.MERGE_ABORT_FAILED,
|
|
164
|
+
detail: abort.detail ?? (r.stderr || r.stdout).trim(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
124
167
|
return {
|
|
125
168
|
source,
|
|
126
169
|
ok: false,
|
|
@@ -142,15 +185,27 @@ async function mergeCommit(gitTop, source, message, into) {
|
|
|
142
185
|
/**
|
|
143
186
|
* Rebase `source` onto `into`, then return to `into`.
|
|
144
187
|
* On failure: abort rebase, check out `into` again, return structured conflict.
|
|
188
|
+
* Successful rebase rewrites the source branch tip (new SHAs).
|
|
145
189
|
*/
|
|
146
190
|
async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
147
191
|
// `git rebase <upstream> <branch>` first switches to <branch>.
|
|
148
192
|
const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
|
|
149
193
|
if (!r.ok) {
|
|
150
194
|
const paths = await conflictPaths(gitTop);
|
|
151
|
-
await abortRebase(gitTop);
|
|
152
|
-
// Ensure we're back on `into` regardless of rebase state.
|
|
195
|
+
const abort = await abortRebase(gitTop);
|
|
196
|
+
// Ensure we're back on `into` regardless of rebase state (best-effort).
|
|
153
197
|
await spawnGitAsync(gitTop, ["checkout", into]);
|
|
198
|
+
if (!abort.ok) {
|
|
199
|
+
return {
|
|
200
|
+
source,
|
|
201
|
+
ok: false,
|
|
202
|
+
outcome: "conflicts",
|
|
203
|
+
conflictStage: "rebase",
|
|
204
|
+
conflictPaths: paths,
|
|
205
|
+
error: ERROR_CODES.REBASE_ABORT_FAILED,
|
|
206
|
+
detail: abort.detail ?? (r.stderr || r.stdout).trim(),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
154
209
|
return {
|
|
155
210
|
source,
|
|
156
211
|
ok: false,
|
|
@@ -179,6 +234,9 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
|
179
234
|
async function maybeRemoveWorktree(gitTop, source, enabled) {
|
|
180
235
|
if (!enabled)
|
|
181
236
|
return undefined;
|
|
237
|
+
// Gate on the source branch name first (path basename may be an agent temp dir).
|
|
238
|
+
if (isProtectedBranch(source))
|
|
239
|
+
return undefined;
|
|
182
240
|
const path = await worktreeForBranch(gitTop, source);
|
|
183
241
|
if (!path)
|
|
184
242
|
return undefined;
|
|
@@ -209,6 +267,7 @@ export function registerGitMergeTool(server) {
|
|
|
209
267
|
name: "git_merge",
|
|
210
268
|
description: "Merge one or more source branches into a destination. `auto` cascades " +
|
|
211
269
|
"fast-forward → rebase → merge-commit, preferring linear history. " +
|
|
270
|
+
"`auto`/`rebase` rewrite the source branch tip in place when rebasing (new SHAs), not only the destination. " +
|
|
212
271
|
"Refuses on dirty tree; stops on first conflict. Optional flags delete merged branches/worktrees " +
|
|
213
272
|
"(protected names skipped: main, master, dev, develop, stable, trunk, prod, production, release/*, hotfix/*).",
|
|
214
273
|
annotations: {
|
|
@@ -240,7 +299,7 @@ export function registerGitMergeTool(server) {
|
|
|
240
299
|
.boolean()
|
|
241
300
|
.optional()
|
|
242
301
|
.default(false)
|
|
243
|
-
.describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected tails skipped."),
|
|
302
|
+
.describe("Remove local worktrees on source branches after clean merge (`git worktree remove`). Protected source names and path tails skipped."),
|
|
244
303
|
}),
|
|
245
304
|
execute: async (args) => {
|
|
246
305
|
const pre = requireSingleRepo(server, args);
|
|
@@ -330,7 +389,6 @@ export function registerGitMergeTool(server) {
|
|
|
330
389
|
}),
|
|
331
390
|
...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
|
|
332
391
|
...spreadDefined("worktreeRemoved", r.worktreeRemoved),
|
|
333
|
-
...spreadDefined("skipReason", r.skipReason),
|
|
334
392
|
...spreadDefined("error", r.error),
|
|
335
393
|
...spreadDefined("detail", r.detail),
|
|
336
394
|
})),
|
|
@@ -34,17 +34,26 @@ export function registerGitParityTool(server) {
|
|
|
34
34
|
for (const workspaceRoot of pre.roots) {
|
|
35
35
|
const top = gitTopLevel(workspaceRoot);
|
|
36
36
|
if (!top) {
|
|
37
|
-
const
|
|
38
|
-
const err = jsonRespond(errPayload);
|
|
37
|
+
const errDesc = `not a git repository: ${workspaceRoot}`;
|
|
39
38
|
if (args.format === "json") {
|
|
40
39
|
results.push({
|
|
41
40
|
workspaceRoot: workspaceRoot,
|
|
42
41
|
status: "MISMATCH",
|
|
43
|
-
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error:
|
|
42
|
+
pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: errDesc }],
|
|
44
43
|
});
|
|
45
44
|
}
|
|
46
45
|
else {
|
|
47
|
-
mdParts.push(
|
|
46
|
+
mdParts.push([
|
|
47
|
+
"# Git HEAD parity",
|
|
48
|
+
"",
|
|
49
|
+
`status: MISMATCH`,
|
|
50
|
+
"",
|
|
51
|
+
`## — — error`,
|
|
52
|
+
"```text",
|
|
53
|
+
errDesc,
|
|
54
|
+
"```",
|
|
55
|
+
"",
|
|
56
|
+
].join("\n"));
|
|
48
57
|
}
|
|
49
58
|
continue;
|
|
50
59
|
}
|
|
@@ -45,7 +45,9 @@ export function registerGitPushTool(server) {
|
|
|
45
45
|
if (!branch) {
|
|
46
46
|
return jsonRespond({ error: ERROR_CODES.PUSH_DETACHED_HEAD });
|
|
47
47
|
}
|
|
48
|
-
|
|
48
|
+
// Reject leading `+` (git force-update refspec) even if the shared ref
|
|
49
|
+
// token helper still permits `+` mid-name — never force-push via argv.
|
|
50
|
+
if (!isSafeGitRefToken(branch) || branch.startsWith("+")) {
|
|
49
51
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: branch });
|
|
50
52
|
}
|
|
51
53
|
// --- Resolve remote ---
|
package/dist/server/git-refs.js
CHANGED
|
@@ -51,13 +51,14 @@ export function isProtectedBranch(name) {
|
|
|
51
51
|
* Conservative check for branch/ref names passed to git argv.
|
|
52
52
|
* Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
|
|
53
53
|
* sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
|
|
54
|
-
* and pathological tokens.
|
|
54
|
+
* leading `+` (git force-refspec), and pathological tokens.
|
|
55
55
|
*/
|
|
56
56
|
export function isSafeGitRefToken(s) {
|
|
57
57
|
const t = s.trim();
|
|
58
58
|
if (t.length === 0 || t.length > 256)
|
|
59
59
|
return false;
|
|
60
|
-
|
|
60
|
+
// Leading `-` is a git option; leading `+` is a force-update refspec.
|
|
61
|
+
if (t.startsWith("-") || t.startsWith("+"))
|
|
61
62
|
return false;
|
|
62
63
|
if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith("."))
|
|
63
64
|
return false;
|
|
@@ -70,32 +71,52 @@ export function isSafeGitRefToken(s) {
|
|
|
70
71
|
return /^[A-Za-z0-9_./+-]+$/.test(t);
|
|
71
72
|
}
|
|
72
73
|
/**
|
|
73
|
-
* Same as `
|
|
74
|
-
* by `git reset --soft HEAD~3
|
|
74
|
+
* Same as `isSafeGitCommitIsh` — trailing `~N` / `^N` ancestor notation used
|
|
75
|
+
* by `git reset --soft HEAD~3`, plus the full `isSafeGitRefToken` base guards
|
|
76
|
+
* (`..`, `.lock`, `//`, `@{`, leading `+/-`, etc.). Kept as a named export for
|
|
77
|
+
* call sites that only need ancestor-capable single refs (not ranges).
|
|
75
78
|
*/
|
|
76
79
|
export function isSafeGitAncestorRef(s) {
|
|
80
|
+
return isSafeGitCommitIsh(s);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Same as `isSafeGitRefToken`, but also accepts a trailing run of ancestor
|
|
84
|
+
* operators (`~N` / `^N`, mixable and order-independent, e.g. `HEAD~3`,
|
|
85
|
+
* `main^2`, `v1.0.0~2^1`) as used by commit-ish arguments to `git diff`,
|
|
86
|
+
* `git blame`, and `git show`. The base name (everything before the first
|
|
87
|
+
* ancestor operator) must itself pass `isSafeGitRefToken` — so all of that
|
|
88
|
+
* function's guards (leading `-`/`+`, `..`, `.lock` suffix, `//`, `@{`,
|
|
89
|
+
* whitespace, `:`, etc.) still apply. Ancestor operators are only permitted
|
|
90
|
+
* as a trailing suffix run, not embedded mid-name (e.g. `a~b` is rejected).
|
|
91
|
+
*/
|
|
92
|
+
export function isSafeGitCommitIsh(s) {
|
|
77
93
|
const t = s.trim();
|
|
78
94
|
if (t.length === 0 || t.length > 256)
|
|
79
95
|
return false;
|
|
80
|
-
|
|
96
|
+
const suffixMatch = /(?:[~^][0-9]*)+$/.exec(t);
|
|
97
|
+
const base = suffixMatch ? t.slice(0, suffixMatch.index) : t;
|
|
98
|
+
if (base.length === 0)
|
|
81
99
|
return false;
|
|
82
|
-
return
|
|
100
|
+
return isSafeGitRefToken(base);
|
|
83
101
|
}
|
|
84
102
|
/**
|
|
85
103
|
* Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
|
|
86
|
-
* used by `git log` / `git cherry-pick`. Splits once and
|
|
104
|
+
* used by `git log` / `git cherry-pick` / `git_diff_summary`. Splits once and
|
|
105
|
+
* validates each side (and the no-range single-ref fallthrough) with
|
|
106
|
+
* `isSafeGitCommitIsh`, so ancestor notation (`HEAD~3`, `main^2`) is accepted
|
|
107
|
+
* on either endpoint, e.g. `HEAD~3..HEAD` or `main...feature^2`.
|
|
87
108
|
*/
|
|
88
109
|
export function isSafeGitRangeToken(s) {
|
|
89
110
|
const t = s.trim();
|
|
90
111
|
if (t.includes("...")) {
|
|
91
112
|
const parts = t.split("...");
|
|
92
|
-
return parts.length === 2 && parts.every((p) =>
|
|
113
|
+
return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
|
|
93
114
|
}
|
|
94
115
|
if (t.includes("..")) {
|
|
95
116
|
const parts = t.split("..");
|
|
96
|
-
return parts.length === 2 && parts.every((p) =>
|
|
117
|
+
return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
|
|
97
118
|
}
|
|
98
|
-
return
|
|
119
|
+
return isSafeGitCommitIsh(t);
|
|
99
120
|
}
|
|
100
121
|
// ---------------------------------------------------------------------------
|
|
101
122
|
// Async helpers
|
|
@@ -218,11 +239,16 @@ export async function commitListBetween(cwd, excludeRef, includeRef) {
|
|
|
218
239
|
.map((l) => l.trim())
|
|
219
240
|
.filter((l) => l.length > 0);
|
|
220
241
|
}
|
|
221
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* Parse `git worktree list --porcelain` into structured entries.
|
|
244
|
+
* Returns `{ ok: false, detail }` when git fails — callers must not treat
|
|
245
|
+
* failure as an empty worktree list.
|
|
246
|
+
*/
|
|
222
247
|
export async function listWorktrees(cwd) {
|
|
223
248
|
const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
|
|
224
|
-
if (!r.ok)
|
|
225
|
-
return
|
|
249
|
+
if (!r.ok) {
|
|
250
|
+
return { ok: false, detail: (r.stderr || r.stdout).trim() || "git worktree list failed" };
|
|
251
|
+
}
|
|
226
252
|
const out = [];
|
|
227
253
|
let cur = {};
|
|
228
254
|
for (const line of r.stdout.split("\n")) {
|
|
@@ -245,12 +271,17 @@ export async function listWorktrees(cwd) {
|
|
|
245
271
|
}
|
|
246
272
|
if (cur.path)
|
|
247
273
|
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
|
|
248
|
-
return out;
|
|
274
|
+
return { ok: true, worktrees: out };
|
|
249
275
|
}
|
|
250
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* Path of the worktree currently checked out on `branch`; `null` if none
|
|
278
|
+
* or if listing worktrees failed (fail closed for destructive callers).
|
|
279
|
+
*/
|
|
251
280
|
export async function worktreeForBranch(cwd, branch) {
|
|
252
|
-
const
|
|
253
|
-
|
|
281
|
+
const listed = await listWorktrees(cwd);
|
|
282
|
+
if (!listed.ok)
|
|
283
|
+
return null;
|
|
284
|
+
const hit = listed.worktrees.find((t) => t.branch === branch);
|
|
254
285
|
return hit?.path ?? null;
|
|
255
286
|
}
|
|
256
287
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { conflictPaths, isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** SHA of REVERT_HEAD (the commit currently mid-revert), or undefined once resolved/absent. */
|
|
12
|
+
async function revertHead(gitTop) {
|
|
13
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "REVERT_HEAD"]);
|
|
14
|
+
if (!r.ok)
|
|
15
|
+
return undefined;
|
|
16
|
+
const sha = r.stdout.trim();
|
|
17
|
+
return sha === "" ? undefined : sha;
|
|
18
|
+
}
|
|
19
|
+
/** Result of attempting `git revert --abort` — callers must check `ok` before claiming a clean abort. */
|
|
20
|
+
export async function abortRevert(gitTop) {
|
|
21
|
+
const r = await spawnGitAsync(gitTop, ["revert", "--abort"]);
|
|
22
|
+
if (r.ok)
|
|
23
|
+
return { ok: true };
|
|
24
|
+
const detail = (r.stderr || r.stdout).trim();
|
|
25
|
+
return detail === "" ? { ok: false } : { ok: false, detail };
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Tool registration
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export function registerGitRevertTool(server) {
|
|
31
|
+
server.addTool({
|
|
32
|
+
name: "git_revert",
|
|
33
|
+
description: "`git revert`: creates new commit(s) that undo the changes introduced by one or more source " +
|
|
34
|
+
"commits. Unlike `git_reset_soft`, this never rewrites history — safe on shared/pushed branches. " +
|
|
35
|
+
"Refuses on a dirty tree. On conflict, aborts and returns the tree clean. `noCommit` stages the " +
|
|
36
|
+
"revert(s) without committing (working tree intentionally left staged in that case). `mainline` " +
|
|
37
|
+
"selects the parent to diff against when reverting a merge commit.",
|
|
38
|
+
annotations: {
|
|
39
|
+
readOnlyHint: false,
|
|
40
|
+
destructiveHint: false,
|
|
41
|
+
idempotentHint: false,
|
|
42
|
+
},
|
|
43
|
+
parameters: WorkspacePickSchema.extend({
|
|
44
|
+
sources: z
|
|
45
|
+
.array(z.string().min(1))
|
|
46
|
+
.min(1)
|
|
47
|
+
.max(20)
|
|
48
|
+
.describe("Commits to revert, applied in order: SHA, branch/tag name, or ancestor notation (`HEAD~1`)."),
|
|
49
|
+
noCommit: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.optional()
|
|
52
|
+
.default(false)
|
|
53
|
+
.describe("Pass `--no-commit`: apply the revert(s) to the index/working tree without committing " +
|
|
54
|
+
"(changes are left staged instead)."),
|
|
55
|
+
mainline: z
|
|
56
|
+
.number()
|
|
57
|
+
.int()
|
|
58
|
+
.min(1)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("Parent number (`-m N`) to diff against — required when reverting a merge commit."),
|
|
61
|
+
}),
|
|
62
|
+
execute: async (args) => {
|
|
63
|
+
const pre = requireSingleRepo(server, args);
|
|
64
|
+
if (!pre.ok)
|
|
65
|
+
return jsonRespond(pre.error);
|
|
66
|
+
const { gitTop } = pre;
|
|
67
|
+
// --- Validate sources ---
|
|
68
|
+
for (const raw of args.sources) {
|
|
69
|
+
if (!isSafeGitAncestorRef(raw)) {
|
|
70
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, source: raw });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// --- Refuse dirty tree ---
|
|
74
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
75
|
+
return jsonRespond({
|
|
76
|
+
error: ERROR_CODES.WORKING_TREE_DIRTY,
|
|
77
|
+
detail: "git_revert requires a clean working tree. Commit or stash pending changes first.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const preHeadProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
81
|
+
const preHead = preHeadProbe.ok ? preHeadProbe.stdout.trim() : "";
|
|
82
|
+
const revertArgs = ["revert"];
|
|
83
|
+
if (args.noCommit)
|
|
84
|
+
revertArgs.push("--no-commit");
|
|
85
|
+
if (args.mainline !== undefined)
|
|
86
|
+
revertArgs.push("-m", String(args.mainline));
|
|
87
|
+
revertArgs.push(...args.sources);
|
|
88
|
+
const r = await spawnGitAsync(gitTop, revertArgs);
|
|
89
|
+
if (!r.ok) {
|
|
90
|
+
const failedSha = await revertHead(gitTop);
|
|
91
|
+
const paths = await conflictPaths(gitTop);
|
|
92
|
+
const abortResult = await abortRevert(gitTop);
|
|
93
|
+
return jsonRespond({
|
|
94
|
+
ok: false,
|
|
95
|
+
aborted: abortResult.ok,
|
|
96
|
+
...spreadDefined("commit", failedSha),
|
|
97
|
+
conflicts: paths,
|
|
98
|
+
...spreadDefined("detail", (r.stderr || r.stdout).trim() || undefined),
|
|
99
|
+
...spreadWhen(!abortResult.ok, {
|
|
100
|
+
error: ERROR_CODES.REVERT_ABORT_FAILED,
|
|
101
|
+
...spreadDefined("abortDetail", abortResult.detail),
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// --- No-commit: revert(s) staged, no new commits ---
|
|
106
|
+
if (args.noCommit) {
|
|
107
|
+
const stagedResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
108
|
+
const stagedFiles = stagedResult.ok
|
|
109
|
+
? stagedResult.stdout
|
|
110
|
+
.split("\n")
|
|
111
|
+
.map((l) => l.trim())
|
|
112
|
+
.filter((l) => l.length > 0)
|
|
113
|
+
: [];
|
|
114
|
+
if (args.format === "json") {
|
|
115
|
+
return jsonRespond({
|
|
116
|
+
ok: true,
|
|
117
|
+
staged: true,
|
|
118
|
+
sources: args.sources,
|
|
119
|
+
stagedCount: stagedFiles.length,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return [
|
|
123
|
+
"# Revert (staged, not committed)",
|
|
124
|
+
`${args.sources.length} source(s) → ${stagedFiles.length} file(s) staged`,
|
|
125
|
+
...args.sources.map((s) => `- ${s}`),
|
|
126
|
+
].join("\n");
|
|
127
|
+
}
|
|
128
|
+
// --- Committed: one new commit per source, oldest-first ---
|
|
129
|
+
const newCommitsResult = await spawnGitAsync(gitTop, [
|
|
130
|
+
"rev-list",
|
|
131
|
+
"--reverse",
|
|
132
|
+
`${preHead}..HEAD`,
|
|
133
|
+
]);
|
|
134
|
+
const newCommits = newCommitsResult.ok
|
|
135
|
+
? newCommitsResult.stdout
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((l) => l.trim())
|
|
138
|
+
.filter((l) => l.length > 0)
|
|
139
|
+
: [];
|
|
140
|
+
const reverted = [];
|
|
141
|
+
for (let i = 0; i < args.sources.length; i++) {
|
|
142
|
+
const source = args.sources[i];
|
|
143
|
+
const sha = newCommits[i];
|
|
144
|
+
if (source !== undefined && sha !== undefined)
|
|
145
|
+
reverted.push({ source, sha });
|
|
146
|
+
}
|
|
147
|
+
if (args.format === "json") {
|
|
148
|
+
return jsonRespond({
|
|
149
|
+
ok: true,
|
|
150
|
+
reverted,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const lines = [`# Revert: ${reverted.length} commit(s)`, ""];
|
|
154
|
+
for (const item of reverted) {
|
|
155
|
+
lines.push(`- ${item.source} → ${item.sha.slice(0, 7)}`);
|
|
156
|
+
}
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -2,7 +2,7 @@ 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 {
|
|
5
|
+
import { isSafeGitCommitIsh } from "./git-refs.js";
|
|
6
6
|
import { jsonRespond } from "./json.js";
|
|
7
7
|
import { requireSingleRepo } from "./roots.js";
|
|
8
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -39,6 +39,7 @@ async function runGitShow(opts) {
|
|
|
39
39
|
if (!r.ok) {
|
|
40
40
|
return {
|
|
41
41
|
error: ERROR_CODES.GIT_SHOW_FAILED,
|
|
42
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
// Parse the output. For a commit, git show outputs:
|
|
@@ -157,7 +158,10 @@ export function registerGitShowTool(server) {
|
|
|
157
158
|
readOnlyHint: true,
|
|
158
159
|
},
|
|
159
160
|
parameters: WorkspacePickSchema.extend({
|
|
160
|
-
ref: z
|
|
161
|
+
ref: z
|
|
162
|
+
.string()
|
|
163
|
+
.min(1)
|
|
164
|
+
.describe('Commit reference (SHA, branch, tag, or ancestor notation like "HEAD~3", "main^2").'),
|
|
161
165
|
path: z
|
|
162
166
|
.string()
|
|
163
167
|
.optional()
|
|
@@ -176,7 +180,7 @@ export function registerGitShowTool(server) {
|
|
|
176
180
|
if (!pre.ok)
|
|
177
181
|
return jsonRespond(pre.error);
|
|
178
182
|
const top = pre.gitTop;
|
|
179
|
-
if (!
|
|
183
|
+
if (!isSafeGitCommitIsh(args.ref)) {
|
|
180
184
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
181
185
|
}
|
|
182
186
|
if (args.path !== undefined) {
|