@rethunk/mcp-multi-root-git 2.6.0 → 2.8.1

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 (40) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +21 -0
  3. package/HUMANS.md +1 -1
  4. package/README.md +1 -1
  5. package/dist/server/batch-commit-tool.js +16 -20
  6. package/dist/server/error-codes.js +95 -0
  7. package/dist/server/git-blame-tool.js +227 -0
  8. package/dist/server/git-branch-list-tool.js +138 -0
  9. package/dist/server/git-cherry-pick-tool.js +22 -31
  10. package/dist/server/git-diff-summary-tool.js +6 -6
  11. package/dist/server/git-diff-tool.js +54 -18
  12. package/dist/server/git-fetch-tool.js +142 -14
  13. package/dist/server/git-inventory-tool.js +5 -4
  14. package/dist/server/git-log-tool.js +18 -19
  15. package/dist/server/git-merge-tool.js +21 -24
  16. package/dist/server/git-parity-tool.js +3 -2
  17. package/dist/server/git-push-tool.js +9 -5
  18. package/dist/server/git-reflog-tool.js +126 -0
  19. package/dist/server/git-reset-soft-tool.js +7 -9
  20. package/dist/server/git-show-tool.js +83 -23
  21. package/dist/server/git-stash-tool.js +5 -6
  22. package/dist/server/git-tag-tool.js +8 -7
  23. package/dist/server/git-worktree-tool.js +14 -16
  24. package/dist/server/git.js +70 -4
  25. package/dist/server/list-presets-tool.js +2 -1
  26. package/dist/server/presets-resource.js +3 -2
  27. package/dist/server/presets.js +11 -5
  28. package/dist/server/roots.js +11 -10
  29. package/dist/server/schemas.js +4 -4
  30. package/dist/server/tool-parameter-schemas.js +9 -0
  31. package/dist/server/tools.js +6 -0
  32. package/docs/mcp-tools.md +120 -7
  33. package/package.json +10 -13
  34. package/schemas/git_blame.json +52 -0
  35. package/schemas/git_branch_list.json +36 -0
  36. package/schemas/git_diff.json +14 -1
  37. package/schemas/git_reflog.json +44 -0
  38. package/schemas/git_show.json +12 -1
  39. package/schemas/index.json +15 -0
  40. package/tool-parameters.schema.json +236 -86
