@rethunk/mcp-multi-root-git 2.6.0 → 2.8.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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +1 -1
  4. package/dist/server/batch-commit-tool.js +7 -6
  5. package/dist/server/error-codes.js +95 -0
  6. package/dist/server/git-blame-tool.js +227 -0
  7. package/dist/server/git-branch-list-tool.js +140 -0
  8. package/dist/server/git-cherry-pick-tool.js +11 -10
  9. package/dist/server/git-diff-summary-tool.js +3 -2
  10. package/dist/server/git-diff-tool.js +56 -12
  11. package/dist/server/git-fetch-tool.js +142 -14
  12. package/dist/server/git-inventory-tool.js +5 -4
  13. package/dist/server/git-log-tool.js +10 -5
  14. package/dist/server/git-merge-tool.js +15 -14
  15. package/dist/server/git-parity-tool.js +3 -2
  16. package/dist/server/git-push-tool.js +9 -5
  17. package/dist/server/git-reflog-tool.js +126 -0
  18. package/dist/server/git-reset-soft-tool.js +4 -3
  19. package/dist/server/git-show-tool.js +83 -23
  20. package/dist/server/git-stash-tool.js +2 -1
  21. package/dist/server/git-tag-tool.js +8 -7
  22. package/dist/server/git-worktree-tool.js +8 -7
  23. package/dist/server/git.js +70 -4
  24. package/dist/server/list-presets-tool.js +2 -1
  25. package/dist/server/presets-resource.js +3 -2
  26. package/dist/server/presets.js +11 -5
  27. package/dist/server/roots.js +11 -10
  28. package/dist/server/tool-parameter-schemas.js +9 -0
  29. package/dist/server/tools.js +6 -0
  30. package/docs/mcp-tools.md +119 -6
  31. package/package.json +2 -2
  32. package/schemas/git_blame.json +52 -0
  33. package/schemas/git_branch_list.json +36 -0
  34. package/schemas/git_diff.json +14 -1
  35. package/schemas/git_reflog.json +44 -0
  36. package/schemas/git_show.json +12 -1
  37. package/schemas/index.json +15 -0
  38. package/tool-parameters.schema.json +152 -2
@@ -1,4 +1,6 @@
1
1
  import { z } from "zod";
2
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
+ import { ERROR_CODES } from "./error-codes.js";
2
4
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
5
  import { jsonRespond } from "./json.js";
4
6
  import { requireSingleRepo } from "./roots.js";
@@ -19,14 +21,18 @@ function buildDiffArgs(opts) {
19
21
  const baseStr = opts.base?.trim() ?? "HEAD";
20
22
  const headStr = opts.head?.trim() ?? "HEAD";
21
23
  if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
22
- return { ok: false, error: "unsafe_range_token" };
24
+ return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
23
25
  }
24
26
  // Use two-dot range: base..head
25
27
  args.push(`${baseStr}..${headStr}`);
26
28
  }
27
- // Scope to path if provided
28
- if (opts.path?.trim()) {
29
- args.push("--", opts.path.trim());
29
+ // Apply unified context width if specified
30
+ if (typeof opts.unified === "number") {
31
+ args.push(`-U${opts.unified}`);
32
+ }
33
+ // Scope to paths if provided
34
+ if (opts.paths && opts.paths.length > 0) {
35
+ args.push("--", ...opts.paths);
30
36
  }
31
37
  return { ok: true, args };
32
38
  }
