@rethunk/mcp-multi-root-git 2.3.3 → 2.4.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 (42) hide show
  1. package/AGENTS.md +30 -3
  2. package/CHANGELOG.md +61 -0
  3. package/HUMANS.md +106 -2
  4. package/README.md +15 -1
  5. package/dist/server/batch-commit-tool.js +244 -19
  6. package/dist/server/coverage.js +22 -0
  7. package/dist/server/git-diff-tool.js +132 -0
  8. package/dist/server/git-fetch-tool.js +131 -0
  9. package/dist/server/git-push-tool.js +8 -1
  10. package/dist/server/git-show-tool.js +148 -0
  11. package/dist/server/git-stash-tool.js +131 -0
  12. package/dist/server/git-tag-tool.js +162 -0
  13. package/dist/server/git.js +18 -2
  14. package/dist/server/roots.js +8 -4
  15. package/dist/server/test-harness.js +77 -6
  16. package/dist/server/tool-parameter-schemas.js +94 -0
  17. package/dist/server/tools.js +11 -0
  18. package/docs/install.md +19 -2
  19. package/docs/mcp-tools.md +235 -5
  20. package/package.json +15 -8
  21. package/schemas/batch_commit.json +125 -0
  22. package/schemas/git_cherry_pick.json +63 -0
  23. package/schemas/git_diff.json +62 -0
  24. package/schemas/git_diff_summary.json +75 -0
  25. package/schemas/git_fetch.json +52 -0
  26. package/schemas/git_inventory.json +74 -0
  27. package/schemas/git_log.json +75 -0
  28. package/schemas/git_merge.json +79 -0
  29. package/schemas/git_parity.json +74 -0
  30. package/schemas/git_push.json +50 -0
  31. package/schemas/git_reset_soft.json +42 -0
  32. package/schemas/git_show.json +39 -0
  33. package/schemas/git_stash_apply.json +44 -0
  34. package/schemas/git_stash_list.json +30 -0
  35. package/schemas/git_status.json +49 -0
  36. package/schemas/git_tag.json +50 -0
  37. package/schemas/git_worktree_add.json +52 -0
  38. package/schemas/git_worktree_list.json +30 -0
  39. package/schemas/git_worktree_remove.json +48 -0
  40. package/schemas/index.json +108 -0
  41. package/schemas/list_presets.json +44 -0
  42. package/tool-parameters.schema.json +1125 -0
@@ -5,9 +5,24 @@ import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
5
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
6
  import { requireSingleRepo } from "./roots.js";
7
7
  import { WorkspacePickSchema } from "./schemas.js";
8
+ const FileEntrySchema = z.union([
9
+ z.string().min(1),
10
+ z.object({
11
+ path: z.string().min(1).describe("File path relative to git root."),
12
+ lines: z
13
+ .object({
14
+ from: z.number().int().min(1).describe("Start line number (1-indexed)."),
15
+ to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
16
+ })
17
+ .describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
18
+ }),
19
+ ]);
8
20
  const CommitEntrySchema = z.object({
9
21
  message: z.string().min(1).describe("Commit message."),
10
- files: z.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
22
+ files: z
23
+ .array(FileEntrySchema)
24
+ .min(1)
25
+ .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."),
11
26
  });
12
27
  const PushModeSchema = z
13
28
  .enum(["never", "after"])
@@ -16,6 +31,132 @@ const PushModeSchema = z
16
31
  .describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
17
32
  "fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
18
33
  "Enum reserved for future modes such as `force-with-lease`.");
