@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,6 +1,8 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
2
4
  import { z } from "zod";
3
- import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
5
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
4
6
  import { ERROR_CODES } from "./error-codes.js";
5
7
  import { spawnGitAsync } from "./git.js";
6
8
  import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
@@ -20,6 +22,9 @@ const FileEntrySchema = z.union([
20
22
  .min(1)
21
23
  .max(1000000)
22
24
  .describe("End line number (1-indexed, inclusive)."),
25
+ })
26
+ .refine((l) => l.from <= l.to, {
27
+ message: "lines.from must be <= lines.to",
23
28
  })
24
29
  .describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
25
30
  }),
@@ -30,7 +35,10 @@ const CommitEntrySchema = z.object({
30
35
  .array(FileEntrySchema)
31
36
  .min(1)
32
37
  .describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
33
- "Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file."),
38
+ "Each path is staged individually (`git add` / `git apply --cached` / `git rm --cached`); " +
39
+ "on mid-entry stage failure, already-staged paths for that entry are unstaged. " +
40
+ "Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file. " +
41
+ "Rejects `.`, the repo root, and directory pathspecs."),
34
42
  });
35
43
  const PushModeSchema = z
36
44
  .enum(["never", "after"])
@@ -42,7 +50,30 @@ const DryRunSchema = z
42
50
  .boolean()
43
51
  .optional()
44
52
  .default(false)