@@ -44,8 +50,8 @@ function rangeLabel(opts) {
44
50
  else {
45
51
  label = "unstaged changes";
46
52
  }
47
- if (opts.path?.trim()) {
48
- label += ` (${opts.path.trim()})`;
53
+ if (opts.paths && opts.paths.length > 0) {
54
+ label += ` (${opts.paths.join(", ")})`;
49
55
  }
50
56
  return label;
51
57
  }
@@ -55,9 +61,10 @@ function rangeLabel(opts) {
55
61
  export function registerGitDiffTool(server) {
56
62
  server.addTool({
57
63
  name: "git_diff",
58
- description: "Get diff text for scoped file or range. Returns the raw diff output. " +
64
+ description: "Get diff text for scoped file(s) or range. Returns the raw diff output. " +
59
65
  "Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
60
- "and `path` to scope to a specific file.",
66
+ "`path` to scope to a single file, `paths` to scope to multiple files, " +
67
+ "and `unified` to control the number of context lines.",
61
68
  annotations: {
62
69
  readOnlyHint: true,
63
70
  },
@@ -78,23 +85,60 @@ export function registerGitDiffTool(server) {
78
85
  path: z
79
86
  .string()
80
87
  .optional()
81
- .describe('Scope diff to a single file path (e.g. "src/main.ts").'),
88
+ .describe('Scope diff to a single file path (e.g. "src/main.ts"). ' +
89
+ "For multiple files, prefer `paths`. If both `path` and `paths` are given, they are unioned."),
90
+ paths: z
91
+ .array(z.string())
92
+ .optional()
93
+ .describe('Scope diff to multiple file paths (e.g. ["src/a.ts", "src/b.ts"]). ' +
94
+ "Each path is validated and must lie within the repository root. " +
95
+ "If both `path` and `paths` are given, they are unioned."),
82
96
  staged: z
83
97
  .boolean()
84
98
  .optional()
85
99
  .default(false)
86
100
  .describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
101
+ unified: z
102
+ .number()
103
+ .int()
104
+ .min(0)
105
+ .max(100)
106
+ .optional()
107
+ .describe("Number of context lines to show around each change (passed as -U<n> to git diff). " +
108
+ "Defaults to git's built-in default (3). Use 0 for no context."),
87
109
  }),
88
110
  execute: async (args) => {
89
111
  const pre = requireSingleRepo(server, args);
90
112
  if (!pre.ok)
91
113
  return jsonRespond(pre.error);
92
114
  const gitTop = pre.gitTop;
115
+ // Union path + paths, trim, dedup
116
+ const rawPaths = [];
117
+ if (args.path && typeof args.path === "string" && args.path.trim()) {
118
+ rawPaths.push(args.path.trim());
119
+ }
120
+ if (Array.isArray(args.paths)) {
121
+ for (const p of args.paths) {
122
+ if (typeof p === "string" && p.trim()) {
123
+ rawPaths.push(p.trim());
124
+ }
125
+ }
126
+ }
127
+ // Dedup preserving order
128
+ const dedupedPaths = [...new Set(rawPaths)];
129
+ // Confine each path within the repo
130
+ for (const p of dedupedPaths) {
131
+ const resolved = resolvePathForRepo(p, gitTop);
132
+ if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
133
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
134
+ }
135
+ }
93
136
  // Build git diff args
94
137
  const diffArgsResult = buildDiffArgs({
95
138
  base: args.base,
96
139
  head: args.head,
97
- path: args.path,
140
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
141
+ unified: typeof args.unified === "number" ? args.unified : undefined,
98
142
  staged: args.staged,
99
143
  });
100
144
  if (!diffArgsResult.ok) {
@@ -104,14 +148,14 @@ export function registerGitDiffTool(server) {
104
148
  const result = await spawnGitAsync(gitTop, diffArgsResult.args);
105
149
  if (!result.ok) {
106
150
  return jsonRespond({
107
- error: "git_diff_failed",
151
+ error: ERROR_CODES.GIT_DIFF_FAILED,
108
152
  detail: (result.stderr || result.stdout).trim(),
109
153
  });
110
154
  }
111
155
  const label = rangeLabel({
112
156
  base: args.base,
113
157
  head: args.head,
114
- path: args.path,
158
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
115
159
  staged: args.staged,
116
160
  });
117
161
  if (args.format === "json") {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
- import { jsonRespond } from "./json.js";
4
+ import { jsonRespond, spreadWhen } from "./json.js";
4
5
  import { requireSingleRepo } from "./roots.js";
5
6
  import { WorkspacePickSchema } from "./schemas.js";
6
7
  // ---------------------------------------------------------------------------
@@ -30,6 +31,61 @@ function parseGitFetchOutput(output) {
30
31
  }
31
32
  return { updatedRefs, newRefs };
32
33
  }
34
+ const ZEROS_SHA = "0000000000000000000000000000000000000000";
35
+ /**
36
+ * Parse `git fetch --porcelain` stdout.
37
+ *
38
+ * Machine-readable lines have the form:
39
+ * <flag><SP><old-sha><SP><new-sha><SP><local-ref>
40
+ *
41
+ * Flags:
42
+ * ' ' = fast-forward update
43
+ * '+' = forced update
44
+ * '*' = new ref
45
+ * '-' = pruned (deleted)
46
+ * '!' = rejected
47
+ * '=' = up-to-date (no change)
48
+ * 't' = tag update
49
+ */
50
+ function parsePorcelainOutput(stdout) {
51
+ const updated = [];
52
+ const created = [];
53
+ const pruned = [];
54
+ for (const line of stdout.split("\n")) {
55
+ if (!line)
56
+ continue;
57
+ // Porcelain lines: flag(1) + space(1) + old-sha + space + new-sha + space + ref
58
+ // Minimum viable line: 1 flag + 1 space + at least some content
59
+ if (line.length < 3)
60
+ continue;
61
+ const flag = line[0];
62
+ const rest = line.slice(2); // skip "flag " prefix
63
+ const parts = rest.split(" ");
64
+ // Expected: [old-sha, new-sha, ref...]
65
+ if (parts.length < 3)
66
+ continue;
67
+ const oldSha = parts[0] ?? "";
68
+ const newSha = parts[1] ?? "";
69
+ const ref = parts.slice(2).join(" ");
70
+ if (!ref)
71
+ continue;
72
+ if (flag === "-") {
73
+ pruned.push({ ref });
74
+ }
75
+ else if (flag === "*" || oldSha === ZEROS_SHA) {
76
+ // New ref: flag is '*' OR old-sha is all-zeros
77
+ created.push({ ref, newSha, flag: flag ?? "*" });
78
+ }
79
+ else if (flag === " " || flag === "+" || flag === "t") {
80
+ // Update: old and new differ
81
+ if (oldSha !== newSha) {
82
+ updated.push({ ref, oldSha, newSha, flag: flag ?? " " });
83
+ }
84
+ }
85
+ // '!' (rejected) and '=' (up-to-date) are intentionally ignored
86
+ }
87
+ return { updated, created, pruned };
88
+ }
33
89
  // ---------------------------------------------------------------------------
34
90
  // Tool registration
35
91
  // ---------------------------------------------------------------------------
@@ -80,31 +136,80 @@ export function registerGitFetchTool(server) {
80
136
  const tags = args.tags === true;
81
137
  // Validate remote and branch to prevent argument injection.
82
138
  if (!isSafeGitUpstreamToken(remote)) {
83
- return jsonRespond({ error: "unsafe_remote_token", remote });
139
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN, remote });
84
140
  }
85
141
  if (branch && !isSafeGitUpstreamToken(branch)) {
86
- return jsonRespond({ error: "unsafe_ref_token", branch });
142
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch });
87
143
  }
88
- // Build git fetch command
89
- const fetchArgs = ["fetch"];
144
+ // Build base fetch args (without --porcelain for now)
145
+ const baseArgs = ["fetch"];
90
146
  if (prune) {
91
- fetchArgs.push("--prune");
147
+ baseArgs.push("--prune");
92
148
  }
93
149
  if (tags) {
94
- fetchArgs.push("--tags");
150
+ baseArgs.push("--tags");
95
151
  }
96
- fetchArgs.push(remote);
152
+ baseArgs.push(remote);
97
153
  if (branch) {
98
- fetchArgs.push(branch);
154
+ baseArgs.push(branch);
155
+ }
156
+ // --- Attempt structured fetch with --porcelain (requires git >= 2.41) ---
157
+ let structured = null;
158
+ let result;
159
+ const porcelainArgs = ["fetch", "--porcelain"];
160
+ if (prune)
161
+ porcelainArgs.push("--prune");
162
+ if (tags)
163
+ porcelainArgs.push("--tags");
164
+ porcelainArgs.push(remote);
165
+ if (branch)
166
+ porcelainArgs.push(branch);
167
+ const porcelainResult = await spawnGitAsync(gitTop, porcelainArgs);
168
+ if (!porcelainResult.ok &&
169
+ (porcelainResult.stderr.includes("unknown option") ||
170
+ porcelainResult.stderr.includes("unknown switch") ||
171
+ porcelainResult.stderr.includes("invalid option"))) {
172
+ // --porcelain not supported: fall back to plain fetch
173
+ result = await spawnGitAsync(gitTop, baseArgs);
174
+ structured = null;
175
+ }
176
+ else {
177
+ // Use porcelain result (ok or actual fetch error)
178
+ result = porcelainResult;
179
+ if (porcelainResult.ok || !porcelainResult.stderr.includes("unknown option")) {
180
+ structured = parsePorcelainOutput(porcelainResult.stdout);
181
+ }
182
+ }
183
+ // Legacy string-line parse: use combined output when porcelain is unavailable.
184
+ // When porcelain succeeded, derive legacy fields from structured data so
185
+ // that callers who rely on updatedRefs/newRefs still get useful values.
186
+ let updatedRefs;
187
+ let newRefs;
188
+ if (structured !== null) {
189
+ // Derive legacy string arrays from structured deltas
190
+ updatedRefs = structured.updated.map((d) => `${d.oldSha.slice(0, 7)}..${d.newSha.slice(0, 7)} ${d.ref}`);
191
+ newRefs = structured.created.map((d) => `[new ref] ${d.ref} -> ${d.newSha.slice(0, 7)}`);
192
+ }
193
+ else {
194
+ const parsed = parseGitFetchOutput(result.stdout + result.stderr);
195
+ updatedRefs = parsed.updatedRefs;
196
+ newRefs = parsed.newRefs;
99
197
  }
100
- const result = await spawnGitAsync(gitTop, fetchArgs);
101
- const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
102
198
  const fetchResult = {
103
199
  ok: result.ok,
104
200
  remote,
105
201
  updatedRefs,
106
202
  newRefs,
107
203
  output: (result.stdout + result.stderr).trim(),
204
+ ...spreadWhen(structured !== null && structured.updated.length > 0, {
205
+ updated: structured?.updated ?? [],
206
+ }),
207
+ ...spreadWhen(structured !== null && structured.created.length > 0, {
208
+ created: structured?.created ?? [],
209
+ }),
210
+ ...spreadWhen(structured !== null && structured.pruned.length > 0, {
211
+ pruned: structured?.pruned ?? [],
212
+ }),
108
213
  };
109
214
  if (args.format === "json") {
110
215
  return jsonRespond(fetchResult);
@@ -117,19 +222,42 @@ export function registerGitFetchTool(server) {
117
222
  return lines.join("\n");
118
223
  }
119
224
  lines.push("", "**Status**: Success", "");
120
- if (updatedRefs.length > 0) {
225
+ // Prefer structured deltas in markdown when available
226
+ if (structured !== null && structured.updated.length > 0) {
227
+ lines.push("## Updated refs", "");
228
+ for (const d of structured.updated) {
229
+ lines.push(`- \`${d.ref}\` ${d.oldSha.slice(0, 7)}→${d.newSha.slice(0, 7)}`);
230
+ }
231
+ }
232
+ else if (updatedRefs.length > 0) {
121
233
  lines.push("## Updated refs", "");
122
234
  for (const ref of updatedRefs) {
123
235
  lines.push(`- ${ref}`);
124
236
  }
125
237
  }
126
- if (newRefs.length > 0) {
238
+ if (structured !== null && structured.created.length > 0) {
239
+ lines.push("", "## New refs", "");
240
+ for (const d of structured.created) {
241
+ lines.push(`- \`${d.ref}\` (new, ${d.newSha.slice(0, 7)})`);
242
+ }
243
+ }
244
+ else if (newRefs.length > 0) {
127
245
  lines.push("", "## New refs", "");
128
246
  for (const ref of newRefs) {
129
247
  lines.push(`- ${ref}`);
130
248
  }
131
249
  }
132
- if (updatedRefs.length === 0 && newRefs.length === 0 && result.stdout.trim()) {
250
+ if (structured !== null && structured.pruned.length > 0) {
251
+ lines.push("", "## Pruned refs", "");
252
+ for (const d of structured.pruned) {
253
+ lines.push(`- \`${d.ref}\` (deleted)`);
254
+ }
255
+ }
256
+ if (updatedRefs.length === 0 &&
257
+ newRefs.length === 0 &&
258
+ (structured === null ||
259
+ (structured.updated.length === 0 && structured.created.length === 0)) &&
260
+ result.stdout.trim()) {
133
261
  lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
134
262
  }
135
263
  return lines.join("\n");
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitRevParseGitDir, gitTopLevel, isSafeGitUpstreamToken, } from "./git.js";
3
4
  import { buildInventorySectionMarkdown, collectInventoryEntry, makeSkipEntry, validateRepoPath, } from "./inventory.js";
4
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -27,7 +28,7 @@ export function registerGitInventoryTool(server) {
27
28
  execute: async (args) => {
28
29
  if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0) {
29
30
  if (args.preset || (args.nestedRoots?.length ?? 0) > 0) {
30
- return jsonRespond({ error: "absolute_git_roots_nested_or_preset_conflict" });
31
+ return jsonRespond({ error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_NESTED_OR_PRESET_CONFLICT });
31
32
  }
32
33
  }
33
34
  const pre = requireGitAndRoots(server, args, args.preset);
@@ -39,12 +40,12 @@ export function registerGitInventoryTool(server) {
39
40
  const hasRemote = rawRemote !== undefined && rawRemote !== "";
40
41
  const hasBranch = rawBranch !== undefined && rawBranch !== "";
41
42
  if (hasRemote !== hasBranch) {
42
- return jsonRespond({ error: "remote_branch_mismatch" });
43
+ return jsonRespond({ error: ERROR_CODES.REMOTE_BRANCH_MISMATCH });
43
44
  }
44
45
  let upstream = { mode: "auto" };
45
46
  if (hasRemote && hasBranch && rawRemote && rawBranch) {
46
47
  if (!isSafeGitUpstreamToken(rawRemote) || !isSafeGitUpstreamToken(rawBranch)) {
47
- return jsonRespond({ error: "invalid_remote_or_branch" });
48
+ return jsonRespond({ error: ERROR_CODES.INVALID_REMOTE_OR_BRANCH });
48
49
  }
49
50
  upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
50
51
  }
@@ -54,7 +55,7 @@ export function registerGitInventoryTool(server) {
54
55
  for (const workspaceRoot of pre.roots) {
55
56
  const top = gitTopLevel(workspaceRoot);
56
57
  if (!top) {
57
- const err = { error: "not_a_git_repository", path: workspaceRoot };
58
+ const err = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
58
59
  if (args.format === "json") {
59
60
  allJson.push({
60
61
  workspaceRoot: workspaceRoot,
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { z } from "zod";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
4
5
  import { isSafeGitAncestorRef } from "./git-refs.js";
5
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -82,7 +83,7 @@ async function runGitLog(opts) {
82
83
  const r = await spawnGitAsync(top, logArgs);
83
84
  if (!r.ok) {
84
85
  return {
85
- error: "git_log_failed",
86
+ error: ERROR_CODES.GIT_LOG_FAILED,
86
87
  path: top,
87
88
  };
88
89
  }
@@ -222,19 +223,19 @@ export function registerGitLogTool(server) {
222
223
  // Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
223
224
  const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
224
225
  if (/[\n\r;|&`$<>]/.test(rawSince)) {
225
- return jsonRespond({ error: "invalid_since", since: rawSince });
226
+ return jsonRespond({ error: ERROR_CODES.INVALID_SINCE, since: rawSince });
226
227
  }
227
228
  // Validate paths — reject anything with null bytes or shell meta.
228
229
  // Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
229
230
  const rawPaths = args.paths ?? [];
230
231
  for (const p of rawPaths) {
231
232
  if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
232
- return jsonRespond({ error: "invalid_paths", path: p });
233
+ return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
233
234
  }
234
235
  }
235
236
  // Validate branch — reject leading-dash and other injection attempts.
236
237
  if (args.branch && !isSafeGitAncestorRef(args.branch)) {
237
- return jsonRespond({ error: "unsafe_ref_token", branch: args.branch });
238
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
238
239
  }
239
240
  const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
240
241
  // Fan out across roots.
@@ -242,7 +243,11 @@ export function registerGitLogTool(server) {
242
243
  const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
243
244
  const top = gitTopLevel(rootInput);
244
245
  if (!top) {
245
- return { _error: true, workspaceRoot: rootInput, error: "not_a_git_repository" };
246
+ return {
247
+ _error: true,
248
+ workspaceRoot: rootInput,
249
+ error: ERROR_CODES.NOT_A_GIT_REPOSITORY,
250
+ };
246
251
  }
247
252
  const r = await runGitLog({
248
253
  top,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { spawnGitAsync } from "./git.js";
3
4
  import { conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -36,17 +37,17 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
36
37
  const intoSha = await resolveRef(gitTop, into);
37
38
  const sourceSha = await resolveRef(gitTop, source);
38
39
  if (!sourceSha) {
39
- return { source, ok: false, error: "source_not_found" };
40
+ return { source, ok: false, error: ERROR_CODES.SOURCE_NOT_FOUND };
40
41
  }
41
42
  if (!intoSha) {
42
- return { source, ok: false, error: "destination_not_found" };
43
+ return { source, ok: false, error: ERROR_CODES.DESTINATION_NOT_FOUND };
43
44
  }
44
45
  const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
45
46
  if (!mb.ok) {
46
47
  return {
47
48
  source,
48
49
  ok: false,
49
- error: "merge_base_failed",
50
+ error: ERROR_CODES.MERGE_BASE_FAILED,
50
51
  detail: (mb.stderr || mb.stdout).trim(),
51
52
  };
52
53
  }
@@ -63,7 +64,7 @@ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
63
64
  return {
64
65
  source,
65
66
  ok: false,
66
- error: "cannot_fast_forward",
67
+ error: ERROR_CODES.CANNOT_FAST_FORWARD,
67
68
  detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
68
69
  };
69
70
  }
@@ -102,7 +103,7 @@ async function fastForward(gitTop, source) {
102
103
  outcome: "conflicts",
103
104
  conflictStage: "merge",
104
105
  conflictPaths: [],
105
- error: "merge_failed",
106
+ error: ERROR_CODES.MERGE_FAILED,
106
107
  detail: (r.stderr || r.stdout).trim(),
107
108
  };
108
109
  }
@@ -126,7 +127,7 @@ async function mergeCommit(gitTop, source, message, into) {
126
127
  outcome: "conflicts",
127
128
  conflictStage: "merge",
128
129
  conflictPaths: paths,
129
- error: "merge_conflicts",
130
+ error: ERROR_CODES.MERGE_CONFLICTS,
130
131
  detail: (r.stderr || r.stdout).trim(),
131
132
  };
132
133
  }
@@ -156,7 +157,7 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
156
157
  outcome: "conflicts",
157
158
  conflictStage: "rebase",
158
159
  conflictPaths: paths,
159
- error: "rebase_conflicts",
160
+ error: ERROR_CODES.REBASE_CONFLICTS,
160
161
  detail: (r.stderr || r.stdout).trim(),
161
162
  };
162
163
  }
@@ -166,7 +167,7 @@ async function rebaseSourceOntoInto(gitTop, into, source) {
166
167
  return {
167
168
  source,
168
169
  ok: false,
169
- error: "checkout_failed",
170
+ error: ERROR_CODES.CHECKOUT_FAILED,
170
171
  detail: (co.stderr || co.stdout).trim(),
171
172
  };
172
173
  }
@@ -253,28 +254,28 @@ export function registerGitMergeTool(server) {
253
254
  // --- Validate ref tokens early ---
254
255
  for (const s of args.sources) {
255
256
  if (!isSafeGitRefToken(s)) {
256
- return jsonRespond({ error: "unsafe_ref_token", ref: s });
257
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: s });
257
258
  }
258
259
  }
259
260
  if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
260
- return jsonRespond({ error: "unsafe_ref_token", ref: args.into });
261
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.into });
261
262
  }
262
263
  // --- Resolve destination ---
263
264
  const startBranch = await getCurrentBranch(gitTop);
264
265
  const into = args.into?.trim() || startBranch;
265
266
  if (!into) {
266
- return jsonRespond({ error: "into_detached_head" });
267
+ return jsonRespond({ error: ERROR_CODES.INTO_DETACHED_HEAD });
267
268
  }
268
269
  // --- Refuse dirty tree ---
269
270
  if (!(await isWorkingTreeClean(gitTop))) {
270
- return jsonRespond({ error: "working_tree_dirty" });
271
+ return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
271
272
  }
272
273
  // --- Ensure destination is checked out ---
273
274
  if (into !== startBranch) {
274
275
  const co = await spawnGitAsync(gitTop, ["checkout", into]);
275
276
  if (!co.ok) {
276
277
  return jsonRespond({
277
- error: "checkout_failed",
278
+ error: ERROR_CODES.CHECKOUT_FAILED,
278
279
  detail: (co.stderr || co.stdout).trim(),
279
280
  });
280
281
  }
@@ -282,7 +283,7 @@ export function registerGitMergeTool(server) {
282
283
  // Verify destination exists after checkout.
283
284
  const intoShaProbe = await resolveRef(gitTop, into);
284
285
  if (!intoShaProbe) {
285
- return jsonRespond({ error: "destination_not_found", ref: into });
286
+ return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: into });
286
287
  }
287
288
  // --- Merge each source sequentially ---
288
289
  const strategy = args.strategy ?? "auto";
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { gitRevParseHead, gitTopLevel } from "./git.js";
3
4
  import { validateRepoPath } from "./inventory.js";
4
5
  import { jsonRespond, spreadDefined } from "./json.js";
@@ -33,7 +34,7 @@ export function registerGitParityTool(server) {
33
34
  for (const workspaceRoot of pre.roots) {
34
35
  const top = gitTopLevel(workspaceRoot);
35
36
  if (!top) {
36
- const errPayload = { error: "not_a_git_repository", path: workspaceRoot };
37
+ const errPayload = { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: workspaceRoot };
37
38
  const err = jsonRespond(errPayload);
38
39
  if (args.format === "json") {
39
40
  results.push({
@@ -58,7 +59,7 @@ export function registerGitParityTool(server) {
58
59
  parityPresetSchemaVersion = applied.presetSchemaVersion;
59
60
  }
60
61
  if (!pairs?.length) {
61
- return jsonRespond({ error: "no_pairs" });
62
+ return jsonRespond({ error: ERROR_CODES.NO_PAIRS });
62
63
  }
63
64
  let allOk = true;
64
65
  const pairResults = [];
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
4
  import { getCurrentBranch, inferRemoteFromUpstream, isSafeGitRefToken } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined } from "./json.js";
@@ -42,16 +43,19 @@ export function registerGitPushTool(server) {
42
43
  const currentBranch = await getCurrentBranch(gitTop);
43
44
  const branch = args.branch?.trim() || currentBranch;
44
45
  if (!branch) {
45
- return jsonRespond({ error: "push_detached_head" });
46
+ return jsonRespond({ error: ERROR_CODES.PUSH_DETACHED_HEAD });
46
47
  }
47
48
  if (!isSafeGitRefToken(branch)) {
48
- return jsonRespond({ error: "unsafe_ref_token", ref: branch });
49
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: branch });
49
50
  }
50
51
  // --- Resolve remote ---
51
52
  let remote;
52
53
  if (args.remote?.trim()) {
53
54
  if (!isSafeGitUpstreamToken(args.remote.trim())) {
54
- return jsonRespond({ error: "unsafe_remote_token", remote: args.remote.trim() });
55
+ return jsonRespond({
56
+ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN,
57
+ remote: args.remote.trim(),
58
+ });
55
59
  }
56
60
  remote = args.remote.trim();
57
61
  }
@@ -64,7 +68,7 @@ export function registerGitPushTool(server) {
64
68
  const t = await inferRemoteFromUpstream(gitTop);
65
69
  if (!t.ok) {
66
70
  return jsonRespond({
67
- error: "push_no_upstream",
71
+ error: ERROR_CODES.PUSH_NO_UPSTREAM,
68
72
  branch,
69
73
  detail: t.detail,
70
74
  });
@@ -82,7 +86,7 @@ export function registerGitPushTool(server) {
82
86
  ok: false,
83
87
  branch,
84
88
  remote,
85
- error: "push_failed",
89
+ error: ERROR_CODES.PUSH_FAILED,
86
90
  detail: (pushResult.stderr || pushResult.stdout).trim(),
87
91
  });
88
92
  }