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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/AGENTS.md +23 -19
  2. package/CHANGELOG.md +145 -39
  3. package/HUMANS.md +23 -42
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +203 -53
  7. package/dist/server/error-codes.js +31 -16
  8. package/dist/server/git-blame-tool.js +66 -19
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +221 -12
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +39 -28
  13. package/dist/server/git-diff-tool.js +56 -31
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +30 -10
  16. package/dist/server/git-log-tool.js +37 -6
  17. package/dist/server/git-merge-tool.js +71 -13
  18. package/dist/server/git-parity-tool.js +15 -6
  19. package/dist/server/git-push-tool.js +4 -2
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-reset-soft-tool.js +1 -1
  22. package/dist/server/git-revert-tool.js +160 -0
  23. package/dist/server/git-show-tool.js +5 -10
  24. package/dist/server/git-stash-tool.js +112 -78
  25. package/dist/server/git-status-tool.js +2 -2
  26. package/dist/server/git-tag-tool.js +8 -13
  27. package/dist/server/git-worktree-tool.js +67 -59
  28. package/dist/server/git.js +116 -24
  29. package/dist/server/inventory.js +90 -32
  30. package/dist/server/list-presets-tool.js +2 -8
  31. package/dist/server/presets-resource.js +37 -20
  32. package/dist/server/presets.js +87 -15
  33. package/dist/server/roots.js +52 -79
  34. package/dist/server/schemas.js +18 -19
  35. package/dist/server/test-harness.js +11 -4
  36. package/dist/server/tool-parameter-schemas.js +47 -58
  37. package/dist/server/tools.js +36 -17
  38. package/dist/server.js +1 -1
  39. package/docs/install.md +52 -5
  40. package/docs/mcp-tools.md +472 -284
  41. package/package.json +6 -6
  42. package/schemas/batch_commit.json +5 -17
  43. package/schemas/git_blame.json +13 -11
  44. package/schemas/git_branch.json +54 -0
  45. package/schemas/git_cherry_pick.json +13 -15
  46. package/schemas/git_cherry_pick_continue.json +34 -0
  47. package/schemas/git_conflicts.json +38 -0
  48. package/schemas/git_diff.json +12 -10
  49. package/schemas/git_diff_summary.json +1 -15
  50. package/schemas/git_grep.json +85 -0
  51. package/schemas/git_inventory.json +32 -23
  52. package/schemas/git_log.json +20 -24
  53. package/schemas/git_merge.json +3 -15
  54. package/schemas/git_parity.json +13 -23
  55. package/schemas/git_push.json +1 -13
  56. package/schemas/git_reset_soft.json +1 -13
  57. package/schemas/git_revert.json +47 -0
  58. package/schemas/git_show.json +2 -8
  59. package/schemas/git_stash_apply.json +2 -8
  60. package/schemas/git_stash_push.json +47 -0
  61. package/schemas/git_status.json +13 -23
  62. package/schemas/git_tag.json +1 -7
  63. package/schemas/git_worktree_add.json +2 -14
  64. package/schemas/git_worktree_remove.json +2 -14
  65. package/schemas/index.json +28 -23
  66. package/schemas/list_presets.json +13 -23
  67. package/tool-parameters.schema.json +407 -423
  68. package/dist/server/git-branch-list-tool.js +0 -138
  69. package/dist/server/git-fetch-tool.js +0 -266
  70. package/dist/server/git-reflog-tool.js +0 -126
  71. package/schemas/git_branch_list.json +0 -36
  72. package/schemas/git_fetch.json +0 -52
  73. package/schemas/git_reflog.json +0 -44
  74. package/schemas/git_stash_list.json +0 -30
  75. package/schemas/git_worktree_list.json +0 -30