45
- .describe("Stage files and return a preview without writing commits; unstages afterwards. Response is marked DRY RUN.");
53
+ .describe("Stage files and return a preview without writing commits; restores the index afterwards. Response is marked DRY RUN.");
54
+ /**
55
+ * True when `path` would stage the whole tree or a directory (not a single file).
56
+ * Rejects `.`, `./`, paths resolving to gitTop, trailing-slash directory forms,
57
+ * and on-disk directories.
58
+ */
59
+ function isWholeTreeOrDirectoryPathspec(path, gitTop) {
60
+ const t = path.trim();
61
+ if (t === "" || t === "." || t === "./")
62
+ return true;
63
+ if (t.endsWith("/") || t.endsWith("/.") || t.endsWith("/.."))
64
+ return true;
65
+ const abs = resolvePathForRepo(path, gitTop);
66
+ if (resolve(abs) === resolve(gitTop))
67
+ return true;
68
+ try {
69
+ if (existsSync(abs) && statSync(abs).isDirectory())
70
+ return true;
71
+ }
72
+ catch {
73
+ // ignore stat errors — treat as non-directory
74
+ }
75
+ return false;
76
+ }
46
77
  /**
47
78
  * Parses a unified diff to extract hunks that overlap with a given line range.
48
79
  * Returns a partial patch containing only the overlapping hunks, including header lines.
@@ -159,23 +190,42 @@ async function stageFile(gitTop, filePath, lines) {
159
190
  error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
160
191
  };
161
192
  }
162
- // Line range case: extract overlapping hunks and apply patch
163
- const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
164
- if (!diffResult.ok) {
165
- return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
193
+ // Line range case: extract overlapping hunks and apply patch.
194
+ // Tracked files: unstaged worktree vs index (`git diff -- path`).
195
+ // Untracked files: synthesize a new-file diff via `--no-index` (exit 1 with
196
+ // differences is expected treat non-empty stdout as success).
197
+ const tracked = await spawnGitAsync(gitTop, ["ls-files", "--error-unmatch", "--", filePath]);
198
+ let diffStdout;
199
+ if (tracked.ok) {
200
+ const diffResult = await spawnGitAsync(gitTop, ["diff", "--", filePath]);
201
+ if (!diffResult.ok && !diffResult.stdout.trim()) {
202
+ return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
203
+ }
204
+ diffStdout = diffResult.stdout;
205
+ }
206
+ else {
207
+ const diffResult = await spawnGitAsync(gitTop, [
208
+ "diff",
209
+ "--no-index",
210
+ "--",
211
+ "/dev/null",
212
+ filePath,
213
+ ]);
214
+ // --no-index exits 1 when files differ; accept stdout as the patch body.
215
+ if (!diffResult.stdout.trim()) {
216
+ return {
217
+ ok: false,
218
+ error: (diffResult.stderr || diffResult.stdout || "No hunks found in line range").trim(),
219
+ };
220
+ }
221
+ diffStdout = diffResult.stdout;
166
222
  }
167
- const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
223
+ const partialPatch = extractOverlappingHunks(diffStdout, lines.from, lines.to);
168
224
  if (!partialPatch) {
169
225
  return { ok: false, error: "No hunks found in line range" };
170
226
  }
171
- // Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
172
- const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
173
- if (!gitDirResult.ok) {
174
- return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
175
- }
176
- const gitDir = gitDirResult.stdout.trim();
177
- // Write partial patch to temp file in the git dir and apply it to the index
178
- const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
227
+ // Write partial patch to a temp file outside the git dir (avoids orphan files in .git)
228
+ const tempPatchFile = join(tmpdir(), `.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
179
229
  const { writeFileSync, unlinkSync } = await import("node:fs");
180
230
  writeFileSync(tempPatchFile, partialPatch, "utf8");
181
231
  const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
@@ -226,7 +276,11 @@ export function registerBatchCommitTool(server) {
226
276
  server.addTool({
227
277
  name: "batch_commit",
228
278
  description: "Create multiple sequential git commits in one call. " +
229
- "Each entry stages its files then commits. Stops on first failure. " +
279
+ "Each entry stages its files then commits. Unrelated pre-staged index paths " +
280
+ "are temporarily unstaged around the commit so they are not included " +
281
+ "(hunk-level staging is preserved — pathspec commit mode is not used). " +
282
+ "Stops on first failure; mid-entry stage failures unstage that entry's " +
283
+ "already-staged paths. " +
230
284
  'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
231
285
  annotations: {
232
286
  readOnlyHint: false,
@@ -248,18 +302,18 @@ export function registerBatchCommitTool(server) {
248
302
  return jsonRespond(pre.error);
249
303
  const gitTop = pre.gitTop;
250
304
  const results = [];
251
- const stagedFilesForCleanup = new Set();
252
- // Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
253
- // doesn't unstage files the caller had staged before invoking.
254
- let preStagedPaths = new Set();
305
+ // Snapshot the full index before dry-run so cleanup restores pre-staged
306
+ // paths even when dryRun stages additional hunks onto the same paths.
307
+ let indexTreeBefore;
255
308
  if (args.dryRun) {
256
- const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
257
- if (snapResult.ok) {
258
- preStagedPaths = new Set(snapResult.stdout
259
- .split("\n")
260
- .map((s) => s.trim())
261
- .filter(Boolean));
309
+ const wt = await spawnGitAsync(gitTop, ["write-tree"]);
310
+ if (!wt.ok) {
311
+ return jsonRespond({
312
+ error: ERROR_CODES.COMMIT_FAILED,
313
+ detail: (wt.stderr || wt.stdout).trim() || "failed to snapshot index before dryRun",
314
+ });
262
315
  }
316
+ indexTreeBefore = wt.stdout.trim();
263
317
  }
264
318
  for (let i = 0; i < args.commits.length; i++) {
265
319
  const entry = args.commits[i];
@@ -268,21 +322,38 @@ export function registerBatchCommitTool(server) {
268
322
  // Normalize file entries to { path, lines? } format
269
323
  const fileEntries = [];
270
324
  const filePaths = [];
325
+ let invalidLineRange = false;
271
326
  for (const fileEntry of entry.files) {
272
327
  if (typeof fileEntry === "string") {
273
328
  fileEntries.push({ path: fileEntry });
274
329
  filePaths.push(fileEntry);
275
330
  }
276
331
  else {
332
+ if (fileEntry.lines.from > fileEntry.lines.to) {
333
+ invalidLineRange = true;
334
+ filePaths.push(fileEntry.path);
335
+ break;
336
+ }
277
337
  fileEntries.push(fileEntry);
278
338
  filePaths.push(fileEntry.path);
279
339
  }
280
340
  }
341
+ if (invalidLineRange) {
342
+ results.push({
343
+ index: i,
344
+ ok: false,
345
+ message: entry.message,
346
+ files: filePaths,
347
+ error: ERROR_CODES.INVALID_LINE_RANGE,
348
+ detail: "lines.from must be <= lines.to",
349
+ });
350
+ break;
351
+ }
281
352
  // --- Validate all paths are under the git toplevel ---
282
353
  const escapedPaths = [];
283
354
  for (const path of filePaths) {
284
355
  const abs = resolvePathForRepo(path, gitTop);
285
- if (!isStrictlyUnderGitTop(abs, gitTop)) {
356
+ if (!assertRelativePathUnderTop(path, abs, gitTop)) {
286
357
  escapedPaths.push(path);
287
358
  }
288
359
  }
@@ -297,9 +368,23 @@ export function registerBatchCommitTool(server) {
297
368
  });
298
369
  break;
299
370
  }
371
+ // --- Reject `.` / repo-root / directory pathspecs ---
372
+ const invalidPaths = filePaths.filter((p) => isWholeTreeOrDirectoryPathspec(p, gitTop));
373
+ if (invalidPaths.length > 0) {
374
+ results.push({
375
+ index: i,
376
+ ok: false,
377
+ message: entry.message,
378
+ files: filePaths,
379
+ error: ERROR_CODES.INVALID_PATHS,
380
+ detail: `directory or whole-tree pathspec rejected: ${invalidPaths.join(", ")}`,
381
+ });
382
+ break;
383
+ }
300
384
  // --- Stage files (with optional line ranges) ---
301
385
  let stagingFailed = false;
302
386
  let stagingError = "";
387
+ const stagedSoFar = [];
303
388
  for (const fileEntry of fileEntries) {
304
389
  const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
305
390
  if (!stageResult.ok) {
@@ -307,16 +392,14 @@ export function registerBatchCommitTool(server) {
307
392
  stagingError = stageResult.error || "Unknown error";
308
393
  break;
309
394
  }
310
- // Track for dry-run cleanup as soon as a file stages successfully — a later
311
- // file in this same commit entry may still fail, and anything already staged
312
- // must still be unstaged so a failed dry run never leaves index state behind.
313
- // Excludes paths that were already staged before this call (so pre-existing
314
- // staged state survives).
315
- if (args.dryRun && !preStagedPaths.has(fileEntry.path)) {
316
- stagedFilesForCleanup.add(fileEntry.path);
317
- }
395
+ stagedSoFar.push(fileEntry.path);
318
396
  }
319
397
  if (stagingFailed) {
398
+ // Unstage anything this entry already staged (live + dryRun).
399
+ // dryRun final cleanup also restores via read-tree when available.
400
+ if (stagedSoFar.length > 0) {
401
+ await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...stagedSoFar]);
402
+ }
320
403
  results.push({
321
404
  index: i,
322
405
  ok: false,
@@ -328,10 +411,16 @@ export function registerBatchCommitTool(server) {
328
411
  });
329
412
  break;
330
413
  }
331
- // --- Dry-run mode: collect preview and unstage ---
414
+ // --- Dry-run mode: collect preview scoped to this entry's paths ---
332
415
  if (args.dryRun) {
333
- // Get diff stat for this staged entry
334
- const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
416
+ // Path-scoped stat so multi-entry previews do not accumulate prior entries.
417
+ const diffStatResult = await spawnGitAsync(gitTop, [
418
+ "diff",
419
+ "--staged",
420
+ "--stat",
421
+ "--",
422
+ ...filePaths,
423
+ ]);
335
424
  const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
336
425
  results.push({
337
426
  index: i,
@@ -341,11 +430,55 @@ export function registerBatchCommitTool(server) {
341
430
  staged: filePaths,
342
431
  ...spreadDefined("diffStat", diffStat || undefined),
343
432
  });
344
- continue; // Skip actual commit in dry-run mode
433
+ // Unstage this entry before the next so the next entry's staging starts clean
434
+ // relative to the snapshot (final read-tree still restores pre-call index).
435
+ await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...filePaths]);
436
+ continue;
437
+ }
438
+ // --- Commit: isolate entry files from unrelated pre-staged index paths ---
439
+ // `git commit -- <paths>` uses --only (worktree) mode and would squash
440
+ // hunk-level staging. Instead: snapshot index, temporarily unstage
441
+ // unrelated staged paths, commit from the index, then restore them.
442
+ const stagedNamesResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
443
+ const stagedNames = stagedNamesResult.ok
444
+ ? stagedNamesResult.stdout
445
+ .split("\n")
446
+ .map((s) => s.trim())
447
+ .filter(Boolean)
448
+ : [];
449
+ const entryPathSet = new Set(filePaths);
450
+ const unrelatedStaged = stagedNames.filter((p) => !entryPathSet.has(p));
451
+ let indexSnap;
452
+ if (unrelatedStaged.length > 0) {
453
+ const wt = await spawnGitAsync(gitTop, ["write-tree"]);
454
+ if (!wt.ok) {
455
+ results.push({
456
+ index: i,
457
+ ok: false,
458
+ message: entry.message,
459
+ files: filePaths,
460
+ error: ERROR_CODES.COMMIT_FAILED,
461
+ detail: (wt.stderr || wt.stdout).trim() ||
462
+ "failed to snapshot index for pre-staged path isolation",
463
+ });
464
+ break;
465
+ }
466
+ indexSnap = wt.stdout.trim();
467
+ await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...unrelatedStaged]);
345
468
  }
346
- // --- Commit ---
347
469
  const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
348
470
  if (!commitResult.ok) {
471
+ // Restore unrelated staged paths even on failure so we don't leave the
472
+ // index worse than when we entered this entry.
473
+ if (indexSnap && unrelatedStaged.length > 0) {
474
+ await spawnGitAsync(gitTop, [
475
+ "restore",
476
+ `--source=${indexSnap}`,
477
+ "--staged",
478
+ "--",
479
+ ...unrelatedStaged,
480
+ ]);
481
+ }
349
482
  const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
350
483
  results.push({
351
484
  index: i,
@@ -358,6 +491,15 @@ export function registerBatchCommitTool(server) {
358
491
  });
359
492
  break;
360
493
  }
494
+ if (indexSnap && unrelatedStaged.length > 0) {
495
+ await spawnGitAsync(gitTop, [
496
+ "restore",
497
+ `--source=${indexSnap}`,
498
+ "--staged",
499
+ "--",
500
+ ...unrelatedStaged,
501
+ ]);
502
+ }
361
503
  // --- Extract SHA from commit output ---
362
504
  const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
363
505
  const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
@@ -370,10 +512,9 @@ export function registerBatchCommitTool(server) {
370
512
  ...spreadDefined("output", gitOutput || undefined),
371
513
  });
372
514
  }
373
- // --- In dry-run mode, unstage all files ---
374
- if (args.dryRun && stagedFilesForCleanup.size > 0) {
375
- const filesToReset = Array.from(stagedFilesForCleanup);
376
- await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
515
+ // --- In dry-run mode, restore the full pre-call index ---
516
+ if (args.dryRun && indexTreeBefore) {
517
+ await spawnGitAsync(gitTop, ["read-tree", indexTreeBefore]);
377
518
  }
378
519
  const allOk = results.length === args.commits.length && results.every((r) => r.ok);
379
520
  // --- Optional push after all commits succeed (not in dry-run mode) ---
@@ -29,7 +29,13 @@ export const ERROR_CODES = {
29
29
  INVALID_PATHS: "invalid_paths",
30
30
  INVALID_REMOTE_OR_BRANCH: "invalid_remote_or_branch",
31
31
  INVALID_SINCE: "invalid_since",
32
+ /** Canonical path-escape wire string (grep/diff/blame/show/stash). */
32
33
  PATH_ESCAPES_REPO: "path_escapes_repo",
