@rethunk/mcp-multi-root-git 2.5.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 (41) hide show
  1. package/AGENTS.md +4 -4
  2. package/CHANGELOG.md +45 -1
  3. package/README.md +1 -1
  4. package/dist/server/batch-commit-tool.js +32 -10
  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 +10 -3
  10. package/dist/server/git-diff-tool.js +60 -13
  11. package/dist/server/git-fetch-tool.js +148 -13
  12. package/dist/server/git-inventory-tool.js +7 -6
  13. package/dist/server/git-log-tool.js +14 -4
  14. package/dist/server/git-merge-tool.js +15 -14
  15. package/dist/server/git-parity-tool.js +5 -4
  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-refs.js +12 -5
  19. package/dist/server/git-reset-soft-tool.js +4 -3
  20. package/dist/server/git-show-tool.js +96 -22
  21. package/dist/server/git-stash-tool.js +17 -15
  22. package/dist/server/git-tag-tool.js +8 -7
  23. package/dist/server/git-worktree-tool.js +8 -7
  24. package/dist/server/git.js +86 -7
  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/tool-parameter-schemas.js +9 -0
  30. package/dist/server/tools.js +6 -0
  31. package/dist/server.js +7 -0
  32. package/docs/mcp-tools.md +134 -12
  33. package/package.json +4 -3
  34. package/schemas/git_blame.json +52 -0
  35. package/schemas/git_branch_list.json +36 -0
  36. package/schemas/git_diff.json +14 -15
  37. package/schemas/git_diff_summary.json +0 -6
  38. package/schemas/git_reflog.json +44 -0
  39. package/schemas/git_show.json +13 -1
  40. package/schemas/index.json +15 -0
  41. package/tool-parameters.schema.json +153 -22
