@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
@@ -1,6 +1,8 @@
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";
5
+ import { isSafeGitAncestorRef } from "./git-refs.js";
4
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
7
  import { requireGitAndRoots } from "./roots.js";
6
8
  import { WorkspacePickSchema } from "./schemas.js";
@@ -81,7 +83,7 @@ async function runGitLog(opts) {
81
83
  const r = await spawnGitAsync(top, logArgs);
82
84
  if (!r.ok) {
83
85
  return {
84
- error: "git_log_failed",
86
+ error: ERROR_CODES.GIT_LOG_FAILED,
85
87
  path: top,
86
88
  };
87
89
  }
@@ -221,23 +223,31 @@ export function registerGitLogTool(server) {
221
223
  // Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
222
224
  const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
223
225
  if (/[\n\r;|&`$<>]/.test(rawSince)) {
224
- return jsonRespond({ error: "invalid_since", since: rawSince });
226
+ return jsonRespond({ error: ERROR_CODES.INVALID_SINCE, since: rawSince });
225
227
  }
226
228
  // Validate paths — reject anything with null bytes or shell meta.
227
229
  // Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
228
230
  const rawPaths = args.paths ?? [];
229
231
  for (const p of rawPaths) {
230
232
  if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
231
- return jsonRespond({ error: "invalid_paths", path: p });
233
+ return jsonRespond({ error: ERROR_CODES.INVALID_PATHS, path: p });
232
234
  }
233
235
  }
236
+ // Validate branch — reject leading-dash and other injection attempts.
237
+ if (args.branch && !isSafeGitAncestorRef(args.branch)) {
238
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, branch: args.branch });
239
+ }
234
240
  const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
235
241
  // Fan out across roots.
236
242
  const jobs = pre.roots.map((rootInput) => ({ rootInput }));
237
243
  const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
238
244
  const top = gitTopLevel(rootInput);
239
245
  if (!top) {
240
- 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
+ };
241
251
  }
242
252
  const r = await runGitLog({
243
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,11 +34,11 @@ 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({
40
- workspace_root: workspaceRoot,
41
+ workspaceRoot: workspaceRoot,
41
42
  status: "MISMATCH",
42
43
  pairs: [{ label: "—", leftPath: "", rightPath: "", match: false, error: err }],
43
44
  });
@@ -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 = [];
@@ -114,7 +115,7 @@ export function registerGitParityTool(server) {
114
115
  }
115
116
  }
116
117
  results.push({
117
- workspace_root: top,
118
+ workspaceRoot: top,
118
119
  ...spreadDefined("presetSchemaVersion", parityPresetSchemaVersion),
119
120
  status: allOk ? "OK" : "MISMATCH",
120
121
  pairs: 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
  }
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { isSafeGitRefToken } from "./git-refs.js";
5
+ import { jsonRespond } from "./json.js";
6
+ import { requireSingleRepo } from "./roots.js";
7
+ import { WorkspacePickSchema } from "./schemas.js";
8
+ // ---------------------------------------------------------------------------
9
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+ const MAX_ENTRIES_HARD_CAP = 200;
12
+ const DEFAULT_MAX_ENTRIES = 30;
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Run git reflog for a single ref and return structured data.
18
+ * Uses NUL-delimited fields within each line for robust parsing.
19
+ */
20
+ async function runGitReflog(opts) {
21
+ const { top, ref, maxEntries } = opts;
22
+ // %h = short sha, %H = full sha, %gd = selector (HEAD@{0}), %gs = reflog subject
23
+ const reflogArgs = [
24
+ "reflog",
25
+ "show",
26
+ ref,
27
+ "--format=%h%x00%H%x00%gd%x00%gs",
28
+ `-n`,
29
+ String(maxEntries),
30
+ ];
31
+ const r = await spawnGitAsync(top, reflogArgs);
32
+ if (!r.ok) {
33
+ return {
34
+ error: ERROR_CODES.REFLOG_FAILED,
35
+ detail: (r.stderr || r.stdout || "git reflog failed").trim(),
36
+ };
37
+ }
38
+ const entries = [];
39
+ const lines = r.stdout.split("\n");
40
+ for (const line of lines) {
41
+ if (!line.trim())
42
+ continue;
43
+ const parts = line.split("\x00");
44
+ // Expect 4 fields: shaShort, shaFull, selector, message
45
+ if (parts.length < 4)
46
+ continue;
47
+ const [, shaFull, selector, message] = parts;
48
+ if (!shaFull)
49
+ continue;
50
+ entries.push({
51
+ sha: shaFull.trim(),
52
+ selector: (selector ?? "").trim(),
53
+ message: (message ?? "").trim(),
54
+ });
55
+ }
56
+ return { ref, entries };
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Markdown rendering
60
+ // ---------------------------------------------------------------------------
61
+ function renderReflogMarkdown(result) {
62
+ const lines = [];
63
+ lines.push(`## Reflog (${result.ref})`);
64
+ lines.push("");
65
+ if (result.entries.length === 0) {
66
+ lines.push("_(no reflog entries)_");
67
+ }
68
+ else {
69
+ for (const entry of result.entries) {
70
+ lines.push(`${entry.selector} ${entry.sha.slice(0, 7)} ${entry.message}`);
71
+ }
72
+ }
73
+ return lines.join("\n");
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Tool registration
77
+ // ---------------------------------------------------------------------------
78
+ export function registerGitReflogTool(server) {
79
+ server.addTool({
80
+ name: "git_reflog",
81
+ description: "Show the reflog for a ref (default HEAD). Returns a list of recent HEAD movements with selector (e.g. HEAD@{0}), full SHA, and message. Useful for recovering lost commits or inspecting reset/checkout history.",
82
+ annotations: {
83
+ readOnlyHint: true,
84
+ },
85
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
86
+ .pick({
87
+ workspaceRoot: true,
88
+ rootIndex: true,
89
+ format: true,
90
+ })
91
+ .extend({
92
+ ref: z
93
+ .string()
94
+ .optional()
95
+ .default("HEAD")
96
+ .describe("Ref whose reflog to show (branch name, HEAD, etc.). Default: HEAD."),
97
+ maxEntries: z
98
+ .number()
99
+ .int()
100
+ .min(1)
101
+ .max(MAX_ENTRIES_HARD_CAP)
102
+ .optional()
103
+ .default(DEFAULT_MAX_ENTRIES)
104
+ .describe(`Maximum reflog entries to return (hard cap ${MAX_ENTRIES_HARD_CAP}). Default ${DEFAULT_MAX_ENTRIES}.`),
105
+ }),
106
+ execute: async (args) => {
107
+ const pre = requireSingleRepo(server, args);
108
+ if (!pre.ok)
109
+ return jsonRespond(pre.error);
110
+ const top = pre.gitTop;
111
+ const ref = args.ref ?? "HEAD";
112
+ if (!isSafeGitRefToken(ref)) {
113
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
114
+ }
115
+ const maxEntries = Math.min(args.maxEntries ?? DEFAULT_MAX_ENTRIES, MAX_ENTRIES_HARD_CAP);
116
+ const result = await runGitReflog({ top, ref, maxEntries });
117
+ if ("error" in result) {
118
+ return jsonRespond(result);
119
+ }
120
+ if (args.format === "json") {
121
+ return jsonRespond(result);
122
+ }
123
+ return renderReflogMarkdown(result);
124
+ },
125
+ });
126
+ }
@@ -25,17 +25,24 @@ const PROTECTED_EXACT = new Set([
25
25
  "trunk",
26
26
  "prod",
27
27
  "production",
28
- "HEAD",
28
+ "head",
29
29
  ]);
30
30
  const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
31
31
  /** True when a branch name is on the protected list and must not be auto-deleted. */
32
32
  export function isProtectedBranch(name) {
33
- const t = name.trim();
34
- if (t === "")
33
+ // Normalize: trim whitespace, strip leading refs/heads/ prefix, then lowercase.
34
+ const trimmed = name.trim();
35
+ if (trimmed === "")
35
36
  return true;
36
- if (PROTECTED_EXACT.has(t))
37
+ const stripped = trimmed.startsWith("refs/heads/")
38
+ ? trimmed.slice("refs/heads/".length)
39
+ : trimmed;
40
+ const normalized = stripped.toLowerCase();
41
+ if (normalized === "")
37
42
  return true;
38
- return PROTECTED_PATTERN.test(t);
43
+ if (PROTECTED_EXACT.has(normalized))
44
+ return true;
45
+ return PROTECTED_PATTERN.test(normalized);
39
46
  }
40
47
  // ---------------------------------------------------------------------------
41
48
  // Ref/branch name validation (argv-safe subset of git's ref-format rules)
@@ -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 { isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined } from "./json.js";
@@ -30,12 +31,12 @@ export function registerGitResetSoftTool(server) {
30
31
  const { gitTop } = pre;
31
32
  // Validate ref — allow ancestor notation (~N, ^N).
32
33
  if (!isSafeGitAncestorRef(args.ref)) {
33
- return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
34
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
34
35
  }
35
36
  // Refuse when the working tree is dirty (unstaged or untracked changes).
36
37
  if (!(await isWorkingTreeClean(gitTop))) {
37
38
  return jsonRespond({
38
- error: "working_tree_dirty",
39
+ error: ERROR_CODES.WORKING_TREE_DIRTY,
39
40
  detail: "git_reset_soft requires a clean working tree. " +
40
41
  "Commit or stash pending changes first.",
41
42
  });
@@ -47,7 +48,7 @@ export function registerGitResetSoftTool(server) {
47
48
  const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
48
49
  if (!r.ok) {
49
50
  return jsonRespond({
50
- error: "reset_failed",
51
+ error: ERROR_CODES.RESET_FAILED,
51
52
  detail: (r.stderr || r.stdout).trim(),
52
53
  });
53
54
  }
@@ -1,5 +1,8 @@
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 { spawnGitAsync } from "./git.js";
5
+ import { isSafeGitAncestorRef } from "./git-refs.js";
3
6
  import { jsonRespond } from "./json.js";
4
7
  import { requireSingleRepo } from "./roots.js";
5
8
  import { WorkspacePickSchema } from "./schemas.js";
@@ -7,22 +10,35 @@ import { WorkspacePickSchema } from "./schemas.js";
7
10
  // Helpers
8
11
  // ---------------------------------------------------------------------------
9
12
  /**
10
- * Run git show for a single ref, optionally limiting to a specific path.
11
- * Returns commit message and diff (or file content if path is specified).
13
+ * Run git show for a single ref, optionally limiting to specific paths and/or
14
+ * showing only the --stat diffstat rather than the full patch.
15
+ * Returns commit message and diff/stat output.
12
16
  */
13
17
  async function runGitShow(opts) {
14
- const { top, ref, path } = opts;
15
- // Build git show args. Shows commit message + full diff (or file content when path given).
16
- const showArgs = ["show", ref];
17
- if (path) {
18
- // When path is specified, show that path at the ref without --no-patch
19
- // to get the full content at that ref
20
- showArgs.push("--", path);
18
+ const { top, ref, path, paths, stat } = opts;
19
+ // Merge single path + paths array into a unified list (deduped, order preserved).
20
+ const effectivePaths = [];
21
+ if (path)
22
+ effectivePaths.push(path);
23
+ if (paths) {
24
+ for (const p of paths) {
25
+ if (!effectivePaths.includes(p))
26
+ effectivePaths.push(p);
27
+ }
28
+ }
29
+ // Build git show args. Shows commit message + full diff (or --stat diffstat).
30
+ const showArgs = ["show"];
31
+ if (stat) {
32
+ showArgs.push("--stat");
33
+ }
34
+ showArgs.push(ref);
35
+ if (effectivePaths.length > 0) {
36
+ showArgs.push("--", ...effectivePaths);
21
37
  }
22
38
  const r = await spawnGitAsync(top, showArgs);
23
39
  if (!r.ok) {
24
40
  return {
25
- error: "git_show_failed",
41
+ error: ERROR_CODES.GIT_SHOW_FAILED,
26
42
  };
27
43
  }
28
44
  // Parse the output. For a commit, git show outputs:
@@ -30,10 +46,9 @@ async function runGitShow(opts) {
30
46
  // - Blank line
31
47
  // - Commit message (may contain multiple lines and blank lines)
32
48
  // - Blank line (separator before diff)
33
- // - Diff (if --no-patch not used) or file content
49
+ // - Diff or --stat diffstat section
34
50
  const output = r.stdout;
35
51
  let message = "";
36
- let diff = "";
37
52
  const lines = output.split("\n");
38
53
  let inHeader = true;
39
54
  let inMessage = false;
@@ -48,9 +63,14 @@ async function runGitShow(opts) {
48
63
  inMessage = true;
49
64
  continue;
50
65
  }
51
- // In message: collect until we see "diff --git" which marks the start of the diff section
52
66
  if (inMessage) {
53
- if (line.startsWith("diff --git")) {
67
+ // In stat mode: content starts at the first line that looks like a stat entry
68
+ // (indented file path) or the summary line "N files changed".
69
+ // In diff mode: content starts at "diff --git".
70
+ const isStatLine = stat &&
71
+ (line.match(/^\s+\S.*\|/) !== null || line.match(/^\s*\d+ files? changed/) !== null);
72
+ const isDiffLine = !stat && line.startsWith("diff --git");
73
+ if (isStatLine || isDiffLine) {
54
74
  inMessage = false;
55
75
  contentLines.push(line);
56
76
  }
@@ -64,16 +84,26 @@ async function runGitShow(opts) {
64
84
  }
65
85
  }
66
86
  message = messageLines.join("\n").trim();
67
- diff = contentLines.join("\n").trim();
87
+ const contentStr = contentLines.join("\n").trim();
68
88
  const result = {
69
89
  ref,
70
90
  message,
71
91
  };
72
- if (path) {
92
+ // Reflect single legacy path (for backward-compat) and new paths[] in result.
93
+ if (path && !paths) {
73
94
  result.path = path;
74
95
  }
75
- if (diff) {
76
- result.diff = diff;
96
+ else if (effectivePaths.length > 0) {
97
+ result.paths = effectivePaths;
98
+ }
99
+ if (stat) {
100
+ result.stat = true;
101
+ if (contentStr) {
102
+ result.statOutput = contentStr;
103
+ }
104
+ }
105
+ else if (contentStr) {
106
+ result.diff = contentStr;
77
107
  }
78
108
  return result;
79
109
  }
@@ -86,13 +116,27 @@ function renderShowMarkdown(result) {
86
116
  if (result.path) {
87
117
  lines.push(`_path: ${result.path}_`);
88
118
  }
119
+ else if (result.paths && result.paths.length > 0) {
120
+ lines.push(`_paths: ${result.paths.join(", ")}_`);
121
+ }
122
+ if (result.stat) {
123
+ lines.push("_mode: stat_");
124
+ }
89
125
  lines.push("");
90
126
  lines.push("## Commit message");
91
127
  lines.push("");
92
128
  lines.push("```");
93
129
  lines.push(result.message);
94
130
  lines.push("```");
95
- if (result.diff) {
131
+ if (result.stat && result.statOutput) {
132
+ lines.push("");
133
+ lines.push("## Stat");
134
+ lines.push("");
135
+ lines.push("```");
136
+ lines.push(result.statOutput);
137
+ lines.push("```");
138
+ }
139
+ else if (result.diff) {
96
140
  lines.push("");
97
141
  lines.push("## Diff");
98
142
  lines.push("");
@@ -108,7 +152,7 @@ function renderShowMarkdown(result) {
108
152
  export function registerGitShowTool(server) {
109
153
  server.addTool({
110
154
  name: "git_show",
111
- description: "Inspect commit content by ref/SHA. Returns commit message and diff (or file content at a specific path).",
155
+ description: "Inspect commit content by ref/SHA. Returns commit message and diff (or --stat diffstat when stat:true). Optionally filter to specific paths via path or paths[].",
112
156
  annotations: {
113
157
  readOnlyHint: true,
114
158
  },
@@ -119,21 +163,51 @@ export function registerGitShowTool(server) {
119
163
  format: true,
120
164
  })
121
165
  .extend({
122
- ref: z.string().describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
166
+ ref: z
167
+ .string()
168
+ .min(1)
169
+ .describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
123
170
  path: z
124
171
  .string()
125
172
  .optional()
126
- .describe("Optional file path to inspect at the ref. If provided, shows that path's content at the ref instead of the diff."),
173
+ .describe("Optional single file path to inspect at the ref. Merged with `paths` when both are provided."),
174
+ paths: z
175
+ .array(z.string())
176
+ .optional()
177
+ .describe("Optional list of file paths to filter the shown diff/stat. Merged with `path` when both are provided."),
178
+ stat: z
179
+ .boolean()
180
+ .optional()
181
+ .describe("When true, show --stat diffstat (files changed summary) instead of the full patch."),
127
182
  }),
128
183
  execute: async (args) => {
129
184
  const pre = requireSingleRepo(server, args);
130
185
  if (!pre.ok)
131
186
  return jsonRespond(pre.error);
132
187
  const top = pre.gitTop;
188
+ if (!isSafeGitAncestorRef(args.ref)) {
189
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
190
+ }
191
+ if (args.path !== undefined) {
192
+ const resolved = resolvePathForRepo(args.path, top);
193
+ if (!assertRelativePathUnderTop(args.path, resolved, top)) {
194
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: args.path });
195
+ }
196
+ }
197
+ if (Array.isArray(args.paths)) {
198
+ for (const p of args.paths) {
199
+ const resolved = resolvePathForRepo(p, top);
200
+ if (!assertRelativePathUnderTop(p, resolved, top)) {
201
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
202
+ }
203
+ }
204
+ }
133
205
  const result = await runGitShow({
134
206
  top,
135
207
  ref: args.ref,
136
208
  path: args.path,
209
+ paths: args.paths,
210
+ stat: args.stat,
137
211
  });
138
212
  if ("error" in result) {
139
213
  return jsonRespond(result);