34
+ /**
35
+ * Legacy path-escape alias used by `batch_commit`. Same failure class as
36
+ * `PATH_ESCAPES_REPO`; clients should treat both as equivalent until a
37
+ * future contract bump unifies them (Worker P owns batch_commit migration).
38
+ */
33
39
  PATH_ESCAPES_REPOSITORY: "path_escapes_repository",
34
40
  UNSAFE_RANGE_TOKEN: "unsafe_range_token",
35
41
  UNSAFE_REF_TOKEN: "unsafe_ref_token",
@@ -40,8 +46,11 @@ export const ERROR_CODES = {
40
46
  ONTO_DETACHED_HEAD: "onto_detached_head",
41
47
  PROTECTED_BRANCH: "protected_branch",
42
48
  WORKING_TREE_DIRTY: "working_tree_dirty",
43
- // Branch list
44
- BRANCH_LIST_FAILED: "branch_list_failed",
49
+ // Branch create/delete/rename
50
+ BRANCH_CREATE_FAILED: "branch_create_failed",
51
+ BRANCH_DELETE_FAILED: "branch_delete_failed",
52
+ BRANCH_RENAME_FAILED: "branch_rename_failed",
53
+ MISSING_NEW_NAME: "missing_new_name",
45
54
  // Commit / stage
46
55
  COMMIT_FAILED: "commit_failed",
47
56
  STAGE_FAILED: "stage_failed",
@@ -49,16 +58,15 @@ export const ERROR_CODES = {
49
58
  GIT_DIFF_FAILED: "git_diff_failed",
50
59
  // Log
51
60
  GIT_LOG_FAILED: "git_log_failed",
61
+ // Grep (pickaxe)
62
+ GIT_GREP_FAILED: "git_grep_failed",
52
63
  // Show
53
64
  GIT_SHOW_FAILED: "git_show_failed",
54
65
  // Blame
55
66
  GIT_BLAME_FAILED: "git_blame_failed",
56
- // Reflog
57
- REFLOG_FAILED: "reflog_failed",
58
67
  // Stash
59
- STASH_LIST_FAILED: "stash_list_failed",
60
- // Fetch
61
- // (uses UNSAFE_REMOTE_TOKEN and UNSAFE_REF_TOKEN)
68
+ STASH_APPLY_FAILED: "stash_apply_failed",
69
+ STASH_PUSH_FAILED: "stash_push_failed",
62
70
  // Push
63
71
  PUSH_DETACHED_HEAD: "push_detached_head",
64
72
  PUSH_FAILED: "push_failed",
@@ -66,16 +74,26 @@ export const ERROR_CODES = {
66
74
  // Merge
67
75
  CANNOT_FAST_FORWARD: "cannot_fast_forward",
68
76
  DESTINATION_NOT_FOUND: "destination_not_found",
77
+ MERGE_ABORT_FAILED: "merge_abort_failed",
69
78
  MERGE_BASE_FAILED: "merge_base_failed",
70
79
  MERGE_CONFLICTS: "merge_conflicts",
71
80
  MERGE_FAILED: "merge_failed",
81
+ REBASE_ABORT_FAILED: "rebase_abort_failed",
72
82
  REBASE_CONFLICTS: "rebase_conflicts",
73
83
  SOURCE_NOT_FOUND: "source_not_found",
74
84
  // Cherry-pick
75
85
  CHECKOUT_FAILED: "checkout_failed",
86
+ CHERRY_PICK_ABORT_FAILED: "cherry_pick_abort_failed",
87
+ CHERRY_PICK_CONTINUE_FAILED: "cherry_pick_continue_failed",
88
+ CHERRY_PICK_IN_PROGRESS: "cherry_pick_in_progress",
89
+ CHERRY_PICK_TOO_MANY_COMMITS: "cherry_pick_too_many_commits",
90
+ CHERRY_PICK_UNRESOLVED_PATHS: "cherry_pick_unresolved_paths",
91
+ NO_CHERRY_PICK_IN_PROGRESS: "no_cherry_pick_in_progress",
76
92
  RANGE_RESOLUTION_FAILED: "range_resolution_failed",
77
93
  // Reset
78
94
  RESET_FAILED: "reset_failed",
95
+ // Revert
96
+ REVERT_ABORT_FAILED: "revert_abort_failed",
79
97
  // Tag
80
98
  REF_NOT_FOUND: "ref_not_found",
81
99
  TAG_CREATE_FAILED: "tag_create_failed",
@@ -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 { isSafeGitRefToken } from "./git-refs.js";
5
+ import { isSafeGitCommitIsh } from "./git-refs.js";
6
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
7
7
  import { requireSingleRepo } from "./roots.js";
8
8
  import { WorkspacePickSchema } from "./schemas.js";
@@ -182,7 +182,10 @@ export function registerGitBlameTool(server) {
182
182
  },
183
183
  parameters: WorkspacePickSchema.extend({
184
184
  path: z.string().min(1).describe("Repo-relative path to the file to blame."),
185
- ref: z.string().optional().describe("Optional commit-ish (SHA, branch, tag) to blame at."),
185
+ ref: z
186
+ .string()
187
+ .optional()
188
+ .describe('Optional commit-ish (SHA, branch, tag) to blame at. Ancestor notation is accepted (e.g. "HEAD~3", "main^2").'),
186
189
  startLine: z
187
190
  .number()
188
191
  .int()
@@ -218,7 +221,7 @@ export function registerGitBlameTool(server) {
218
221
  }
219
222
  // Ref validation
220
223
  if (args.ref !== undefined) {
221
- if (!isSafeGitRefToken(args.ref)) {
224
+ if (!isSafeGitCommitIsh(args.ref)) {
222
225
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
223
226
  }
224
227
  }
@@ -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
+ }