@@ -0,0 +1,230 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import { z } from "zod";
4
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
5
+ import { ERROR_CODES } from "./error-codes.js";
6
+ import { spawnGitAsync } from "./git.js";
7
+ import { conflictPaths } from "./git-refs.js";
8
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
9
+ import { requireSingleRepo } from "./roots.js";
10
+ import { WorkspacePickSchema } from "./schemas.js";
11
+ // ---------------------------------------------------------------------------
12
+ // Operation-state detection
13
+ // ---------------------------------------------------------------------------
14
+ /** Resolve the repo's git directory (handles worktrees, where it is not literally `<top>/.git`). */
15
+ async function resolveGitDir(gitTop) {
16
+ const r = await spawnGitAsync(gitTop, ["rev-parse", "--git-dir"]);
17
+ if (!r.ok)
18
+ return null;
19
+ const raw = r.stdout.trim();
20
+ if (!raw)
21
+ return null;
22
+ return isAbsolute(raw) ? raw : join(gitTop, raw);
23
+ }
24
+ /** Detect the in-progress operation, if any, via marker files/dirs under the git dir. */
25
+ export async function detectConflictState(gitTop) {
26
+ const gitDir = await resolveGitDir(gitTop);
27
+ if (!gitDir)
28
+ return undefined;
29
+ if (existsSync(join(gitDir, "MERGE_HEAD")))
30
+ return "merge";
31
+ if (existsSync(join(gitDir, "CHERRY_PICK_HEAD")))
32
+ return "cherry-pick";
33
+ if (existsSync(join(gitDir, "REVERT_HEAD")))
34
+ return "revert";
35
+ if (existsSync(join(gitDir, "rebase-merge")) || existsSync(join(gitDir, "rebase-apply"))) {
36
+ return "rebase";
37
+ }
38
+ return undefined;
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // Conflict marker parsing
42
+ // ---------------------------------------------------------------------------
43
+ const OURS_MARKER = "<<<<<<<";
44
+ const BASE_MARKER = "|||||||";
45
+ const SPLIT_MARKER = "=======";
46
+ const THEIRS_MARKER = ">>>>>>>";
47
+ function labelAfterMarker(line, marker) {
48
+ const rest = line.slice(marker.length).trim();
49
+ return rest.length > 0 ? rest : undefined;
50
+ }
51
+ /**
52
+ * Parse `<<<<<<<`/`|||||||`/`=======`/`>>>>>>>` conflict markers out of file text.
53
+ * Only the first `maxLinesPerFile` lines are scanned; when the file is longer,
54
+ * `truncated: true` is reported and any hunk still open at the cutoff is dropped
55
+ * rather than emitted half-formed. An incomplete hunk at EOF (corrupt/missing
56
+ * closing marker within the scan window) also sets `truncated: true`.
57
+ */
58
+ export function parseConflictHunks(text, maxLinesPerFile) {
59
+ const allLines = text.split("\n");
60
+ const truncatedByCap = allLines.length > maxLinesPerFile;
61
+ const lines = truncatedByCap ? allLines.slice(0, maxLinesPerFile) : allLines;
62
+ const hunks = [];
63
+ let state = "outside";
64
+ let cur = null;
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i] ?? "";
67
+ const lineNo = i + 1;
68
+ if (line.startsWith(OURS_MARKER)) {
69
+ cur = {
70
+ startLine: lineNo,
71
+ oursLines: [],
72
+ baseLines: [],
73
+ theirsLines: [],
74
+ oursLabel: labelAfterMarker(line, OURS_MARKER),
75
+ };
76
+ state = "ours";
77
+ continue;
78
+ }
79
+ if (state === "ours" && line.startsWith(BASE_MARKER)) {
80
+ state = "base";
81
+ continue;
82
+ }
83
+ if ((state === "ours" || state === "base") && line.startsWith(SPLIT_MARKER)) {
84
+ state = "theirs";
85
+ continue;
86
+ }
87
+ if (state === "theirs" && line.startsWith(THEIRS_MARKER) && cur) {
88
+ cur.theirsLabel = labelAfterMarker(line, THEIRS_MARKER);
89
+ hunks.push({
90
+ startLine: cur.startLine,
91
+ ours: cur.oursLines.join("\n"),
92
+ theirs: cur.theirsLines.join("\n"),
93
+ ...spreadWhen(cur.baseLines.length > 0, { base: cur.baseLines.join("\n") }),
94
+ ...spreadDefined("oursLabel", cur.oursLabel),
95
+ ...spreadDefined("theirsLabel", cur.theirsLabel),
96
+ });
97
+ cur = null;
98
+ state = "outside";
99
+ continue;
100
+ }
101
+ if (!cur)
102
+ continue;
103
+ if (state === "ours")
104
+ cur.oursLines.push(line);
105
+ else if (state === "base")
106
+ cur.baseLines.push(line);
107
+ else if (state === "theirs")
108
+ cur.theirsLines.push(line);
109
+ }
110
+ // Incomplete open hunk at EOF (or at the line-cap) → flag truncated so callers
111
+ // know markers were present but not fully parsed.
112
+ const truncated = truncatedByCap || cur !== null;
113
+ return { hunks, truncated };
114
+ }
115
+ /** Conservative binary sniff: a NUL byte in the first 8000 bytes. */
116
+ function isLikelyBinary(buf) {
117
+ const len = Math.min(buf.length, 8000);
118
+ for (let i = 0; i < len; i++) {
119
+ if (buf[i] === 0)
120
+ return true;
121
+ }
122
+ return false;
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // Per-file resolution
126
+ // ---------------------------------------------------------------------------
127
+ export function readConflictFile(gitTop, relPath, maxLinesPerFile) {
128
+ const resolved = resolvePathForRepo(relPath, gitTop);
129
+ if (!assertRelativePathUnderTop(relPath, resolved, gitTop)) {
130
+ return { path: relPath, error: ERROR_CODES.PATH_ESCAPES_REPO };
131
+ }
132
+ let buf;
133
+ try {
134
+ buf = readFileSync(resolved);
135
+ }
136
+ catch {
137
+ return { path: relPath };
138
+ }
139
+ if (isLikelyBinary(buf)) {
140
+ return { path: relPath };
141
+ }
142
+ const { hunks, truncated } = parseConflictHunks(buf.toString("utf8"), maxLinesPerFile);
143
+ return {
144
+ path: relPath,
145
+ ...spreadWhen(hunks.length > 0, { hunks }),
146
+ ...spreadWhen(truncated, { truncated: true }),
147
+ };
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // Markdown rendering
151
+ // ---------------------------------------------------------------------------
152
+ function renderConflictsMarkdown(result) {
153
+ const lines = ["# Git conflicts"];
154
+ if (result.state)
155
+ lines.push(`_state: ${result.state}_`);
156
+ lines.push("");
157
+ if (result.files.length === 0) {
158
+ lines.push("_(no conflicts)_");
159
+ return lines.join("\n");
160
+ }
161
+ for (const f of result.files) {
162
+ lines.push(`## ${f.path}`);
163
+ if (f.error)
164
+ lines.push(`_error: ${f.error}_`);
165
+ if (f.truncated)
166
+ lines.push("_(truncated)_");
167
+ if (!f.hunks || f.hunks.length === 0) {
168
+ lines.push("_(no parsed hunks — unreadable, binary, or no markers found)_", "");
169
+ continue;
170
+ }
171
+ for (const h of f.hunks) {
172
+ lines.push(`### hunk @ line ${h.startLine}`);
173
+ lines.push(`**ours${h.oursLabel ? ` (${h.oursLabel})` : ""}:**`, "```", h.ours, "```");
174
+ if (h.base !== undefined) {
175
+ lines.push("**base:**", "```", h.base, "```");
176
+ }
177
+ lines.push(`**theirs${h.theirsLabel ? ` (${h.theirsLabel})` : ""}:**`, "```", h.theirs, "```");
178
+ }
179
+ lines.push("");
180
+ }
181
+ return lines.join("\n").trimEnd();
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Tool registration
185
+ // ---------------------------------------------------------------------------
186
+ export function registerGitConflictsTool(server) {
187
+ server.addTool({
188
+ name: "git_conflicts",
189
+ description: "Inspect unresolved merge conflicts after git_merge/git_cherry_pick reports them. " +
190
+ "Reports the in-progress operation (merge/cherry-pick/revert/rebase, when detectable) and, " +
191
+ "per conflicted file, the parsed ours/theirs (and base, for diff3-style markers) hunks.",
192
+ annotations: {
193
+ readOnlyHint: true,
194
+ },
195
+ parameters: WorkspacePickSchema.extend({
196
+ withHunks: z
197
+ .boolean()
198
+ .optional()
199
+ .default(true)
200
+ .describe("Parse conflict-marker hunks per file. Set false for just the path list."),
201
+ maxLinesPerFile: z
202
+ .number()
203
+ .int()
204
+ .min(1)
205
+ .max(2000)
206
+ .optional()
207
+ .default(200)
208
+ .describe("Cap on lines scanned per file before marking `truncated: true`."),
209
+ }),
210
+ execute: async (args) => {
211
+ const pre = requireSingleRepo(server, args);
212
+ if (!pre.ok)
213
+ return jsonRespond(pre.error);
214
+ const gitTop = pre.gitTop;
215
+ const state = await detectConflictState(gitTop);
216
+ const paths = await conflictPaths(gitTop);
217
+ const withHunks = args.withHunks !== false;
218
+ const maxLinesPerFile = typeof args.maxLinesPerFile === "number" ? args.maxLinesPerFile : 200;
219
+ const files = paths.map((p) => withHunks ? readConflictFile(gitTop, p, maxLinesPerFile) : { path: p });
220
+ const result = {
221
+ ...spreadDefined("state", state),
222
+ files,
223
+ };
224
+ if (args.format === "json") {
225
+ return jsonRespond(result);
226
+ }
227
+ return renderConflictsMarkdown(result);
228
+ },
229
+ });
230
+ }
@@ -1,7 +1,8 @@
1
1
  import { matchesGlob } from "node:path";
2
2
  import { z } from "zod";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
- import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
4
+ import { spawnGitAsync } from "./git.js";
5
+ import { isSafeGitRangeToken } from "./git-refs.js";
5
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
7
  import { requireSingleRepo } from "./roots.js";
7
8
  import { WorkspacePickSchema } from "./schemas.js";
@@ -27,18 +28,26 @@ const DEFAULT_EXCLUDE_PATTERNS = [
27
28
  /**
28
29
  * Parse `git diff --numstat` output into exact per-file counts.
29
30
  * Format per line: "<additions>\t<deletions>\t<path>"
31
+ * Renames emit "<additions>\t<deletions>\told => new" — keyed by the new path
32
+ * so lookups from extractFileInfo (which uses the destination path) succeed.
30
33
  * Binary files emit "-\t-\t<path>" and are recorded as 0/0.
31
34
  */
32
- function parseNumstatOutput(numstat) {
35
+ export function parseNumstatOutput(numstat) {
33
36
  const result = new Map();
34
37
  for (const line of numstat.split("\n")) {
35
38
  const parts = line.split("\t");
36
39
  if (parts.length < 3)
37
40
  continue;
38
41
  const [addStr, delStr, ...pathParts] = parts;
39
- const filePath = pathParts.join("\t");
42
+ let filePath = pathParts.join("\t");
40
43
  if (!filePath)
41
44
  continue;
45
+ // Rename numstat: "old/path => new/path" — index under the new path.
46
+ const renameSep = " => ";
47
+ const renameIdx = filePath.indexOf(renameSep);
48
+ if (renameIdx >= 0) {
49
+ filePath = filePath.slice(renameIdx + renameSep.length);
50
+ }
42
51
  const additions = addStr === "-" ? 0 : Number.parseInt(addStr ?? "0", 10);
43
52
  const deletions = delStr === "-" ? 0 : Number.parseInt(delStr ?? "0", 10);
44
53
  if (!Number.isNaN(additions) && !Number.isNaN(deletions)) {
@@ -51,7 +60,7 @@ function parseNumstatOutput(numstat) {
51
60
  * Parse `git diff` output into per-file chunks.
52
61
  * Splits on "diff --git a/..." lines.
53
62
  */
54
- function parseDiffOutput(diff) {
63
+ export function parseDiffOutput(diff) {
55
64
  const chunks = [];
56
65
  // Each file section starts with "diff --git"
57
66
  const parts = diff.split(/(?=^diff --git )/m);
@@ -70,7 +79,7 @@ function parseDiffOutput(diff) {
70
79
  * Header: "diff --git a/old b/new"
71
80
  * Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
72
81
  */
73
- function extractFileInfo(header, body) {
82
+ export function extractFileInfo(header, body) {
74
83
  // Parse "diff --git a/X b/Y". For non-renames X === Y; use midpoint split so
75
84
  // paths containing " b/" (e.g. "src/b/file.ts") are not mis-parsed by a greedy regex.
76
85
  const prefix = "diff --git a/";
@@ -114,10 +123,11 @@ function extractFileInfo(header, body) {
114
123
  return { path, oldPath, status };
115
124
  }
116
125
  /**
117
- * Truncate diff body to at most `maxLines` lines (counting only hunk content lines).
126
+ * Truncate diff body to at most `maxLines` lines of the raw body text
127
+ * (includes index/---/+++/@@ headers — coarser than hunk-content-only counting).
118
128
  * Returns { text, truncated }.
119
129
  */
120
- function truncateDiffBody(body, maxLines) {
130
+ export function truncateDiffBody(body, maxLines) {
121
131
  const lines = body.split("\n");
122
132
  if (lines.length <= maxLines) {
123
133
  return { text: body, truncated: false };
@@ -125,7 +135,7 @@ function truncateDiffBody(body, maxLines) {
125
135
  return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
126
136
  }
127
137
  /** Check whether a file path matches any of the given glob patterns. */
128
- function matchesAnyPattern(filePath, patterns) {
138
+ export function matchesAnyPattern(filePath, patterns) {
129
139
  const normalized = filePath.replace(/\\/g, "/");
130
140
  for (const pattern of patterns) {
131
141
  if (matchesGlob(normalized, pattern))
@@ -138,7 +148,7 @@ function matchesAnyPattern(filePath, patterns) {
138
148
  return false;
139
149
  }
140
150
  /** Build the diff args array from the `range` parameter. */
141
- function buildDiffArgs(range) {
151
+ export function buildDiffArgs(range) {
142
152
  if (range === undefined || range === "") {
143
153
  return { ok: true, args: [] };
144
154
  }
@@ -149,21 +159,13 @@ function buildDiffArgs(range) {
149
159
  if (normalized === "head") {
150
160
  return { ok: true, args: ["HEAD"] };
151
161
  }
152
- // Range like "A..B", "A...B", or a single ref
153
- // Split on ".." or "..." separators to validate each token
154
- const separatorMatch = /^(.+?)(\.{2,3})(.+)$/.exec(range.trim());
155
- if (separatorMatch) {
156
- const [, left, sep, right] = separatorMatch;
157
- if (!isSafeGitUpstreamToken(left ?? "") || !isSafeGitUpstreamToken(right ?? "")) {
158
- return { ok: false, error: `unsafe_range_token: ${range}` };
159
- }
160
- return { ok: true, args: [`${left}${sep}${right}`] };
161
- }
162
- // Single ref
163
- if (!isSafeGitUpstreamToken(range.trim())) {
164
- return { ok: false, error: `unsafe_range_token: ${range}` };
162
+ // "A..B", "A...B", or a single ref (ancestor notation like "HEAD~3" accepted
163
+ // on any endpoint) delegates to the shared range validator.
164
+ const trimmed = range.trim();
165
+ if (!isSafeGitRangeToken(trimmed)) {
166
+ return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
165
167
  }
166
- return { ok: true, args: [range.trim()] };
168
+ return { ok: true, args: [trimmed] };
167
169
  }
168
170
  /** Human-readable label for the range. */
169
171
  function rangeLabel(range, diffArgs) {
@@ -184,7 +186,7 @@ export function registerGitDiffSummaryTool(server) {
184
186
  annotations: {
185
187
  readOnlyHint: true,
186
188
  },
187
- parameters: WorkspacePickSchema.omit({ allWorkspaceRoots: true }).extend({
189
+ parameters: WorkspacePickSchema.extend({
188
190
  range: z
189
191
  .string()
190
192
  .optional()
@@ -245,8 +247,9 @@ export function registerGitDiffSummaryTool(server) {
245
247
  }
246
248
  // --- Parse diff chunks ---
247
249
  const chunks = parseDiffOutput(diffResult.stdout);
248
- const totalFiles = chunks.length;
249
250
  // --- Apply excludePatterns and fileFilter ---
251
+ // totalFiles / totals count the post-filter set (before maxFiles truncation)
252
+ // so they stay consistent with each other; truncatedFiles covers display caps.
250
253
  const excludePatterns = args.excludePatterns !== undefined ? args.excludePatterns : DEFAULT_EXCLUDE_PATTERNS;
251
254
  const excludedFiles = [];
252
255
  const includedChunks = [];
@@ -257,24 +260,32 @@ export function registerGitDiffSummaryTool(server) {
257
260
  continue;
258
261
  }
259
262
  if (args.fileFilter && !matchesAnyPattern(filePath, [args.fileFilter])) {
263
+ excludedFiles.push(filePath);
260
264
  continue;
261
265
  }
262
266
  includedChunks.push(chunk);
263
267
  }
268
+ const totalFiles = includedChunks.length;
264
269
  // --- Truncate to maxFiles ---
265
270
  const maxFiles = args.maxFiles ?? 30;
266
271
  const maxLinesPerFile = args.maxLinesPerFile ?? 50;
267
272
  const truncatedFileCount = includedChunks.length > maxFiles ? includedChunks.length - maxFiles : 0;
268
273
  const processedChunks = includedChunks.slice(0, maxFiles);
269
274
  // --- Build FileDiff entries ---
275
+ // Sum stats over the full filtered set (includedChunks), not only the
276
+ // maxFiles-truncated display slice, so totals match totalFiles.
270
277
  let totalAdditions = 0;
271
278
  let totalDeletions = 0;
279
+ for (const chunk of includedChunks) {
280
+ const { path: filePath } = extractFileInfo(chunk.header, chunk.body);
281
+ const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
282
+ totalAdditions += stat.additions;
283
+ totalDeletions += stat.deletions;
284
+ }
272
285
  const files = [];
273
286
  for (const chunk of processedChunks) {
274
287
  const { path: filePath, oldPath, status } = extractFileInfo(chunk.header, chunk.body);
275
288
  const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
276
- totalAdditions += stat.additions;
277
- totalDeletions += stat.deletions;
278
289
  const { text: diffText, truncated } = truncateDiffBody(chunk.body, maxLinesPerFile);
279
290
  files.push({
280
291
  path: filePath,
@@ -282,7 +293,7 @@ export function registerGitDiffSummaryTool(server) {
282
293
  additions: stat.additions,
283
294
  deletions: stat.deletions,
284
295
  ...spreadDefined("oldPath", oldPath),
285
- truncated,
296
+ ...spreadWhen(truncated, { truncated: true }),
286
297
  diff: diffText,
287
298
  });
288
299
  }
@@ -1,31 +1,36 @@
1
1
  import { z } from "zod";
2
2
  import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
- import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
5
- import { jsonRespond } from "./json.js";
4
+ import { spawnGitAsync } from "./git.js";
5
+ import { isSafeGitCommitIsh } from "./git-refs.js";
6
+ import { jsonRespond, spreadWhen } from "./json.js";
6
7
  import { requireSingleRepo } from "./roots.js";
7
8
  import { WorkspacePickSchema } from "./schemas.js";
8
9
  // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+ /** Default byte cap on raw diff stdout to keep agent context bounded. */
13
+ export const GIT_DIFF_DEFAULT_MAX_BYTES = 512_000;
14
+ // ---------------------------------------------------------------------------
9
15
  // Helpers
10
16
  // ---------------------------------------------------------------------------
11
17
  /** Build the diff args array from parameters. */
12
- function buildDiffArgs(opts) {
18
+ export function buildDiffArgs(opts) {
13
19
  const args = ["diff"];
14
- // Handle staged flag first
15
- if (opts.staged === true) {
16
- args.push("--staged");
17
- }
18
- else if (opts.base || opts.head) {
19
- // Range-based diff: base..head or base...head
20
- // If only base is given, use base~0..HEAD (implicit HEAD)
21
- const baseStr = opts.base?.trim() ?? "HEAD";
22
- const headStr = opts.head?.trim() ?? "HEAD";
23
- if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
20
+ // Docs: `staged` is ignored when `base` is provided; `head` is used only
21
+ // when `base` is set. Prefer base..head over --staged whenever base is set.
22
+ if (opts.base) {
23
+ const baseStr = opts.base.trim();
24
+ const headStr = opts.head?.trim() || "HEAD";
25
+ if (!isSafeGitCommitIsh(baseStr) || !isSafeGitCommitIsh(headStr)) {
24
26
  return { ok: false, error: ERROR_CODES.UNSAFE_RANGE_TOKEN };
25
27
  }
26
- // Use two-dot range: base..head
27
28
  args.push(`${baseStr}..${headStr}`);
28
29
  }
30
+ else if (opts.staged === true) {
31
+ args.push("--staged");
32
+ }
33
+ // head without base is ignored (matches docs: head used only when base is set)
29
34
  // Apply unified context width if specified
30
35
  if (typeof opts.unified === "number") {
31
36
  args.push(`-U${opts.unified}`);
@@ -37,16 +42,16 @@ function buildDiffArgs(opts) {
37
42
  return { ok: true, args };
38
43
  }
39
44
  /** Human-readable label for the range. */
40
- function rangeLabel(opts) {
45
+ export function rangeLabel(opts) {
41
46
  let label = "";
42
- if (opts.staged === true) {
43
- label = "staged changes";
44
- }
45
- else if (opts.base || opts.head) {
46
- const baseStr = opts.base?.trim() ?? "HEAD";
47
- const headStr = opts.head?.trim() ?? "HEAD";
47
+ if (opts.base) {
48
+ const baseStr = opts.base.trim();
49
+ const headStr = opts.head?.trim() || "HEAD";
48
50
  label = `${baseStr}..${headStr}`;
49
51
  }
52
+ else if (opts.staged === true) {
53
+ label = "staged changes";
54
+ }
50
55
  else {
51
56
  label = "unstaged changes";
52
57
  }
@@ -55,6 +60,14 @@ function rangeLabel(opts) {
55
60
  }
56
61
  return label;
57
62
  }
63
+ /** Cap diff text at maxBytes; UTF-8 safe via Buffer slice. */
64
+ export function truncateDiffOutput(diff, maxBytes) {
65
+ const buf = Buffer.from(diff, "utf8");
66
+ if (buf.length <= maxBytes) {
67
+ return { text: diff, truncated: false };
68
+ }
69
+ return { text: buf.subarray(0, maxBytes).toString("utf8"), truncated: true };
70
+ }
58
71
  // ---------------------------------------------------------------------------
59
72
  // Tool registration
60
73
  // ---------------------------------------------------------------------------
@@ -62,22 +75,20 @@ export function registerGitDiffTool(server) {
62
75
  server.addTool({
63
76
  name: "git_diff",
64
77
  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.",
78
+ "`base`/`head` for revision ranges, `path`/`paths` to scope, `unified` for context lines. " +
79
+ "Output is capped by `maxBytes` (default 512000) to bound agent context.",
66
80
  annotations: {
67
81
  readOnlyHint: true,
68
82
  },
69
- parameters: WorkspacePickSchema.omit({
70
- absoluteGitRoots: true,
71
- allWorkspaceRoots: true,
72
- }).extend({
83
+ parameters: WorkspacePickSchema.extend({
73
84
  base: z
74
85
  .string()
75
86
  .optional()
76
- .describe('Base ref (e.g. "main", "HEAD~3"). Omit for unstaged changes.'),
87
+ .describe('Base ref (e.g. "main"). Ancestor notation is accepted (e.g. "HEAD~3", "main^2"). Omit for unstaged changes.'),
77
88
  head: z
78
89
  .string()
79
90
  .optional()
80
- .describe('Head ref (e.g. "feature-branch"). Defaults to HEAD. Used only when `base` is set.'),
91
+ .describe('Head ref (e.g. "feature-branch"). Ancestor notation is accepted (e.g. "HEAD~3", "main^2"). Defaults to HEAD. Used only when `base` is set.'),
81
92
  path: z
82
93
  .string()
83
94
  .optional()
@@ -98,6 +109,14 @@ export function registerGitDiffTool(server) {
98
109
  .max(100)
99
110
  .optional()
100
111
  .describe("Context lines around each change (`-U<n>`). Default: 3. Use 0 for no context."),
112
+ maxBytes: z
113
+ .number()
114
+ .int()
115
+ .min(1024)
116
+ .max(10_000_000)
117
+ .optional()
118
+ .default(GIT_DIFF_DEFAULT_MAX_BYTES)
119
+ .describe(`Max UTF-8 bytes of diff text to return (default ${GIT_DIFF_DEFAULT_MAX_BYTES}). Oversized output is truncated with truncated:true.`),
101
120
  }),
102
121
  execute: async (args) => {
103
122
  const pre = requireSingleRepo(server, args);
@@ -144,6 +163,8 @@ export function registerGitDiffTool(server) {
144
163
  detail: (result.stderr || result.stdout).trim(),
145
164
  });
146
165
  }
166
+ const maxBytes = typeof args.maxBytes === "number" ? args.maxBytes : GIT_DIFF_DEFAULT_MAX_BYTES;
167
+ const { text: diffText, truncated } = truncateDiffOutput(result.stdout, maxBytes);
147
168
  const label = rangeLabel({
148
169
  base: args.base,
149
170
  head: args.head,
@@ -153,18 +174,22 @@ export function registerGitDiffTool(server) {
153
174
  if (args.format === "json") {
154
175
  return jsonRespond({
155
176
  range: label,
156
- diff: result.stdout,
177
+ diff: diffText,
178
+ ...spreadWhen(truncated, { truncated: true }),
157
179
  });
158
180
  }
159
181
  // Markdown output
160
182
  const lines = [];
161
183
  lines.push(`# Diff: ${label}`, "");
162
- if (result.stdout.trim()) {
163
- lines.push("```diff", result.stdout.trimEnd(), "```");
184
+ if (diffText.trim()) {
185
+ lines.push("```diff", diffText.trimEnd(), "```");
164
186
  }
165
187
  else {
166
188
  lines.push("_(no changes)_");
167
189
  }
190
+ if (truncated) {
191
+ lines.push("", `_(diff truncated at ${maxBytes} bytes)_`);
192
+ }
168
193
  return lines.join("\n");
169
194
  },
170
195
  });