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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/AGENTS.md +23 -19
  2. package/CHANGELOG.md +145 -39
  3. package/HUMANS.md +23 -42
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +203 -53
  7. package/dist/server/error-codes.js +31 -16
  8. package/dist/server/git-blame-tool.js +66 -19
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +221 -12
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +39 -28
  13. package/dist/server/git-diff-tool.js +56 -31
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +30 -10
  16. package/dist/server/git-log-tool.js +37 -6
  17. package/dist/server/git-merge-tool.js +71 -13
  18. package/dist/server/git-parity-tool.js +15 -6
  19. package/dist/server/git-push-tool.js +4 -2
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-reset-soft-tool.js +1 -1
  22. package/dist/server/git-revert-tool.js +160 -0
  23. package/dist/server/git-show-tool.js +5 -10
  24. package/dist/server/git-stash-tool.js +112 -78
  25. package/dist/server/git-status-tool.js +2 -2
  26. package/dist/server/git-tag-tool.js +8 -13
  27. package/dist/server/git-worktree-tool.js +67 -59
  28. package/dist/server/git.js +116 -24
  29. package/dist/server/inventory.js +90 -32
  30. package/dist/server/list-presets-tool.js +2 -8
  31. package/dist/server/presets-resource.js +37 -20
  32. package/dist/server/presets.js +87 -15
  33. package/dist/server/roots.js +52 -79
  34. package/dist/server/schemas.js +18 -19
  35. package/dist/server/test-harness.js +11 -4
  36. package/dist/server/tool-parameter-schemas.js +47 -58
  37. package/dist/server/tools.js +36 -17
  38. package/dist/server.js +1 -1
  39. package/docs/install.md +52 -5
  40. package/docs/mcp-tools.md +472 -284
  41. package/package.json +6 -6
  42. package/schemas/batch_commit.json +5 -17
  43. package/schemas/git_blame.json +13 -11
  44. package/schemas/git_branch.json +54 -0
  45. package/schemas/git_cherry_pick.json +13 -15
  46. package/schemas/git_cherry_pick_continue.json +34 -0
  47. package/schemas/git_conflicts.json +38 -0
  48. package/schemas/git_diff.json +12 -10
  49. package/schemas/git_diff_summary.json +1 -15
  50. package/schemas/git_grep.json +85 -0
  51. package/schemas/git_inventory.json +32 -23
  52. package/schemas/git_log.json +20 -24
  53. package/schemas/git_merge.json +3 -15
  54. package/schemas/git_parity.json +13 -23
  55. package/schemas/git_push.json +1 -13
  56. package/schemas/git_reset_soft.json +1 -13
  57. package/schemas/git_revert.json +47 -0
  58. package/schemas/git_show.json +2 -8
  59. package/schemas/git_stash_apply.json +2 -8
  60. package/schemas/git_stash_push.json +47 -0
  61. package/schemas/git_status.json +13 -23
  62. package/schemas/git_tag.json +1 -7
  63. package/schemas/git_worktree_add.json +2 -14
  64. package/schemas/git_worktree_remove.json +2 -14
  65. package/schemas/index.json +28 -23
  66. package/schemas/list_presets.json +13 -23
  67. package/tool-parameters.schema.json +407 -423
  68. package/dist/server/git-branch-list-tool.js +0 -138
  69. package/dist/server/git-fetch-tool.js +0 -266
  70. package/dist/server/git-reflog-tool.js +0 -126
  71. package/schemas/git_branch_list.json +0 -36
  72. package/schemas/git_fetch.json +0 -52
  73. package/schemas/git_reflog.json +0 -44
  74. package/schemas/git_stash_list.json +0 -30
  75. package/schemas/git_worktree_list.json +0 -30