34
+ const DryRunSchema = z
35
+ .boolean()
36
+ .optional()
37
+ .default(false)
38
+ .describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
39
+ "Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
40
+ /**
41
+ * Parses a unified diff to extract hunks that overlap with a given line range.
42
+ * Returns a partial patch containing only the overlapping hunks, including header lines.
43
+ * Uses new file line numbers (after @@) to determine overlap.
44
+ */
45
+ function extractOverlappingHunks(diffContent, fromLine, toLine) {
46
+ const lines = diffContent.split("\n");
47
+ // Find file header lines (index, ---, +++)
48
+ const fileHeaderLines = [];
49
+ let firstHunkIdx = -1;
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const line = lines[i];
52
+ if (line === undefined)
53
+ continue;
54
+ if (line.startsWith("@@")) {
55
+ firstHunkIdx = i;
56
+ break;
57
+ }
58
+ fileHeaderLines.push(line);
59
+ }
60
+ if (firstHunkIdx === -1) {
61
+ // No hunks found
62
+ return null;
63
+ }
64
+ const result = [...fileHeaderLines];
65
+ let i = firstHunkIdx;
66
+ while (i < lines.length) {
67
+ const line = lines[i];
68
+ if (line === undefined) {
69
+ i++;
70
+ continue;
71
+ }
72
+ // Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
73
+ const hunkMatch = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
74
+ if (hunkMatch) {
75
+ const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
76
+ const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
77
+ const hunkEnd = newStart + newCount - 1;
78
+ // Check if hunk overlaps with requested line range
79
+ const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
80
+ if (hasOverlap) {
81
+ // Add hunk header
82
+ result.push(line);
83
+ i++;
84
+ // Add hunk content until next hunk or EOF
85
+ while (i < lines.length) {
86
+ const contentLine = lines[i];
87
+ if (contentLine === undefined) {
88
+ i++;
89
+ continue;
90
+ }
91
+ // Stop at next hunk header
92
+ if (contentLine.startsWith("@@")) {
93
+ break;
94
+ }
95
+ result.push(contentLine);
96
+ i++;
97
+ }
98
+ }
99
+ else {
100
+ // Skip hunk
101
+ i++;
102
+ while (i < lines.length) {
103
+ const contentLine = lines[i];
104
+ if (contentLine === undefined) {
105
+ i++;
106
+ continue;
107
+ }
108
+ if (contentLine.startsWith("@@")) {
109
+ break;
110
+ }
111
+ i++;
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ i++;
117
+ }
118
+ }
119
+ return result.length > fileHeaderLines.length ? result.join("\n") : null;
120
+ }
121
+ /**
122
+ * Stages a file with optional line range. If lines are provided, only hunks
123
+ * overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
124
+ */
125
+ async function stageFile(gitTop, filePath, lines) {
126
+ if (!lines) {
127
+ // Simple case: stage the whole file
128
+ const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
129
+ return {
130
+ ok: addResult.ok,
131
+ error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
132
+ };
133
+ }
134
+ // Line range case: extract overlapping hunks and apply patch
135
+ const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
136
+ if (!diffResult.ok) {
137
+ return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
138
+ }
139
+ const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
140
+ if (!partialPatch) {
141
+ return { ok: false, error: "No hunks found in line range" };
142
+ }
143
+ // Write partial patch to temp file in the git repo and apply it to the index
144
+ const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
145
+ const { writeFileSync, unlinkSync } = await import("node:fs");
146
+ writeFileSync(tempPatchFile, partialPatch, "utf8");
147
+ const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
148
+ // Clean up temp file
149
+ try {
150
+ unlinkSync(tempPatchFile);
151
+ }
152
+ catch {
153
+ // Ignore cleanup errors
154
+ }
155
+ return {
156
+ ok: applyResult.ok,
157
+ error: applyResult.ok ? undefined : (applyResult.stderr || applyResult.stdout).trim(),
158
+ };
159
+ }
19
160
  /**
20
161
  * After all commits succeed, push the current branch to its upstream.
21
162
  * Commits are already applied at this point — do NOT attempt rollback on push failure.
@@ -39,7 +180,13 @@ export async function runPushAfter(gitTop) {
39
180
  detail: (pushResult.stderr || pushResult.stdout).trim(),
40
181
  };
41
182
  }
42
- return { ok: true, branch, upstream: t.upstream };
183
+ const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
184
+ return {
185
+ ok: true,
186
+ branch,
187
+ upstream: t.upstream,
188
+ ...spreadDefined("output", gitOutput || undefined),
189
+ };
43
190
  }
44
191
  export function registerBatchCommitTool(server) {
45
192
  server.addTool({
@@ -47,7 +194,8 @@ export function registerBatchCommitTool(server) {
47
194
  description: "Create multiple sequential git commits in a single call. " +
48
195
  "Each entry stages the listed files then commits with the given message. " +
49
196
  'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
50
- "to its upstream once all commits succeed.",
197
+ "to its upstream once all commits succeed. " +
198
+ "Optional `dryRun: true` previews what would be staged/committed without writing commits.",
51
199
  annotations: {
52
200
  readOnlyHint: false,
53
201
  destructiveHint: false,
@@ -60,6 +208,7 @@ export function registerBatchCommitTool(server) {
60
208
  .max(50)
61
209
  .describe("Commits to create, applied in order."),
62
210
  push: PushModeSchema,
211
+ dryRun: DryRunSchema,
63
212
  }),
64
213
  execute: async (args) => {
65
214
  const pre = requireSingleRepo(server, args);
@@ -67,16 +216,30 @@ export function registerBatchCommitTool(server) {
67
216
  return jsonRespond(pre.error);
68
217
  const gitTop = pre.gitTop;
69
218
  const results = [];
219
+ const stagedFilesForCleanup = new Set();
70
220
  for (let i = 0; i < args.commits.length; i++) {
71
221
  const entry = args.commits[i];
72
222
  if (!entry)
73
223
  break;
224
+ // Normalize file entries to { path, lines? } format
225
+ const fileEntries = [];
226
+ const filePaths = [];
227
+ for (const fileEntry of entry.files) {
228
+ if (typeof fileEntry === "string") {
229
+ fileEntries.push({ path: fileEntry });
230
+ filePaths.push(fileEntry);
231
+ }
232
+ else {
233
+ fileEntries.push(fileEntry);
234
+ filePaths.push(fileEntry.path);
235
+ }
236
+ }
74
237
  // --- Validate all paths are under the git toplevel ---
75
238
  const escapedPaths = [];
76
- for (const rel of entry.files) {
77
- const abs = resolvePathForRepo(rel, gitTop);
239
+ for (const path of filePaths) {
240
+ const abs = resolvePathForRepo(path, gitTop);
78
241
  if (!isStrictlyUnderGitTop(abs, gitTop)) {
79
- escapedPaths.push(rel);
242
+ escapedPaths.push(path);
80
243
  }
81
244
  }
82
245
  if (escapedPaths.length > 0) {
@@ -84,53 +247,94 @@ export function registerBatchCommitTool(server) {
84
247
  index: i,
85
248
  ok: false,
86
249
  message: entry.message,
87
- files: entry.files,
250
+ files: filePaths,
88
251
  error: "path_escapes_repository",
89
252
  detail: escapedPaths.join(", "),
90
253
  });
91
254
  break;
92
255
  }
93
- // --- Stage files ---
94
- const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
95
- if (!addResult.ok) {
256
+ // --- Stage files (with optional line ranges) ---
257
+ let stagingFailed = false;
258
+ let stagingError = "";
259
+ for (const fileEntry of fileEntries) {
260
+ const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
261
+ if (!stageResult.ok) {
262
+ stagingFailed = true;
263
+ stagingError = stageResult.error || "Unknown error";
264
+ break;
265
+ }
266
+ }
267
+ if (stagingFailed) {
96
268
  results.push({
97
269
  index: i,
98
270
  ok: false,
99
271
  message: entry.message,
100
- files: entry.files,
272
+ files: filePaths,
101
273
  error: "stage_failed",
102
- detail: (addResult.stderr || addResult.stdout).trim(),
274
+ detail: stagingError,
275
+ ...spreadDefined("output", stagingError || undefined),
103
276
  });
104
277
  break;
105
278
  }
279
+ // Track staged files for cleanup in dry-run
280
+ if (args.dryRun) {
281
+ for (const path of filePaths) {
282
+ stagedFilesForCleanup.add(path);
283
+ }
284
+ }
285
+ // --- Dry-run mode: collect preview and unstage ---
286
+ if (args.dryRun) {
287
+ // Get diff stat for this staged entry
288
+ const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
289
+ const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
290
+ results.push({
291
+ index: i,
292
+ ok: true,
293
+ message: entry.message,
294
+ files: filePaths,
295
+ staged: filePaths,
296
+ ...spreadDefined("diffStat", diffStat || undefined),
297
+ });
298
+ continue; // Skip actual commit in dry-run mode
299
+ }
106
300
  // --- Commit ---
107
301
  const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
108
302
  if (!commitResult.ok) {
303
+ const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
109
304
  results.push({
110
305
  index: i,
111
306
  ok: false,
112
307
  message: entry.message,
113
- files: entry.files,
308
+ files: filePaths,
114
309
  error: "commit_failed",
115
- detail: (commitResult.stderr || commitResult.stdout).trim(),
310
+ detail: gitOutput,
311
+ ...spreadDefined("output", gitOutput || undefined),
116
312
  });
117
313
  break;
118
314
  }
119
315
  // --- Extract SHA from commit output ---
120
316
  const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
317
+ const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
121
318
  results.push({
122
319
  index: i,
123
320
  ok: true,
124
321
  sha: shaMatch?.[1],
125
322
  message: entry.message,
126
- files: entry.files,
323
+ files: filePaths,
324
+ ...spreadDefined("output", gitOutput || undefined),
127
325
  });
128
326
  }
327
+ // --- In dry-run mode, unstage all files ---
328
+ if (args.dryRun && stagedFilesForCleanup.size > 0) {
329
+ const filesToReset = Array.from(stagedFilesForCleanup);
330
+ await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
331
+ }
129
332
  const allOk = results.length === args.commits.length && results.every((r) => r.ok);
130
- // --- Optional push after all commits succeed ---
131
- const push = allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
333
+ // --- Optional push after all commits succeed (not in dry-run mode) ---
334
+ const push = !args.dryRun && allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
132
335
  if (args.format === "json") {
133
336
  return jsonRespond({
337
+ ...spreadWhen(args.dryRun, { dryRun: true }),
134
338
  ok: allOk,
135
339
  committed: results.filter((r) => r.ok).length,
136
340
  total: args.commits.length,
@@ -140,8 +344,11 @@ export function registerBatchCommitTool(server) {
140
344
  ...spreadDefined("sha", r.sha),
141
345
  message: r.message,
142
346
  files: r.files,
347
+ ...spreadDefined("staged", r.staged),
348
+ ...spreadDefined("diffStat", r.diffStat),
143
349
  ...spreadDefined("error", r.error),
144
350
  ...spreadDefined("detail", r.detail),
351
+ ...spreadDefined("output", r.output),
145
352
  })),
146
353
  ...spreadWhen(push !== undefined, {
147
354
  push: {
@@ -150,15 +357,17 @@ export function registerBatchCommitTool(server) {
150
357
  ...spreadDefined("upstream", push?.upstream),
151
358
  ...spreadDefined("error", push?.error),
152
359
  ...spreadDefined("detail", push?.detail),
360
+ ...spreadDefined("output", push?.output),
153
361
  },
154
362
  }),
155
363
  });
156
364
  }
157
365
  // --- Markdown ---
158
366
  const lines = [];
367
+ const dryRunPrefix = args.dryRun ? "DRY RUN — " : "";
159
368
  const header = allOk
160
- ? `# Batch commit: ${results.length}/${args.commits.length} committed`
161
- : `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
369
+ ? `# Batch commit: ${dryRunPrefix}${results.length}/${args.commits.length} committed`
370
+ : `# Batch commit: ${dryRunPrefix}${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
162
371
  lines.push(header, "");
163
372
  for (const r of results) {
164
373
  const icon = r.ok ? "✓" : "✗";
@@ -167,11 +376,24 @@ export function registerBatchCommitTool(server) {
167
376
  if (!r.ok && r.detail) {
168
377
  lines.push(` Error: ${r.error} — ${r.detail}`);
169
378
  }
379
+ if (args.dryRun && r.staged) {
380
+ lines.push(` Staged: ${r.staged.join(", ")}`);
381
+ }
382
+ if (args.dryRun && r.diffStat) {
383
+ lines.push(` Diff stat:`);
384
+ lines.push(` ${r.diffStat.replace(/\n/g, "\n ")}`);
385
+ }
386
+ if (r.output) {
387
+ lines.push(` Output: ${r.output.replace(/\n/g, "\n ")}`);
388
+ }
170
389
  }
171
390
  if (!allOk && results.length < args.commits.length) {
172
391
  const skipped = args.commits.length - results.length;
173
392
  lines.push("", `${skipped} remaining commit(s) skipped.`);
174
393
  }
394
+ if (args.dryRun) {
395
+ lines.push("", "**DRY RUN — no commits written. All staged files have been unstaged.**");
396
+ }
175
397
  if (push) {
176
398
  lines.push("");
177
399
  if (push.ok) {
@@ -180,6 +402,9 @@ export function registerBatchCommitTool(server) {
180
402
  else {
181
403
  lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
182
404
  }
405
+ if (push.output) {
406
+ lines.push(` Output: ${push.output.replace(/\n/g, "\n ")}`);
407
+ }
183
408
  }
184
409
  return lines.join("\n");
185
410
  },
@@ -0,0 +1,22 @@
1
+ export function parseAllFilesLineCoverage(output) {
2
+ const rows = output
3
+ .split(/\r?\n/)
4
+ .map((line) => line.trim())
5
+ .filter((line) => line.includes("|"));
6
+ const header = rows.find((line) => /^File\s*\|/i.test(line));
7
+ if (!header)
8
+ return null;
9
+ const columns = header.split("|").map((column) => column.trim().toLowerCase());
10
+ const lineIndex = columns.indexOf("% lines");
11
+ if (lineIndex === -1)
12
+ return null;
13
+ const allFiles = rows.find((line) => /^All files\s*\|/i.test(line));
14
+ if (!allFiles)
15
+ return null;
16
+ const cells = allFiles.split("|").map((cell) => cell.trim());
17
+ const raw = cells[lineIndex];
18
+ if (!raw)
19
+ return null;
20
+ const value = Number.parseFloat(raw);
21
+ return Number.isFinite(value) ? value : null;
22
+ }
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
+ import { jsonRespond } from "./json.js";
4
+ import { requireSingleRepo } from "./roots.js";
5
+ import { WorkspacePickSchema } from "./schemas.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ /** Build the diff args array from parameters. */
10
+ function buildDiffArgs(opts) {
11
+ const args = ["diff"];
12
+ // Handle staged flag first
13
+ if (opts.staged === true) {
14
+ args.push("--staged");
15
+ }
16
+ else if (opts.base || opts.head) {
17
+ // Range-based diff: base..head or base...head
18
+ // If only base is given, use base~0..HEAD (implicit HEAD)
19
+ const baseStr = opts.base?.trim() ?? "HEAD";
20
+ const headStr = opts.head?.trim() ?? "HEAD";
21
+ if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
22
+ return { ok: false, error: "unsafe_range_token" };
23
+ }
24
+ // Use two-dot range: base..head
25
+ args.push(`${baseStr}..${headStr}`);
26
+ }
27
+ // Scope to path if provided
28
+ if (opts.path?.trim()) {
29
+ args.push("--", opts.path.trim());
30
+ }
31
+ return { ok: true, args };
32
+ }
33
+ /** Human-readable label for the range. */
34
+ function rangeLabel(opts) {
35
+ let label = "";
36
+ if (opts.staged === true) {
37
+ label = "staged changes";
38
+ }
39
+ else if (opts.base || opts.head) {
40
+ const baseStr = opts.base?.trim() ?? "HEAD";
41
+ const headStr = opts.head?.trim() ?? "HEAD";
42
+ label = `${baseStr}..${headStr}`;
43
+ }
44
+ else {
45
+ label = "unstaged changes";
46
+ }
47
+ if (opts.path?.trim()) {
48
+ label += ` (${opts.path.trim()})`;
49
+ }
50
+ return label;
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // Tool registration
54
+ // ---------------------------------------------------------------------------
55
+ export function registerGitDiffTool(server) {
56
+ server.addTool({
57
+ 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.",
61
+ annotations: {
62
+ readOnlyHint: true,
63
+ },
64
+ parameters: WorkspacePickSchema.extend({
65
+ base: z
66
+ .string()
67
+ .optional()
68
+ .describe('Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
69
+ "If omitted and `staged: false`, shows unstaged changes."),
70
+ head: z
71
+ .string()
72
+ .optional()
73
+ .describe('Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
74
+ "Only used if `base` is provided."),
75
+ path: z
76
+ .string()
77
+ .optional()
78
+ .describe('Scope diff to a single file path (e.g. "src/main.ts").'),
79
+ staged: z
80
+ .boolean()
81
+ .optional()
82
+ .default(false)
83
+ .describe("If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided."),
84
+ }),
85
+ execute: async (args) => {
86
+ const pre = requireSingleRepo(server, args);
87
+ if (!pre.ok)
88
+ return jsonRespond(pre.error);
89
+ const gitTop = pre.gitTop;
90
+ // Build git diff args
91
+ const diffArgsResult = buildDiffArgs({
92
+ base: args.base,
93
+ head: args.head,
94
+ path: args.path,
95
+ staged: args.staged,
96
+ });
97
+ if (!diffArgsResult.ok) {
98
+ return jsonRespond({ error: diffArgsResult.error });
99
+ }
100
+ // Run git diff
101
+ const result = await spawnGitAsync(gitTop, diffArgsResult.args);
102
+ if (!result.ok) {
103
+ return jsonRespond({
104
+ error: "git_diff_failed",
105
+ detail: (result.stderr || result.stdout).trim(),
106
+ });
107
+ }
108
+ const label = rangeLabel({
109
+ base: args.base,
110
+ head: args.head,
111
+ path: args.path,
112
+ staged: args.staged,
113
+ });
114
+ if (args.format === "json") {
115
+ return jsonRespond({
116
+ range: label,
117
+ diff: result.stdout,
118
+ });
119
+ }
120
+ // Markdown output
121
+ const lines = [];
122
+ lines.push(`# Diff: ${label}`, "");
123
+ if (result.stdout.trim()) {
124
+ lines.push("```diff", result.stdout.trimEnd(), "```");
125
+ }
126
+ else {
127
+ lines.push("_(no changes)_");
128
+ }
129
+ return lines.join("\n");
130
+ },
131
+ });
132
+ }
@@ -0,0 +1,131 @@
1
+ import { z } from "zod";
2
+ import { spawnGitAsync } from "./git.js";
3
+ import { jsonRespond } from "./json.js";
4
+ import { requireSingleRepo } from "./roots.js";
5
+ import { WorkspacePickSchema } from "./schemas.js";
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Parse `git fetch` output to extract updated and new refs.
11
+ * Lines containing "[new" indicate new refs (new branch, new tag, new ref).
12
+ * Lines with " -> " but not containing "[new" indicate updated refs.
13
+ */
14
+ function parseGitFetchOutput(output) {
15
+ const lines = output.split("\n");
16
+ const updatedRefs = [];
17
+ const newRefs = [];
18
+ for (const line of lines) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed)
21
+ continue;
22
+ // Lines containing "[new" indicate new refs (e.g. "[new branch]", "[new tag]", "[new ref]")
23
+ if (trimmed.includes("[new")) {
24
+ newRefs.push(trimmed);
25
+ }
26
+ // Lines with " -> " that don't contain "[new" indicate ref updates
27
+ else if (trimmed.includes(" -> ")) {
28
+ updatedRefs.push(trimmed);
29
+ }
30
+ }
31
+ return { updatedRefs, newRefs };
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Tool registration
35
+ // ---------------------------------------------------------------------------
36
+ export function registerGitFetchTool(server) {
37
+ server.addTool({
38
+ name: "git_fetch",
39
+ description: "Fetch updates from a remote repository without modifying the working tree. " +
40
+ "Returns structured output distinguishing updated refs from new refs.",
41
+ annotations: {
42
+ readOnlyHint: false, // Fetch modifies refs but not working tree; not strictly read-only but safe
43
+ },
44
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
45
+ .pick({
46
+ workspaceRoot: true,
47
+ rootIndex: true,
48
+ format: true,
49
+ })
50
+ .extend({
51
+ remote: z
52
+ .string()
53
+ .optional()
54
+ .default("origin")
55
+ .describe("Remote to fetch from (default: origin)."),
56
+ branch: z
57
+ .string()
58
+ .optional()
59
+ .describe("If specified: fetch only this branch (e.g. 'main')."),
60
+ prune: z
61
+ .boolean()
62
+ .optional()
63
+ .default(false)
64
+ .describe("Pass --prune to remove deleted remote branches (default: false)."),
65
+ tags: z
66
+ .boolean()
67
+ .optional()
68
+ .default(false)
69
+ .describe("Pass --tags to also fetch all tags (default: false)."),
70
+ }),
71
+ execute: async (args) => {
72
+ const pre = requireSingleRepo(server, args);
73
+ if (!pre.ok) {
74
+ return jsonRespond(pre.error);
75
+ }
76
+ const gitTop = pre.gitTop;
77
+ const remote = (args.remote ?? "origin").trim();
78
+ const branch = args.branch?.trim();
79
+ const prune = args.prune === true;
80
+ const tags = args.tags === true;
81
+ // Build git fetch command
82
+ const fetchArgs = ["fetch"];
83
+ if (prune) {
84
+ fetchArgs.push("--prune");
85
+ }
86
+ if (tags) {
87
+ fetchArgs.push("--tags");
88
+ }
89
+ fetchArgs.push(remote);
90
+ if (branch) {
91
+ fetchArgs.push(branch);
92
+ }
93
+ const result = await spawnGitAsync(gitTop, fetchArgs);
94
+ const { updatedRefs, newRefs } = parseGitFetchOutput(result.stdout + result.stderr);
95
+ const fetchResult = {
96
+ ok: result.ok,
97
+ remote,
98
+ updatedRefs,
99
+ newRefs,
100
+ output: (result.stdout + result.stderr).trim(),
101
+ };
102
+ if (args.format === "json") {
103
+ return jsonRespond(fetchResult);
104
+ }
105
+ // Markdown output
106
+ const lines = [`# Git fetch from '${remote}'`];
107
+ if (!result.ok) {
108
+ lines.push("", "**Status**: Failed", "");
109
+ lines.push("```", result.stdout || result.stderr || "(no output)", "```");
110
+ return lines.join("\n");
111
+ }
112
+ lines.push("", "**Status**: Success", "");
113
+ if (updatedRefs.length > 0) {
114
+ lines.push("## Updated refs", "");
115
+ for (const ref of updatedRefs) {
116
+ lines.push(`- ${ref}`);
117
+ }
118
+ }
119
+ if (newRefs.length > 0) {
120
+ lines.push("", "## New refs", "");
121
+ for (const ref of newRefs) {
122
+ lines.push(`- ${ref}`);
123
+ }
124
+ }
125
+ if (updatedRefs.length === 0 && newRefs.length === 0 && result.stdout.trim()) {
126
+ lines.push("", "## Output", "", "```", result.stdout.trim(), "```");
127
+ }
128
+ return lines.join("\n");
129
+ },
130
+ });
131
+ }