@@ -0,0 +1,140 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireSingleRepo } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Helpers
9
+ // ---------------------------------------------------------------------------
10
+ async function runBranchList(opts) {
11
+ const { top, includeRemotes } = opts;
12
+ // Local branches: name, full SHA, upstream (may be empty), HEAD marker (* or space)
13
+ const localR = await spawnGitAsync(top, [
14
+ "for-each-ref",
15
+ "--format=%(refname:short)%00%(objectname)%00%(upstream:short)%00%(HEAD)",
16
+ "refs/heads",
17
+ ]);
18
+ if (!localR.ok) {
19
+ return {
20
+ error: ERROR_CODES.BRANCH_LIST_FAILED,
21
+ detail: (localR.stderr || localR.stdout).trim(),
22
+ };
23
+ }
24
+ const branches = [];
25
+ const localLines = (localR.stdout || "").split("\n").filter((l) => l.length > 0);
26
+ for (const line of localLines) {
27
+ const parts = line.split("\x00");
28
+ const name = parts[0] ?? "";
29
+ const sha = parts[1] ?? "";
30
+ const upstream = parts[2] ?? "";
31
+ const headMarker = parts[3] ?? "";
32
+ if (!name || !sha)
33
+ continue;
34
+ branches.push({
35
+ name,
36
+ sha,
37
+ current: headMarker === "*",
38
+ ...spreadDefined("upstream", upstream || undefined),
39
+ });
40
+ }
41
+ if (!includeRemotes) {
42
+ return { branches };
43
+ }
44
+ // Remote branches: name, full SHA — skip symbolic origin/HEAD refs
45
+ const remoteR = await spawnGitAsync(top, [
46
+ "for-each-ref",
47
+ "--format=%(refname:short)%00%(objectname)",
48
+ "refs/remotes",
49
+ ]);
50
+ if (!remoteR.ok) {
51
+ return {
52
+ error: ERROR_CODES.BRANCH_LIST_FAILED,
53
+ detail: (remoteR.stderr || remoteR.stdout).trim(),
54
+ };
55
+ }
56
+ const remotes = [];
57
+ const remoteLines = (remoteR.stdout || "").split("\n").filter((l) => l.length > 0);
58
+ for (const line of remoteLines) {
59
+ const parts = line.split("\x00");
60
+ const name = parts[0] ?? "";
61
+ const sha = parts[1] ?? "";
62
+ // Skip symbolic origin/HEAD
63
+ if (!name || !sha || name.endsWith("/HEAD"))
64
+ continue;
65
+ remotes.push({ name, sha });
66
+ }
67
+ return {
68
+ branches,
69
+ ...spreadWhen(remotes.length > 0, { remotes }),
70
+ };
71
+ }
72
+ // ---------------------------------------------------------------------------
73
+ // Markdown rendering
74
+ // ---------------------------------------------------------------------------
75
+ function renderBranchListMarkdown(result) {
76
+ const lines = ["## Branches", ""];
77
+ if (result.branches.length === 0) {
78
+ lines.push("_(none)_");
79
+ }
80
+ else {
81
+ for (const b of result.branches) {
82
+ const prefix = b.current ? "* " : " ";
83
+ const upstream = b.upstream ? ` → ${b.upstream}` : "";
84
+ lines.push(`${prefix}**${b.name}**${upstream} (\`${b.sha}\`)`);
85
+ }
86
+ }
87
+ if (result.remotes && result.remotes.length > 0) {
88
+ lines.push("");
89
+ lines.push("## Remote branches");
90
+ lines.push("");
91
+ for (const r of result.remotes) {
92
+ lines.push(`- **${r.name}** (\`${r.sha}\`)`);
93
+ }
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // Tool registration
99
+ // ---------------------------------------------------------------------------
100
+ export function registerGitBranchListTool(server) {
101
+ server.addTool({
102
+ name: "git_branch_list",
103
+ description: "List local branches (and optionally remote-tracking branches) for a single git repository. " +
104
+ "Returns `{ branches: [{ name, sha, current, upstream? }], remotes?: [{ name, sha }] }`. " +
105
+ "The current branch is marked with `current: true`. Remote branches are included when `includeRemotes: true`.",
106
+ annotations: {
107
+ readOnlyHint: true,
108
+ },
109
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
110
+ .pick({
111
+ workspaceRoot: true,
112
+ rootIndex: true,
113
+ format: true,
114
+ })
115
+ .extend({
116
+ includeRemotes: z
117
+ .boolean()
118
+ .optional()
119
+ .default(false)
120
+ .describe("When true, also return remote-tracking branches from refs/remotes (excluding symbolic origin/HEAD)."),
121
+ }),
122
+ execute: async (args) => {
123
+ const pre = requireSingleRepo(server, args);
124
+ if (!pre.ok)
125
+ return jsonRespond(pre.error);
126
+ const { gitTop } = pre;
127
+ const result = await runBranchList({
128
+ top: gitTop,
129
+ includeRemotes: args.includeRemotes ?? false,
130
+ });
131
+ if ("error" in result) {
132
+ return jsonRespond(result);
133
+ }
134
+ if (args.format === "json") {
135
+ return jsonRespond(result);
136
+ }
137
+ return renderBranchListMarkdown(result);
138
+ },
139
+ });
140
+ }
@@ -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
  }
@@ -149,26 +150,26 @@ export function registerGitCherryPickTool(server) {
149
150
  const startBranch = await getCurrentBranch(gitTop);
150
151
  const onto = args.onto?.trim() || startBranch;
151
152
  if (!onto)
152
- return jsonRespond({ error: "onto_detached_head" });
153
+ return jsonRespond({ error: ERROR_CODES.ONTO_DETACHED_HEAD });
153
154
  if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
154
- return jsonRespond({ error: "unsafe_ref_token", ref: args.onto });
155
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.onto });
155
156
  }
156
157
  // --- Refuse dirty tree ---
157
158
  if (!(await isWorkingTreeClean(gitTop))) {
158
- return jsonRespond({ error: "working_tree_dirty" });
159
+ return jsonRespond({ error: ERROR_CODES.WORKING_TREE_DIRTY });
159
160
  }
