@rethunk/mcp-multi-root-git 2.3.4 → 2.5.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 (45) hide show
  1. package/AGENTS.md +30 -4
  2. package/CHANGELOG.md +74 -0
  3. package/HUMANS.md +54 -5
  4. package/README.md +15 -1
  5. package/dist/server/batch-commit-tool.js +266 -19
  6. package/dist/server/git-cherry-pick-tool.js +32 -8
  7. package/dist/server/git-diff-summary-tool.js +39 -21
  8. package/dist/server/git-diff-tool.js +132 -0
  9. package/dist/server/git-fetch-tool.js +131 -0
  10. package/dist/server/git-log-tool.js +37 -0
  11. package/dist/server/git-push-tool.js +8 -1
  12. package/dist/server/git-refs.js +72 -0
  13. package/dist/server/git-show-tool.js +148 -0
  14. package/dist/server/git-stash-tool.js +131 -0
  15. package/dist/server/git-tag-tool.js +162 -0
  16. package/dist/server/git.js +35 -4
  17. package/dist/server/roots.js +8 -4
  18. package/dist/server/test-harness.js +68 -5
  19. package/dist/server/tool-parameter-schemas.js +21 -1
  20. package/dist/server/tools.js +11 -0
  21. package/docs/install.md +19 -2
  22. package/docs/mcp-tools.md +443 -10
  23. package/package.json +18 -6
  24. package/schemas/batch_commit.json +125 -0
  25. package/schemas/git_cherry_pick.json +69 -0
  26. package/schemas/git_diff.json +62 -0
  27. package/schemas/git_diff_summary.json +75 -0
  28. package/schemas/git_fetch.json +52 -0
  29. package/schemas/git_inventory.json +74 -0
  30. package/schemas/git_log.json +77 -0
  31. package/schemas/git_merge.json +79 -0
  32. package/schemas/git_parity.json +74 -0
  33. package/schemas/git_push.json +50 -0
  34. package/schemas/git_reset_soft.json +42 -0
  35. package/schemas/git_show.json +39 -0
  36. package/schemas/git_stash_apply.json +44 -0
  37. package/schemas/git_stash_list.json +30 -0
  38. package/schemas/git_status.json +49 -0
  39. package/schemas/git_tag.json +50 -0
  40. package/schemas/git_worktree_add.json +52 -0
  41. package/schemas/git_worktree_list.json +30 -0
  42. package/schemas/git_worktree_remove.json +48 -0
  43. package/schemas/index.json +108 -0
  44. package/schemas/list_presets.json +44 -0
  45. package/tool-parameters.schema.json +327 -6
@@ -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
+ }
@@ -133,6 +133,22 @@ async function runGitLog(opts) {
133
133
  // ---------------------------------------------------------------------------
134
134
  // Markdown rendering
135
135
  // ---------------------------------------------------------------------------
136
+ function renderLogOneline(group, multiRoot) {
137
+ const lines = [];
138
+ if (multiRoot) {
139
+ lines.push(`### ${group.repo} (${group.branch})`);
140
+ }
141
+ for (const c of group.commits) {
142
+ lines.push(`${c.sha.slice(0, 7)} ${c.subject}`);
143
+ }
144
+ if (group.commits.length === 0) {
145
+ lines.push("(no commits)");
146
+ }
147
+ if (group.truncated) {
148
+ lines.push(`(+${group.omittedCount} more)`);
149
+ }
150
+ return lines.join("\n");
151
+ }
136
152
  function renderLogMarkdown(group, filterSummary) {
137
153
  const lines = [];
138
154
  lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
@@ -164,6 +180,13 @@ export function registerGitLogTool(server) {
164
180
  readOnlyHint: true,
165
181
  },
166
182
  parameters: WorkspacePickSchema.extend({
183
+ format: z
184
+ .enum(["markdown", "json", "oneline"])
185
+ .optional()
186
+ .default("markdown")
187
+ .describe("`markdown` (default): headed sections per root. " +
188
+ "`json`: structured groups array. " +
189
+ "`oneline`: `<sha7> <subject>` per line, no headers (single-root) or `### repo (branch)` separator per group (multi-root). Lowest-token option for post-commit verification."),
167
190
  since: z
168
191
  .string()
169
192
  .optional()
@@ -258,6 +281,20 @@ export function registerGitLogTool(server) {
258
281
  });
259
282
  return jsonRespond({ groups });
260
283
  }
284
+ // Oneline
285
+ if (args.format === "oneline") {
286
+ const multiRoot = results.length > 1;
287
+ const chunks = [];
288
+ for (const r of results) {
289
+ if (r._error) {
290
+ chunks.push(multiRoot ? `### ${r.workspaceRoot}\n(error: ${r.error})` : `(error: ${r.error})`);
291
+ continue;
292
+ }
293
+ const { _error: _e, ...group } = r;
294
+ chunks.push(renderLogOneline(group, multiRoot));
295
+ }
296
+ return chunks.join("\n\n");
297
+ }
261
298
  // Markdown
262
299
  const mdChunks = ["# Git log"];
263
300
  for (const r of results) {
@@ -94,6 +94,8 @@ export function registerGitPushTool(server) {
94
94
  "@{u}",
95
95
  ]);
96
96
  const upstream = upstreamProbe.ok ? upstreamProbe.stdout.trim() : `${remote}/${branch}`;
97
+ // Append git output (stdout/stderr) if non-empty
98
+ const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
97
99
  if (args.format === "json") {
98
100
  return jsonRespond({
99
101
  ok: true,
@@ -101,9 +103,14 @@ export function registerGitPushTool(server) {
101
103
  remote,
102
104
  upstream,
103
105
  ...spreadDefined("setUpstream", args.setUpstream || undefined),
106
+ ...spreadDefined("output", gitOutput || undefined),
104
107
  });
105
108
  }
106
- return `# Push\n✓ ${branch} → ${upstream}`;
109
+ let result = `# Push\n✓ ${branch} → ${upstream}`;
110
+ if (gitOutput) {
111
+ result += `\n\n${gitOutput}`;
112
+ }
113
+ return result;
107
114
  },
108
115
  });