@@ -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 { commitListBetween, conflictPaths, getCurrentBranch, isContentEquivalentlyMergedInto, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -30,12 +31,12 @@ async function branchExists(gitTop, name) {
30
31
  async function resolveSource(gitTop, onto, raw) {
31
32
  if (raw.includes("..")) {
32
33
  if (!isSafeGitRangeToken(raw)) {
33
- return { error: "unsafe_ref_token", raw };
34
+ return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
34
35
  }
35
36
  const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
36
37
  if (!r.ok) {
37
38
  return {
38
- error: "range_resolution_failed",
39
+ error: ERROR_CODES.RANGE_RESOLUTION_FAILED,
39
40
  detail: (r.stderr || r.stdout).trim(),
40
41
  raw,
41
42
  };
@@ -47,18 +48,18 @@ async function resolveSource(gitTop, onto, raw) {
47
48
  return { raw, kind: "range", commits };
48
49
  }
49
50
  if (!isSafeGitRefToken(raw)) {
50
- return { error: "unsafe_ref_token", raw };
51
+ return { error: ERROR_CODES.UNSAFE_REF_TOKEN, raw };
51
52
  }
52
53
  if (await branchExists(gitTop, raw)) {
53
54
  const commits = await commitListBetween(gitTop, onto, raw);
54
55
  if (commits === null) {
55
- return { error: "range_resolution_failed", raw };
56
+ return { error: ERROR_CODES.RANGE_RESOLUTION_FAILED, raw };
56
57
  }
57
58
  return { raw, kind: "branch", commits };
58
59
  }
59
60
  const sha = await resolveRef(gitTop, raw);
60
61
  if (!sha) {
61
- return { error: "source_not_found", raw };
62
+ return { error: ERROR_CODES.SOURCE_NOT_FOUND, raw };
62
63
  }
63
64
  return { raw, kind: "sha", commits: [sha] };
64
65
  }
@@ -93,14 +94,11 @@ async function filterAndDedupe(gitTop, onto, resolved) {
93
94
  export function registerGitCherryPickTool(server) {
94
95
  server.addTool({
95
96
  name: "git_cherry_pick",
96
- description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
97
- "`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
98
- "Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
99
- "stops on the first conflict and reports paths. Optional flags auto-delete source " +
100
- "branches and worktrees after success; deletion uses patch-id equivalence by default " +
101
- "(content-identical commits with different SHAs are treated as merged, which is the " +
102
- "normal cherry-pick outcome). Pass `strictMergedRefEquality: true` for strict `git branch -d` " +
103
- "ancestry semantics. Protected branch names always skipped.",
97
+ description: "Cherry-pick commits from one or more sources onto a destination. Sources: SHAs, `A..B` ranges, " +
98
+ "or branch names (expanded to `onto..<branch>`, oldest-first). Already-reachable commits skipped. " +
99
+ "Refuses on dirty tree; stops on first conflict. Optional flags delete source branches/worktrees " +
100
+ "after success using patch-id equivalence (set `strictMergedRefEquality: true` for strict ancestry). " +
101
+ "Protected names always skipped.",
104
102
  annotations: {
105
103
  readOnlyHint: false,
106
104
  destructiveHint: false,
@@ -111,8 +109,7 @@ export function registerGitCherryPickTool(server) {
111
109
  .array(z.string().min(1))
112
110
  .min(1)
113
111
  .max(50)
114
- .describe("Sources to cherry-pick: SHA, `A..B` range, or branch name. Branch sources " +
115
- "resolve to `onto..<branch>` (only commits missing from destination)."),
112
+ .describe("Sources: SHA, `A..B` range, or branch name (resolves to `onto..<branch>`)."),
116
113
  onto: z
117
114
  .string()
118
115
  .optional()
@@ -121,24 +118,18 @@ export function registerGitCherryPickTool(server) {
121
118
  .boolean()
122
119
  .optional()
123
120
  .default(false)
124
- .describe("After all commits apply, delete each branch-kind source locally " +
125
- "(`git branch -d`) when it is fully merged into the destination. " +
126
- "Protected names always skipped; never touches remote refs."),
121
+ .describe("Delete branch-kind sources locally after success. Protected names and remote refs unaffected."),
127
122
  deleteMergedWorktrees: z
128
123
  .boolean()
129
124
  .optional()
130
125
  .default(false)
131
- .describe("After success, remove any local worktree attached to a branch-kind source " +
132
- "(`git worktree remove`). Protected tails always skipped."),
126
+ .describe("Remove local worktrees on branch-kind sources after success. Protected tails skipped."),
133
127
  strictMergedRefEquality: z
134
128
  .boolean()
135
129
  .optional()
136
130
  .default(false)
137
- .describe("When false (default), branch deletion uses patch-id equivalence: a source branch " +
138
- "is deleted when every commit it contains has a content-equivalent commit on the " +
139
- "destination (same diff, different SHA — the normal cherry-pick outcome). " +
140
- "Set to true to require strict ref ancestry (`git branch -d` semantics), which " +
141
- "will refuse deletion after a cherry-pick because the SHA differs."),
131
+ .describe("false (default): delete branch when every commit is content-equivalent on destination (patch-id, normal cherry-pick outcome). " +
132
+ "true: require strict ref ancestry (`git branch -d` semantics — will refuse after cherry-pick due to SHA mismatch)."),
142
133
  }),
143
134
  execute: async (args) => {
144
135
  const pre = requireSingleRepo(server, args);
@@ -149,26 +140,26 @@ export function registerGitCherryPickTool(server) {
149
140
  const startBranch = await getCurrentBranch(gitTop);
150
141
  const onto = args.onto?.trim() || startBranch;
151
142
  if (!onto)
152
- return jsonRespond({ error: "onto_detached_head" });
143
+ return jsonRespond({ error: ERROR_CODES.ONTO_DETACHED_HEAD });
153
144
  if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
154
- return jsonRespond({ error: "unsafe_ref_token", ref: args.onto });
145
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.onto });
155
146
  }
156
147
  // --- Refuse dirty tree ---
157
148
  if (!(await isWorkingTreeClean(gitTop))) {
158
- return jsonRespond({ error: "working_tree_dirty" });
149
+ return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
159
150
  }
160
151
  // --- Ensure destination is checked out ---
161
152
  if (onto !== startBranch) {
162
153
  const co = await spawnGitAsync(gitTop, ["checkout", onto]);
163
154
  if (!co.ok) {
164
155
  return jsonRespond({
165
- error: "checkout_failed",
156
+ error: ERROR_CODES.CHECKOUT_FAILED,
166
157
  detail: (co.stderr || co.stdout).trim(),
167
158
  });
168
159
  }
169
160
  }
170
161
  if (!(await resolveRef(gitTop, onto))) {
171
- return jsonRespond({ error: "destination_not_found", ref: onto });
162
+ return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: onto });
172
163
  }
173
164
  // --- Resolve each source ---
174
165
  const resolved = [];
@@ -217,7 +208,7 @@ export function registerGitCherryPickTool(server) {
217
208
  if (allOk) {
218
209
  for (let i = 0; i < perSourceReport.length; i++) {
219
210
  const src = perSourceReport[i];
220
- if (!src || src.kind !== "branch")
211
+ if (src?.kind !== "branch")
221
212
  continue;
222
213
  if (isProtectedBranch(src.raw))
223
214
  continue;
@@ -1,5 +1,6 @@
1
1
  import { matchesGlob } from "node:path";
2
2
  import { z } from "zod";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
4
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
6
  import { requireSingleRepo } from "./roots.js";
@@ -178,9 +179,8 @@ function rangeLabel(range, diffArgs) {
178
179
  export function registerGitDiffSummaryTool(server) {
179
180
  server.addTool({
180
181
  name: "git_diff_summary",
181
- description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
182
- "truncated to configurable line limits, with noise files (lock files, dist, etc.) excluded by default. " +
183
- "Use `range` to target staged, HEAD, or any revision range.",
182
+ description: "Structured diff viewer: per-file diffs with counts, truncated to configurable limits. " +
183
+ "Noise files (lock files, dist, etc.) excluded by default. Use `range` to target staged, HEAD, or a revision range.",
184
184
  annotations: {
185
185
  readOnlyHint: true,
186
186
  },
@@ -213,7 +213,7 @@ export function registerGitDiffSummaryTool(server) {
213
213
  excludePatterns: z
214
214
  .array(z.string())
215
215
  .optional()
216
- .describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
216
+ .describe("Glob patterns to exclude. Default: lock files, dist, vendor, etc."),
217
217
  }),
218
218
  execute: async (args) => {
219
219
  const pre = requireSingleRepo(server, args);
@@ -230,7 +230,7 @@ export function registerGitDiffSummaryTool(server) {
230
230
  const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
231
231
  if (!statResult.ok) {
232
232
  return jsonRespond({
233
- error: "git_diff_failed",
233
+ error: ERROR_CODES.GIT_DIFF_FAILED,
234
234
  detail: (statResult.stderr || statResult.stdout).trim(),
235
235
  });
236
236
  }
@@ -239,7 +239,7 @@ export function registerGitDiffSummaryTool(server) {
239
239
  const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
240
240
  if (!diffResult.ok) {
241
241
  return jsonRespond({
242
- error: "git_diff_failed",
242
+ error: ERROR_CODES.GIT_DIFF_FAILED,
243
243
  detail: (diffResult.stderr || diffResult.stdout).trim(),
244
244
  });
245
245
  }
@@ -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,8 @@ 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. " +
59
- "Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
60
- "and `path` to scope to a specific file.",
64
+ description: "Raw diff text for scoped file(s) or range. `staged: true` for staged changes, " +
65
+ "`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines.",
61
66
  annotations: {
62
67
  readOnlyHint: true,
63
68
  },
@@ -68,33 +73,64 @@ export function registerGitDiffTool(server) {
68
73
  base: z
69
74
  .string()
70
75
  .optional()
71
- .describe('Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
72
- "If omitted and `staged: false`, shows unstaged changes."),
76
+ .describe('Base ref (e.g. "main", "HEAD~3"). Omit for unstaged changes.'),
73
77
  head: z
74
78
  .string()
75
79
  .optional()
76
- .describe('Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
77
- "Only used if `base` is provided."),
80
+ .describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
78
81
  path: z
79
82
  .string()
80
83
  .optional()
81
- .describe('Scope diff to a single file path (e.g. "src/main.ts").'),
84
+ .describe("Scope to a single file. Unioned with `paths` if both given."),
85
+ paths: z
86
+ .array(z.string())
87
+ .optional()
88
+ .describe("Scope to multiple files (must be within repo root). Unioned with `path` if both given."),
82
89
  staged: z
83
90
  .boolean()
84
91
  .optional()
85
92
  .default(false)
86
- .describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
93
+ .describe("Show staged changes (`git diff --staged`). Ignored if `base` is set."),
94
+ unified: z
95
+ .number()
96
+ .int()
97
+ .min(0)
98
+ .max(100)
99
+ .optional()
100
+ .describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
87
101
  }),
88
102
  execute: async (args) => {
89
103
  const pre = requireSingleRepo(server, args);
90
104
  if (!pre.ok)
91
105
  return jsonRespond(pre.error);
92
106
  const gitTop = pre.gitTop;
107
+ // Union path + paths, trim, dedup
108
+ const rawPaths = [];
109
+ if (args.path && typeof args.path === "string" && args.path.trim()) {
110
+ rawPaths.push(args.path.trim());
111
+ }
112
+ if (Array.isArray(args.paths)) {
113
+ for (const p of args.paths) {
114
+ if (typeof p === "string" && p.trim()) {
115
+ rawPaths.push(p.trim());
116
+ }
117
+ }
118
+ }
119
+ // Dedup preserving order
120
+ const dedupedPaths = [...new Set(rawPaths)];
121
+ // Confine each path within the repo
122
+ for (const p of dedupedPaths) {
123
+ const resolved = resolvePathForRepo(p, gitTop);
124
+ if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
125
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
126
+ }
127
+ }
93
128
  // Build git diff args
94
129
  const diffArgsResult = buildDiffArgs({
95
130
  base: args.base,
96
131
  head: args.head,
97
- path: args.path,
132
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
133
+ unified: typeof args.unified === "number" ? args.unified : undefined,
98
134
  staged: args.staged,
99
135
  });
100
136
  if (!diffArgsResult.ok) {
@@ -104,14 +140,14 @@ export function registerGitDiffTool(server) {
104
140
  const result = await spawnGitAsync(gitTop, diffArgsResult.args);
105
141
  if (!result.ok) {
106
142
  return jsonRespond({
107
- error: "git_diff_failed",
143
+ error: ERROR_CODES.GIT_DIFF_FAILED,
108
144
  detail: (result.stderr || result.stdout).trim(),
109
145
  });
110
146
  }
111
147
  const label = rangeLabel({
112
148
  base: args.base,
113
149
  head: args.head,
114
- path: args.path,
150
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
115
151
  staged: args.staged,
116
152
  });
117
153
  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,