@rethunk/mcp-multi-root-git 3.1.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +21 -15
  2. package/CHANGELOG.md +114 -39
  3. package/HUMANS.md +20 -39
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +187 -46
  7. package/dist/server/error-codes.js +25 -7
  8. package/dist/server/git-blame-tool.js +6 -3
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +220 -11
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +36 -25
  13. package/dist/server/git-diff-tool.js +55 -27
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +26 -6
  16. package/dist/server/git-log-tool.js +35 -4
  17. package/dist/server/git-merge-tool.js +70 -12
  18. package/dist/server/git-parity-tool.js +13 -4
  19. package/dist/server/git-push-tool.js +3 -1
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-revert-tool.js +160 -0
  22. package/dist/server/git-show-tool.js +7 -3
  23. package/dist/server/git-stash-tool.js +110 -67
  24. package/dist/server/git-tag-tool.js +7 -6
  25. package/dist/server/git-worktree-tool.js +65 -53
  26. package/dist/server/git.js +116 -24
  27. package/dist/server/inventory.js +90 -32
  28. package/dist/server/presets-resource.js +37 -20
  29. package/dist/server/presets.js +87 -15
  30. package/dist/server/roots.js +13 -1
  31. package/dist/server/schemas.js +9 -2
  32. package/dist/server/test-harness.js +11 -4
  33. package/dist/server/tool-parameter-schemas.js +44 -55
  34. package/dist/server/tools.js +36 -17
  35. package/dist/server.js +1 -1
  36. package/docs/install.md +52 -5
  37. package/docs/mcp-tools.md +385 -178
  38. package/package.json +6 -6
  39. package/schemas/batch_commit.json +2 -2
  40. package/schemas/git_blame.json +1 -1
  41. package/schemas/git_branch.json +54 -0
  42. package/schemas/git_cherry_pick.json +12 -2
  43. package/schemas/git_cherry_pick_continue.json +34 -0
  44. package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
  45. package/schemas/git_diff.json +11 -3
  46. package/schemas/git_grep.json +85 -0
  47. package/schemas/git_inventory.json +19 -5
  48. package/schemas/git_log.json +7 -6
  49. package/schemas/git_merge.json +2 -2
  50. package/schemas/git_parity.json +0 -5
  51. package/schemas/git_revert.json +47 -0
  52. package/schemas/git_show.json +1 -1
  53. package/schemas/git_stash_push.json +47 -0
  54. package/schemas/git_status.json +0 -5
  55. package/schemas/git_worktree_add.json +1 -1
  56. package/schemas/git_worktree_remove.json +1 -1
  57. package/schemas/index.json +28 -23
  58. package/schemas/list_presets.json +0 -5
  59. package/tool-parameters.schema.json +326 -167
  60. package/dist/server/git-branch-list-tool.js +0 -132
  61. package/dist/server/git-fetch-tool.js +0 -257
  62. package/dist/server/git-reflog-tool.js +0 -120
  63. package/schemas/git_branch_list.json +0 -30
  64. package/schemas/git_fetch.json +0 -46
  65. package/schemas/git_stash_list.json +0 -24
  66. package/schemas/git_worktree_list.json +0 -24
@@ -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 \x01 before \n leaves one empty last field (ignored).
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
- async function abortMerge(gitTop) {
23
- await spawnGitAsync(gitTop, ["merge", "--abort"]);
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
- async function abortRebase(gitTop) {
26
- await spawnGitAsync(gitTop, ["rebase", "--abort"]);
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 state is cleaned (merge/rebase aborted, HEAD restored to `into`).
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
- await abortMerge(gitTop);
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 errPayload = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
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: err }],
42
+ pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: errDesc }],
44
43
  });
45
44
  }
46
45
  else {
47
- mdParts.push(err);
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
- if (!isSafeGitRefToken(branch)) {
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 ---
@@ -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
- if (t.startsWith("-"))
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 `isSafeGitRefToken` but also allows `~N` / `^N` ancestor notation used
74
- * by `git reset --soft HEAD~3`. Permits `~` and `^` suffix characters.
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
- if (t.startsWith("-"))
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 /^[A-Za-z0-9_./+~^-]+$/.test(t);
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 validates each side.
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) => isSafeGitRefToken(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) => isSafeGitRefToken(p));
117
+ return parts.length === 2 && parts.every((p) => isSafeGitCommitIsh(p));
97
118
  }
98
- return isSafeGitRefToken(t);
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
- /** Parse `git worktree list --porcelain` into structured entries. */
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
- /** Path of the worktree currently checked out on `branch`; `null` if none. */
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 trees = await listWorktrees(cwd);
253
- const hit = trees.find((t) => t.branch === branch);
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 { isSafeGitAncestorRef } from "./git-refs.js";
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.string().min(1).describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
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 (!isSafeGitAncestorRef(args.ref)) {
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) {