@@ -1,26 +1,70 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { isAbsolute, relative, resolve } from "node:path";
3
- function realPathOrSelf(p) {
4
- try {
5
- return realpathSync(p);
6
- }
7
- catch {
8
- return p;
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ function isErrno(err, codes) {
4
+ const code = err?.code;
5
+ return typeof code === "string" && codes.includes(code);
6
+ }
7
+ /**
8
+ * Canonicalize for confinement: realpath the longest existing prefix, then
9
+ * append any missing trailing segments lexically.
10
+ *
11
+ * Fail closed (`null`) when realpath fails for a reason other than a missing
12
+ * path component (ENOENT/ENOTDIR), or when nothing on the chain resolves —
13
+ * never return the unresolved input (avoids symlink+missing-leaf false-accept).
14
+ */
15
+ function realpathForConfinement(absPath) {
16
+ const abs = resolve(absPath);
17
+ const missing = [];
18
+ let cur = abs;
19
+ for (;;) {
20
+ try {
21
+ const resolved = realpathSync(cur);
22
+ return missing.length === 0 ? resolved : join(resolved, ...missing);
23
+ }
24
+ catch (err) {
25
+ if (!isErrno(err, ["ENOENT", "ENOTDIR"])) {
26
+ return null;
27
+ }
28
+ const parent = dirname(cur);
29
+ if (parent === cur) {
30
+ return null;
31
+ }
32
+ missing.unshift(basename(cur));
33
+ cur = parent;
34
+ }
9
35
  }
10
36
  }
11
- export function isStrictlyUnderGitTop(absPath, gitTop) {
12
- const absR = realPathOrSelf(resolve(absPath));
13
- const topR = realPathOrSelf(resolve(gitTop));
14
- const rel = relative(topR, absR);
37
+ /** True when `relative(top, path)` is inside or equal to top (not `..` / `../…`). */
38
+ function isRelativeInsideTop(rel) {
15
39
  if (rel === "")
16
40
  return true;
17
- return !rel.startsWith("..") && !isAbsolute(rel);
41
+ // Exact `..` or `../` / `..\` only — do not treat a segment named `..foo` as escape.
42
+ if (rel === ".." || rel.startsWith(`..${sep}`))
43
+ return false;
44
+ return !isAbsolute(rel);
45
+ }
46
+ export function isStrictlyUnderGitTop(absPath, gitTop) {
47
+ let topR;
48
+ try {
49
+ topR = realpathSync(resolve(gitTop));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ const absR = realpathForConfinement(absPath);
55
+ if (absR === null)
56
+ return false;
57
+ return isRelativeInsideTop(relative(topR, absR));
18
58
  }
19
59
  export function resolvePathForRepo(p, gitTop) {
20
60
  const t = p.trim();
21
61
  return isAbsolute(t) ? resolve(t) : resolve(gitTop, t);
22
62
  }
23
63
  /** Resolved path must lie inside git toplevel (relative or absolute user input). */
24
- export function assertRelativePathUnderTop(_relPath, absResolved, gitTop) {
64
+ export function assertRelativePathUnderTop(userPath, absResolved, gitTop) {
65
+ // Use the caller-supplied path: reject mismatched abs vs resolvePathForRepo.
66
+ if (resolvePathForRepo(userPath, gitTop) !== absResolved) {
67
+ return false;
68
+ }
25
69
  return isStrictlyUnderGitTop(absResolved, gitTop);
26
70
  }
@@ -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";
@@ -13,8 +15,16 @@ const FileEntrySchema = z.union([
13
15
  path: z.string().min(1).describe("File path relative to git root."),
14
16
  lines: z
15
17
  .object({
16
- from: z.number().int().min(1).describe("Start line number (1-indexed)."),
17
- to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
18
+ from: z.number().int().min(1).max(1000000).describe("Start line number (1-indexed)."),
19
+ to: z
20
+ .number()
21
+ .int()
22
+ .min(1)
23
+ .max(1000000)
24
+ .describe("End line number (1-indexed, inclusive)."),
25
+ })
26
+ .refine((l) => l.from <= l.to, {
27
+ message: "lines.from must be <= lines.to",
18
28
  })
19
29
  .describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
20
30
  }),
@@ -25,7 +35,10 @@ const CommitEntrySchema = z.object({
25
35
  .array(FileEntrySchema)
26
36
  .min(1)
27
37
  .describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
28
- "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."),
29
42
  });
30
43
  const PushModeSchema = z
31
44
  .enum(["never", "after"])
@@ -37,7 +50,30 @@ const DryRunSchema = z
37
50
  .boolean()
38
51
  .optional()
39
52
  .default(false)
40
- .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
+ }
41
77
  /**
42
78
  * Parses a unified diff to extract hunks that overlap with a given line range.
43
79
  * Returns a partial patch containing only the overlapping hunks, including header lines.
@@ -119,7 +155,10 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
119
155
  i++;
120
156
  }
121
157
  }
122
- return result.length > fileHeaderLines.length ? result.join("\n") : null;
158
+ // Trailing newline is required: when the selected hunk(s) aren't the last hunk in the
159
+ // real diff, the last copied line is a content line that had a newline after it in the
160
+ // original diff output. Dropping it produces a patch `git apply` rejects as corrupt.
161
+ return result.length > fileHeaderLines.length ? `${result.join("\n")}\n` : null;
123
162
  }
124
163
  /**
125
164
  * Stages a file with optional line range. If lines are provided, only hunks
@@ -151,23 +190,42 @@ async function stageFile(gitTop, filePath, lines) {
151
190
  error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
152
191
  };
153
192
  }
154
- // Line range case: extract overlapping hunks and apply patch
155
- const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
156
- if (!diffResult.ok) {
157
- 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;
158
222
  }
159
- const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
223
+ const partialPatch = extractOverlappingHunks(diffStdout, lines.from, lines.to);
160
224
  if (!partialPatch) {
161
225
  return { ok: false, error: "No hunks found in line range" };
162
226
  }
163
- // Resolve the real git dir (works for linked worktrees where .git is a file, not a dir)
164
- const gitDirResult = await spawnGitAsync(gitTop, ["rev-parse", "--absolute-git-dir"]);
165
- if (!gitDirResult.ok) {
166
- return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
167
- }
168
- const gitDir = gitDirResult.stdout.trim();
169
- // Write partial patch to temp file in the git dir and apply it to the index
170
- 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`);
171
229
  const { writeFileSync, unlinkSync } = await import("node:fs");
172
230
  writeFileSync(tempPatchFile, partialPatch, "utf8");
173
231
  const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
@@ -218,14 +276,18 @@ export function registerBatchCommitTool(server) {
218
276
  server.addTool({
219
277
  name: "batch_commit",
220
278
  description: "Create multiple sequential git commits in one call. " +
221
- "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. " +
222
284
  'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
223
285
  annotations: {
224
286
  readOnlyHint: false,
225
287
  destructiveHint: false,
226
288
  idempotentHint: false,
227
289
  },
228
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
290
+ parameters: WorkspacePickSchema.extend({
229
291
  commits: z
230
292
  .array(CommitEntrySchema)
231
293
  .min(1)
@@ -240,18 +302,18 @@ export function registerBatchCommitTool(server) {
240
302
  return jsonRespond(pre.error);
241
303
  const gitTop = pre.gitTop;
242
304
  const results = [];
243
- const stagedFilesForCleanup = new Set();
244
- // Snapshot already-staged paths BEFORE the preview loop so dry-run cleanup
245
- // doesn't unstage files the caller had staged before invoking.
246
- 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;
247
308
  if (args.dryRun) {
248
- const snapResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
249
- if (snapResult.ok) {
250
- preStagedPaths = new Set(snapResult.stdout
251
- .split("\n")
252
- .map((s) => s.trim())
253
- .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
+ });
254
315
  }
316
+ indexTreeBefore = wt.stdout.trim();
255
317
  }
256
318
  for (let i = 0; i < args.commits.length; i++) {
257
319
  const entry = args.commits[i];
@@ -260,21 +322,38 @@ export function registerBatchCommitTool(server) {
260
322
  // Normalize file entries to { path, lines? } format
261
323
  const fileEntries = [];
262
324
  const filePaths = [];
325
+ let invalidLineRange = false;
263
326
  for (const fileEntry of entry.files) {
264
327
  if (typeof fileEntry === "string") {
265
328
  fileEntries.push({ path: fileEntry });
266
329
  filePaths.push(fileEntry);
267
330
  }
268
331
  else {
332
+ if (fileEntry.lines.from > fileEntry.lines.to) {
333
+ invalidLineRange = true;
334
+ filePaths.push(fileEntry.path);
335
+ break;
336
+ }
269
337
  fileEntries.push(fileEntry);
270
338
  filePaths.push(fileEntry.path);
271
339
  }
272
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
+ }
273
352
  // --- Validate all paths are under the git toplevel ---
274
353
  const escapedPaths = [];
275
354
  for (const path of filePaths) {
276
355
  const abs = resolvePathForRepo(path, gitTop);
277
- if (!isStrictlyUnderGitTop(abs, gitTop)) {
356
+ if (!assertRelativePathUnderTop(path, abs, gitTop)) {
278
357
  escapedPaths.push(path);
279
358
  }
280
359
  }
@@ -289,9 +368,23 @@ export function registerBatchCommitTool(server) {
289
368
  });
290
369
  break;
291
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
+ }
292
384
  // --- Stage files (with optional line ranges) ---
293
385
  let stagingFailed = false;
294
386
  let stagingError = "";
387
+ const stagedSoFar = [];
295
388
  for (const fileEntry of fileEntries) {
296
389
  const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
297
390
  if (!stageResult.ok) {
@@ -299,8 +392,14 @@ export function registerBatchCommitTool(server) {
299
392
  stagingError = stageResult.error || "Unknown error";
300
393
  break;
301
394
  }
395
+ stagedSoFar.push(fileEntry.path);
302
396
  }
303
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
+ }
304
403
  results.push({
305
404
  index: i,
306
405
  ok: false,
@@ -312,19 +411,16 @@ export function registerBatchCommitTool(server) {
312
411
  });
313
412
  break;
314
413
  }
315
- // Track staged files for cleanup in dry-run, excluding paths that were
316
- // already staged before this call (so pre-existing staged state survives).
414
+ // --- Dry-run mode: collect preview scoped to this entry's paths ---
317
415
  if (args.dryRun) {
318
- for (const path of filePaths) {
319
- if (!preStagedPaths.has(path)) {
320
- stagedFilesForCleanup.add(path);
321
- }
322
- }
323
- }
324
- // --- Dry-run mode: collect preview and unstage ---
325
- if (args.dryRun) {
326
- // Get diff stat for this staged entry
327
- 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
+ ]);
328
424
  const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
329
425
  results.push({
330
426
  index: i,
@@ -334,11 +430,55 @@ export function registerBatchCommitTool(server) {
334
430
  staged: filePaths,
335
431
  ...spreadDefined("diffStat", diffStat || undefined),
336
432
  });
337
- 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]);
338
468
  }
339
- // --- Commit ---
340
469
  const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
341
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
+ }
342
482
  const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
343
483
  results.push({
344
484
  index: i,
@@ -351,6 +491,15 @@ export function registerBatchCommitTool(server) {
351
491
  });
352
492
  break;
353
493
  }
494
+ if (indexSnap && unrelatedStaged.length > 0) {
495
+ await spawnGitAsync(gitTop, [
496
+ "restore",
497
+ `--source=${indexSnap}`,
498
+ "--staged",
499
+ "--",
500
+ ...unrelatedStaged,
501
+ ]);
502
+ }
354
503
  // --- Extract SHA from commit output ---
355
504
  const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
356
505
  const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
@@ -363,10 +512,9 @@ export function registerBatchCommitTool(server) {
363
512
  ...spreadDefined("output", gitOutput || undefined),
364
513
  });
365
514
  }
366
- // --- In dry-run mode, unstage all files ---
367
- if (args.dryRun && stagedFilesForCleanup.size > 0) {
368
- const filesToReset = Array.from(stagedFilesForCleanup);
369
- 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]);
370
518
  }
371
519
  const allOk = results.length === args.commits.length && results.every((r) => r.ok);
372
520
  // --- Optional push after all commits succeed (not in dry-run mode) ---
@@ -381,8 +529,10 @@ export function registerBatchCommitTool(server) {
381
529
  index: r.index,
382
530
  ok: r.ok,
383
531
  ...spreadDefined("sha", r.sha),
384
- message: r.message,
385
- files: r.files,
532
+ // message/files are the caller's own request echoed back — only worth
533
+ // repeating on failure, where the caller needs them to diagnose without
534
+ // cross-referencing the request.
535
+ ...spreadWhen(!r.ok, { message: r.message, files: r.files }),
386
536
  ...spreadDefined("staged", r.staged),
387
537
  ...spreadDefined("diffStat", r.diffStat),
388
538
  ...spreadDefined("error", r.error),
@@ -14,15 +14,12 @@ export const ERROR_CODES = {
14
14
  // Repository resolution
15
15
  NOT_A_GIT_REPOSITORY: "not_a_git_repository",
16
16
  NO_WORKSPACE_ROOT: "no_workspace_root",
17
- ROOT_INDEX_OUT_OF_RANGE: "root_index_out_of_range",
18
- // absoluteGitRoots validation
19
- ABSOLUTE_GIT_ROOTS_EMPTY: "absolute_git_roots_empty",
20
- ABSOLUTE_GIT_ROOTS_EXCLUSIVE: "absolute_git_roots_exclusive",
21
- ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT: "absolute_git_roots_nested_or_preset_conflict",
22
- ABSOLUTE_GIT_ROOTS_PRESET_CONFLICT: "absolute_git_roots_preset_conflict",
23
- ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY: "absolute_git_roots_single_repo_only",
24
- ABSOLUTE_GIT_ROOTS_TOO_MANY: "absolute_git_roots_too_many",
25
- INVALID_ABSOLUTE_GIT_ROOT: "invalid_absolute_git_root",
17
+ // `root` array validation
18
+ INVALID_ROOT_PATH: "invalid_root_path",
19
+ ROOT_LIST_EMPTY: "root_list_empty",
20
+ ROOT_LIST_NESTED_OR_PRESET_CONFLICT: "root_list_nested_or_preset_conflict",
21
+ ROOT_LIST_PRESET_CONFLICT: "root_list_preset_conflict",
22
+ ROOT_LIST_TOO_MANY: "root_list_too_many",
26
23
  // Presets
27
24
  PRESET_FILE_INVALID: "preset_file_invalid",
28
25
  PRESET_NOT_FOUND: "preset_not_found",
@@ -32,7 +29,13 @@ export const ERROR_CODES = {
32
29
  INVALID_PATHS: "invalid_paths",
33
30
  INVALID_REMOTE_OR_BRANCH: "invalid_remote_or_branch",
34
31
  INVALID_SINCE: "invalid_since",
32
+ /** Canonical path-escape wire string (grep/diff/blame/show/stash). */
35
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
+ */
36
39
  PATH_ESCAPES_REPOSITORY: "path_escapes_repository",
37
40
  UNSAFE_RANGE_TOKEN: "unsafe_range_token",
38
41
  UNSAFE_REF_TOKEN: "unsafe_ref_token",
@@ -43,8 +46,11 @@ export const ERROR_CODES = {
43
46
  ONTO_DETACHED_HEAD: "onto_detached_head",
44
47
  PROTECTED_BRANCH: "protected_branch",
45
48
  WORKING_TREE_DIRTY: "working_tree_dirty",
46
- // Branch list
47
- 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",
48
54
  // Commit / stage
49
55
  COMMIT_FAILED: "commit_failed",
50
56
  STAGE_FAILED: "stage_failed",
@@ -52,16 +58,15 @@ export const ERROR_CODES = {
52
58
  GIT_DIFF_FAILED: "git_diff_failed",
53
59
  // Log
54
60
  GIT_LOG_FAILED: "git_log_failed",
61
+ // Grep (pickaxe)
62
+ GIT_GREP_FAILED: "git_grep_failed",
55
63
  // Show
56
64
  GIT_SHOW_FAILED: "git_show_failed",
57
65
  // Blame
58
66
  GIT_BLAME_FAILED: "git_blame_failed",
59
- // Reflog
60
- REFLOG_FAILED: "reflog_failed",
61
67
  // Stash
62
- STASH_LIST_FAILED: "stash_list_failed",
63
- // Fetch
64
- // (uses UNSAFE_REMOTE_TOKEN and UNSAFE_REF_TOKEN)
68
+ STASH_APPLY_FAILED: "stash_apply_failed",
69
+ STASH_PUSH_FAILED: "stash_push_failed",
65
70
  // Push
66
71
  PUSH_DETACHED_HEAD: "push_detached_head",
67
72
  PUSH_FAILED: "push_failed",
@@ -69,16 +74,26 @@ export const ERROR_CODES = {
69
74
  // Merge
70
75
  CANNOT_FAST_FORWARD: "cannot_fast_forward",
71
76
  DESTINATION_NOT_FOUND: "destination_not_found",
77
+ MERGE_ABORT_FAILED: "merge_abort_failed",
72
78
  MERGE_BASE_FAILED: "merge_base_failed",
73
79
  MERGE_CONFLICTS: "merge_conflicts",
74
80
  MERGE_FAILED: "merge_failed",
81
+ REBASE_ABORT_FAILED: "rebase_abort_failed",
75
82
  REBASE_CONFLICTS: "rebase_conflicts",
76
83
  SOURCE_NOT_FOUND: "source_not_found",
77
84
  // Cherry-pick
78
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",
79
92
  RANGE_RESOLUTION_FAILED: "range_resolution_failed",
80
93
  // Reset
81
94
  RESET_FAILED: "reset_failed",
95
+ // Revert
96
+ REVERT_ABORT_FAILED: "revert_abort_failed",
82
97
  // Tag
83
98
  REF_NOT_FOUND: "ref_not_found",
84
99
  TAG_CREATE_FAILED: "tag_create_failed",