160
161
  // --- Ensure destination is checked out ---
161
162
  if (onto !== startBranch) {
162
163
  const co = await spawnGitAsync(gitTop, ["checkout", onto]);
163
164
  if (!co.ok) {
164
165
  return jsonRespond({
165
- error: "checkout_failed",
166
+ error: ERROR_CODES.CHECKOUT_FAILED,
166
167
  detail: (co.stderr || co.stdout).trim(),
167
168
  });
168
169
  }
169
170
  }
170
171
  if (!(await resolveRef(gitTop, onto))) {
171
- return jsonRespond({ error: "destination_not_found", ref: onto });
172
+ return jsonRespond({ error: ERROR_CODES.DESTINATION_NOT_FOUND, ref: onto });
172
173
  }
173
174
  // --- Resolve each source ---
174
175
  const resolved = [];
@@ -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";
@@ -102,6 +103,12 @@ function extractFileInfo(header, body) {
102
103
  status = "renamed";
103
104
  const fromMatch = /^rename from (.+)$/m.exec(body);
104
105
  oldPath = fromMatch?.[1];
106
+ // Prefer the authoritative "rename to <path>" body line over the greedy
107
+ // header regex split, which mis-parses paths containing the literal " b/".
108
+ const toMatch = /^rename to (.+)$/m.exec(body);
109
+ if (toMatch?.[1]) {
110
+ bPath = toMatch[1];
111
+ }
105
112
  }
106
113
  const path = status === "deleted" ? aPath : bPath;
107
114
  return { path, oldPath, status };