109
116
  }
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { spawnGitAsync } from "./git.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Merge conflict helpers (shared between git_merge and git_cherry_pick)
@@ -124,6 +125,77 @@ export async function isFullyMergedInto(cwd, branch, target) {
124
125
  const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
125
126
  return r.ok;
126
127
  }
128
+ /**
129
+ * Returns the git patch-id for a single commit, or undefined on failure.
130
+ * Uses execFileSync to pipe `git diff-tree --patch -r <sha>` into `git patch-id --stable`.
131
+ */
132
+ function commitPatchId(cwd, sha) {
133
+ try {
134
+ const diff = execFileSync("git", ["diff-tree", "--patch", "-r", sha], {
135
+ cwd,
136
+ encoding: "utf8",
137
+ });
138
+ const out = execFileSync("git", ["patch-id", "--stable"], {
139
+ cwd,
140
+ encoding: "utf8",
141
+ input: diff,
142
+ });
143
+ return out.trim().split(" ")[0] || undefined;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
149
+ /**
150
+ * Returns true when every commit reachable from `branch` but not from `target` has a
151
+ * patch-equivalent commit already in `target` (content-equivalent merge check for
152
+ * cherry-pick workflows where SHA differs but diff is identical).
153
+ *
154
+ * Falls back to ref-equality (`isFullyMergedInto`) when patch-id comparison fails.
155
+ */
156
+ export async function isContentEquivalentlyMergedInto(cwd, branch, target) {
157
+ if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
158
+ return false;
159
+ // Fast path: ref equality (normal merge).
160
+ const refMerged = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
161
+ if (refMerged.ok)
162
+ return true;
163
+ // Collect commits in branch not reachable from target.
164
+ const srcList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${branch}`, `^${target}`]);
165
+ if (!srcList.ok)
166
+ return false;
167
+ const srcShas = srcList.stdout
168
+ .split("\n")
169
+ .map((l) => l.trim())
170
+ .filter((l) => l.length > 0);
171
+ if (srcShas.length === 0)
172
+ return true; // nothing to check
173
+ // Build patch-id set for target commits since merge-base.
174
+ const base = await spawnGitAsync(cwd, ["merge-base", branch, target]);
175
+ if (!base.ok)
176
+ return false;
177
+ const baseSha = base.stdout.trim();
178
+ const destList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${baseSha}..${target}`]);
179
+ if (!destList.ok)
180
+ return false;
181
+ const destShas = destList.stdout
182
+ .split("\n")
183
+ .map((l) => l.trim())
184
+ .filter((l) => l.length > 0);
185
+ const destPatchIds = new Set();
186
+ for (const sha of destShas) {
187
+ const patchId = commitPatchId(cwd, sha);
188
+ if (patchId)
189
+ destPatchIds.add(patchId);
190
+ }
191
+ // Every source commit must have its patch-id present in destination.
192
+ for (const sha of srcShas) {
193
+ const patchId = commitPatchId(cwd, sha);
194
+ if (!patchId || !destPatchIds.has(patchId))
195
+ return false;
196
+ }
197
+ return true;
198
+ }
127
199
  /**
128
200
  * SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
129
201
  * Returns `null` on git failure.
@@ -0,0 +1,148 @@
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
+ * 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).
12
+ */
13
+ 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);
21
+ }
22
+ const r = await spawnGitAsync(top, showArgs);
23
+ if (!r.ok) {
24
+ return {
25
+ error: "git_show_failed",
26
+ };
27
+ }
28
+ // Parse the output. For a commit, git show outputs:
29
+ // - Header (commit, Author, Date, etc.)
30
+ // - Blank line
31
+ // - Commit message (may contain multiple lines and blank lines)
32
+ // - Blank line (separator before diff)
33
+ // - Diff (if --no-patch not used) or file content
34
+ const output = r.stdout;
35
+ let message = "";
36
+ let diff = "";
37
+ const lines = output.split("\n");
38
+ let inHeader = true;
39
+ let inMessage = false;
40
+ const messageLines = [];
41
+ const contentLines = [];
42
+ for (const line of lines) {
43
+ if (line === undefined)
44
+ continue;
45
+ // End header when we see a blank line
46
+ if (inHeader && line.trim() === "") {
47
+ inHeader = false;
48
+ inMessage = true;
49
+ continue;
50
+ }
51
+ // In message: collect until we see "diff --git" which marks the start of the diff section
52
+ if (inMessage) {
53
+ if (line.startsWith("diff --git")) {
54
+ inMessage = false;
55
+ contentLines.push(line);
56
+ }
57
+ else {
58
+ messageLines.push(line);
59
+ }
60
+ }
61
+ else if (!inHeader) {
62
+ // In diff/content section
63
+ contentLines.push(line);
64
+ }
65
+ }
66
+ message = messageLines.join("\n").trim();
67
+ diff = contentLines.join("\n").trim();
68
+ const result = {
69
+ ref,
70
+ message,
71
+ };
72
+ if (path) {
73
+ result.path = path;
74
+ }
75
+ if (diff) {
76
+ result.diff = diff;
77
+ }
78
+ return result;
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Markdown rendering
82
+ // ---------------------------------------------------------------------------
83
+ function renderShowMarkdown(result) {
84
+ const lines = [];
85
+ lines.push(`# git show ${result.ref}`);
86
+ if (result.path) {
87
+ lines.push(`_path: ${result.path}_`);
88
+ }
89
+ lines.push("");
90
+ lines.push("## Commit message");
91
+ lines.push("");
92
+ lines.push("```");
93
+ lines.push(result.message);
94
+ lines.push("```");
95
+ if (result.diff) {
96
+ lines.push("");
97
+ lines.push("## Diff");
98
+ lines.push("");
99
+ lines.push("```diff");
100
+ lines.push(result.diff);
101
+ lines.push("```");
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Tool registration
107
+ // ---------------------------------------------------------------------------
108
+ export function registerGitShowTool(server) {
109
+ server.addTool({
110
+ name: "git_show",
111
+ description: "Inspect commit content by ref/SHA. Returns commit message and diff (or file content at a specific path).",
112
+ annotations: {
113
+ readOnlyHint: true,
114
+ },
115
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
116
+ .pick({
117
+ workspaceRoot: true,
118
+ rootIndex: true,
119
+ format: true,
120
+ })
121
+ .extend({
122
+ ref: z.string().describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
123
+ path: z
124
+ .string()
125
+ .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."),
127
+ }),
128
+ execute: async (args) => {
129
+ const pre = requireSingleRepo(server, args);
130
+ if (!pre.ok)
131
+ return jsonRespond(pre.error);
132
+ const top = pre.gitTop;
133
+ const result = await runGitShow({
134
+ top,
135
+ ref: args.ref,
136
+ path: args.path,
137
+ });
138
+ if ("error" in result) {
139
+ return jsonRespond(result);
140
+ }
141
+ if (args.format === "json") {
142
+ return jsonRespond(result);
143
+ }
144
+ // Markdown
145
+ return renderShowMarkdown(result);
146
+ },
147
+ });
148
+ }