@@ -178,7 +185,7 @@ export function registerGitDiffSummaryTool(server) {
178
185
  annotations: {
179
186
  readOnlyHint: true,
180
187
  },
181
- parameters: WorkspacePickSchema.extend({
188
+ parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
182
189
  range: z
183
190
  .string()
184
191
  .optional()
@@ -224,7 +231,7 @@ export function registerGitDiffSummaryTool(server) {
224
231
  const statResult = await spawnGitAsync(gitTop, ["diff", "--numstat", ...diffArgs]);
225
232
  if (!statResult.ok) {
226
233
  return jsonRespond({
227
- error: "git_diff_failed",
234
+ error: ERROR_CODES.GIT_DIFF_FAILED,
228
235
  detail: (statResult.stderr || statResult.stdout).trim(),
229
236
  });
230
237
  }
@@ -233,7 +240,7 @@ export function registerGitDiffSummaryTool(server) {
233
240
  const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
234
241
  if (!diffResult.ok) {
235
242
  return jsonRespond({
236
- error: "git_diff_failed",
243
+ error: ERROR_CODES.GIT_DIFF_FAILED,
237
244
  detail: (diffResult.stderr || diffResult.stdout).trim(),
238
245
  });
239
246
  }
@@ -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,13 +61,17 @@ 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
  },
64
- parameters: WorkspacePickSchema.extend({
71
+ parameters: WorkspacePickSchema.omit({
72
+ absoluteGitRoots: true,
73
+ allWorkspaceRoots: true,
74
+ }).extend({
65
75
  base: z
66
76
  .string()
67
77
  .optional()
@@ -75,23 +85,60 @@ export function registerGitDiffTool(server) {
75
85
  path: z
76
86
  .string()
77
87
  .optional()
78
- .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."),
79
96
  staged: z
80
97
  .boolean()
81
98
  .optional()
82
99
  .default(false)
83
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."),
84
109
  }),
85
110
  execute: async (args) => {
86
111
  const pre = requireSingleRepo(server, args);
87
112
  if (!pre.ok)
88
113
  return jsonRespond(pre.error);
89
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
+ }
90
136
  // Build git diff args
91
137
  const diffArgsResult = buildDiffArgs({
92
138
  base: args.base,
93
139
  head: args.head,
94
- path: args.path,
140
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
141
+ unified: typeof args.unified === "number" ? args.unified : undefined,
95
142
  staged: args.staged,
96
143
  });
97
144
  if (!diffArgsResult.ok) {
@@ -101,14 +148,14 @@ export function registerGitDiffTool(server) {
101
148
  const result = await spawnGitAsync(gitTop, diffArgsResult.args);
102
149
  if (!result.ok) {
103
150
  return jsonRespond({
104
- error: "git_diff_failed",
151
+ error: ERROR_CODES.GIT_DIFF_FAILED,
105
152
  detail: (result.stderr || result.stdout).trim(),
106
153
  });
107
154
  }
108
155
  const label = rangeLabel({
109
156
  base: args.base,
110
157
  head: args.head,
111
- path: args.path,
158
+ paths: dedupedPaths.length > 0 ? dedupedPaths : undefined,
112
159
  staged: args.staged,
113
160
  });
114
161
  if (args.format === "json") {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { spawnGitAsync } from "./git.js";
3
- import { jsonRespond } from "./json.js";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.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
  // ---------------------------------------------------------------------------
@@ -78,26 +134,82 @@ export function registerGitFetchTool(server) {
78
134
  const branch = args.branch?.trim();
79
135
  const prune = args.prune === true;
80
136
  const tags = args.tags === true;
81
- // Build git fetch command
82
- const fetchArgs = ["fetch"];
137
+ // Validate remote and branch to prevent argument injection.
138
+ if (!isSafeGitUpstreamToken(remote)) {
139
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REMOTE_TOKEN, remote });
140
+ }
141
+ if (branch && !isSafeGitUpstreamToken(branch)) {
142
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch });
143
+ }
144
+ // Build base fetch args (without --porcelain for now)
145
+ const baseArgs = ["fetch"];
83
146
  if (prune) {
84
- fetchArgs.push("--prune");
147
+ baseArgs.push("--prune");
85
148
  }
86
149
  if (tags) {
87
- fetchArgs.push("--tags");
150
+ baseArgs.push("--tags");
88
151
  }
89
- fetchArgs.push(remote);
152
+ baseArgs.push(remote);
90
153
  if (branch) {
91
- 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;
92
197
  }
93
- const result = await spawnGitAsync(gitTop, fetchArgs);
94
- const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
95
198
  const fetchResult = {
96
199
  ok: result.ok,
97
200
  remote,
98
201
  updatedRefs,
99
202
  newRefs,
100
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
+ }),
101
213
  };
102
214
  if (args.format === "json") {
103
215
  return jsonRespond(fetchResult);
@@ -110,19 +222,42 @@ export function registerGitFetchTool(server) {
110
222
  return lines.join("\n");
111
223
  }
112
224
  lines.push("", "**Status**: Success", "");
113
- 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) {
114
233
  lines.push("## Updated refs", "");
115
234
  for (const ref of updatedRefs) {
116
235
  lines.push(`- ${ref}`);
117
236
  }
118
237
  }
119
- 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) {
120
245
  lines.push("", "## New refs", "");
121
246
  for (const ref of newRefs) {
122
247
  lines.push(`- ${ref}`);
123
248
  }
124
249
  }
125
- 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()) {
126
261
  lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
127
262
  }
128
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,10 +55,10 @@ 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
- workspace_root: workspaceRoot,
61
+ workspaceRoot: workspaceRoot,
61
62
  ...(upstream.mode === "fixed" ? { upstream } : {}),
62
63
  entries: [
63
64
  makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
@@ -117,7 +118,7 @@ export function registerGitInventoryTool(server) {
117
118
  }
118
119
  if (args.format === "json") {
119
120
  allJson.push({
120
- workspace_root: top,
121
+ workspaceRoot: top,
121
122
  ...spreadDefined("presetSchemaVersion", presetSchemaVersion),
122
123
  ...spreadWhen(nestedRootsTruncated, {
123
124
  nestedRootsTruncated: true,