Skip to content

Commit 40beec3

Browse files
committed
feat: add git_show MCP tool — inspect commit content by ref/SHA
Implements new git_show tool with support for: - Inspecting commit metadata and diffs by ref/SHA - File content inspection at a specific ref with path parameter - Both markdown and JSON output formats - Comprehensive test coverage (7 tests) Fixes #6
1 parent 86440cd commit 40beec3

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

src/server/git-show-tool.test.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* Integration tests for git_show_tool.
3+
*
4+
* Tests create throwaway git repos via `git init` in OS temp dirs
5+
* and exercise git show for commits and file inspection.
6+
*
7+
* We test:
8+
* 1. git show on a commit ref returns message + diff
9+
* 2. git show with a path returns file content at that ref
10+
* 3. commit message is correctly extracted from git show output
11+
* 4. not_a_git_repository error for non-git path
12+
* 5. invalid ref error handling
13+
* 6. JSON format output
14+
*/
15+
16+
import { afterEach, describe, expect, test } from "bun:test";
17+
import { writeFileSync } from "node:fs";
18+
import { join } from "node:path";
19+
20+
import { registerGitShowTool } from "./git-show-tool.js";
21+
import { addCommit, captureTool, cleanupTmpPaths, gitCmd, makeRepo } from "./test-harness.js";
22+
23+
afterEach(cleanupTmpPaths);
24+
25+
describe("git_show_tool", () => {
26+
test("git show on a commit returns message + diff", async () => {
27+
const repo = makeRepo();
28+
addCommit(repo, "file.txt", "content\n", "feat: add file");
29+
30+
const tool = captureTool(registerGitShowTool);
31+
const result = await tool({
32+
wd: repo,
33+
ref: "HEAD",
34+
format: "markdown",
35+
});
36+
37+
// Result should contain commit message and diff info
38+
expect(result).toContain("feat: add file");
39+
expect(result).toContain("git show HEAD");
40+
});
41+
42+
test("git show with path shows file content at ref", async () => {
43+
const repo = makeRepo();
44+
addCommit(repo, "file.txt", "first content\n", "feat: add file");
45+
addCommit(repo, "file.txt", "second content\n", "fix: update file");
46+
47+
const tool = captureTool(registerGitShowTool);
48+
const result = await tool({
49+
wd: repo,
50+
ref: "HEAD~1",
51+
path: "file.txt",
52+
format: "markdown",
53+
});
54+
55+
// Result should contain the file path and content from the previous commit
56+
expect(result).toContain("file.txt");
57+
expect(result).toContain("first content");
58+
});
59+
60+
test("git show returns JSON format", async () => {
61+
const repo = makeRepo();
62+
addCommit(repo, "file.txt", "content\n", "feat: add file");
63+
64+
const tool = captureTool(registerGitShowTool);
65+
const result = await tool({
66+
wd: repo,
67+
ref: "HEAD",
68+
format: "json",
69+
});
70+
71+
const parsed = JSON.parse(result);
72+
expect(parsed.ref).toBe("HEAD");
73+
expect(parsed.message).toContain("feat: add file");
74+
expect(typeof parsed.diff).toBe("string");
75+
});
76+
77+
test("git show not_a_git_repository error for invalid path", async () => {
78+
const tool = captureTool(registerGitShowTool);
79+
const result = await tool({
80+
wd: "/nonexistent/path",
81+
ref: "HEAD",
82+
});
83+
84+
expect(result).toContain("not_a_git_repository");
85+
});
86+
87+
test("git show invalid ref returns error", async () => {
88+
const repo = makeRepo();
89+
addCommit(repo, "file.txt", "content\n", "feat: add file");
90+
91+
const tool = captureTool(registerGitShowTool);
92+
const result = await tool({
93+
wd: repo,
94+
ref: "invalid-ref-xyz",
95+
});
96+
97+
expect(result).toContain("git_show_failed");
98+
});
99+
100+
test("git show with path includes path in JSON", async () => {
101+
const repo = makeRepo();
102+
addCommit(repo, "file.txt", "content\n", "feat: add file");
103+
104+
const tool = captureTool(registerGitShowTool);
105+
const result = await tool({
106+
wd: repo,
107+
ref: "HEAD",
108+
path: "file.txt",
109+
format: "json",
110+
});
111+
112+
const parsed = JSON.parse(result);
113+
expect(parsed.path).toBe("file.txt");
114+
expect(parsed.ref).toBe("HEAD");
115+
});
116+
117+
test("git show commit message with multiline content", async () => {
118+
const repo = makeRepo();
119+
writeFileSync(join(repo, "file.txt"), "content\n");
120+
gitCmd(repo, "add", "file.txt");
121+
gitCmd(
122+
repo,
123+
"commit",
124+
"-m",
125+
"feat: add file\n\nThis is a detailed description\nof the feature.",
126+
);
127+
128+
const tool = captureTool(registerGitShowTool);
129+
const result = await tool({
130+
wd: repo,
131+
ref: "HEAD",
132+
format: "json",
133+
});
134+
135+
const parsed = JSON.parse(result);
136+
expect(parsed.message).toContain("feat: add file");
137+
expect(parsed.message).toContain("detailed description");
138+
});
139+
});

src/server/git-show-tool.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
4+
import { gateGit, gitTopLevel, spawnGitAsync } from "./git.js";
5+
import { jsonRespond } from "./json.js";
6+
7+
// ---------------------------------------------------------------------------
8+
// Types
9+
// ---------------------------------------------------------------------------
10+
11+
interface ShowJson {
12+
ref: string;
13+
path?: string;
14+
message: string;
15+
diff?: string;
16+
}
17+
18+
// ---------------------------------------------------------------------------
19+
// Helpers
20+
// ---------------------------------------------------------------------------
21+
22+
/**
23+
* Run git show for a single ref, optionally limiting to a specific path.
24+
* Returns commit message and diff (or file content if path is specified).
25+
*/
26+
async function runGitShow(opts: {
27+
top: string;
28+
ref: string;
29+
path?: string;
30+
}): Promise<ShowJson | { error: string }> {
31+
const { top, ref, path } = opts;
32+
33+
// Build git show args. Start with --no-patch to get just the commit message.
34+
const showArgs: string[] = ["show", ref];
35+
36+
if (path) {
37+
// When path is specified, show that path at the ref without --no-patch
38+
// to get the full content at that ref
39+
showArgs.push("--", path);
40+
}
41+
42+
const r = await spawnGitAsync(top, showArgs);
43+
if (!r.ok) {
44+
return {
45+
error: "git_show_failed",
46+
};
47+
}
48+
49+
// Parse the output. For a commit, git show outputs:
50+
// - Header (commit, Author, Date, etc.)
51+
// - Blank line
52+
// - Commit message (may contain multiple lines and blank lines)
53+
// - Blank line (separator before diff)
54+
// - Diff (if --no-patch not used) or file content
55+
const output = r.stdout;
56+
let message = "";
57+
let diff = "";
58+
59+
const lines = output.split("\n");
60+
let inHeader = true;
61+
let inMessage = false;
62+
const messageLines: string[] = [];
63+
const contentLines: string[] = [];
64+
65+
for (const line of lines) {
66+
if (line === undefined) continue;
67+
68+
// End header when we see a blank line
69+
if (inHeader && line.trim() === "") {
70+
inHeader = false;
71+
inMessage = true;
72+
continue;
73+
}
74+
75+
// In message: collect until we see "diff --git" which marks the start of the diff section
76+
if (inMessage) {
77+
if (line.startsWith("diff --git")) {
78+
inMessage = false;
79+
contentLines.push(line);
80+
} else {
81+
messageLines.push(line);
82+
}
83+
} else if (!inHeader) {
84+
// In diff/content section
85+
contentLines.push(line);
86+
}
87+
}
88+
89+
message = messageLines.join("\n").trim();
90+
diff = contentLines.join("\n").trim();
91+
92+
const result: ShowJson = {
93+
ref,
94+
message,
95+
};
96+
if (path) {
97+
result.path = path;
98+
}
99+
if (diff) {
100+
result.diff = diff;
101+
}
102+
return result;
103+
}
104+
105+
// ---------------------------------------------------------------------------
106+
// Markdown rendering
107+
// ---------------------------------------------------------------------------
108+
109+
function renderShowMarkdown(result: ShowJson): string {
110+
const lines: string[] = [];
111+
lines.push(`# git show ${result.ref}`);
112+
if (result.path) {
113+
lines.push(`_path: ${result.path}_`);
114+
}
115+
lines.push("");
116+
lines.push("## Commit message");
117+
lines.push("");
118+
lines.push("```");
119+
lines.push(result.message);
120+
lines.push("```");
121+
122+
if (result.diff) {
123+
lines.push("");
124+
lines.push("## Diff");
125+
lines.push("");
126+
lines.push("```diff");
127+
lines.push(result.diff);
128+
lines.push("```");
129+
}
130+
131+
return lines.join("\n");
132+
}
133+
134+
// ---------------------------------------------------------------------------
135+
// Tool registration
136+
// ---------------------------------------------------------------------------
137+
138+
export function registerGitShowTool(server: FastMCP): void {
139+
server.addTool({
140+
name: "git_show",
141+
description:
142+
"Inspect commit content by ref/SHA. Returns commit message and diff (or file content at a specific path).",
143+
annotations: {
144+
readOnlyHint: true,
145+
},
146+
parameters: z.object({
147+
wd: z.string().describe("Working directory (git repository root or subdirectory)."),
148+
ref: z.string().describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
149+
path: z
150+
.string()
151+
.optional()
152+
.describe(
153+
"Optional file path to inspect at the ref. If provided, shows that path's content at the ref instead of the diff.",
154+
),
155+
format: z.enum(["markdown", "json"]).optional().default("markdown"),
156+
}),
157+
execute: async (args) => {
158+
const gg = gateGit();
159+
if (!gg.ok) return jsonRespond(gg.body);
160+
161+
const top = gitTopLevel(args.wd);
162+
if (!top) {
163+
return jsonRespond({ error: "not_a_git_repository", path: args.wd });
164+
}
165+
166+
const result = await runGitShow({
167+
top,
168+
ref: args.ref,
169+
path: args.path,
170+
});
171+
172+
if ("error" in result) {
173+
return jsonRespond(result);
174+
}
175+
176+
if (args.format === "json") {
177+
return jsonRespond(result as unknown as Record<string, unknown>);
178+
}
179+
180+
// Markdown
181+
return renderShowMarkdown(result);
182+
},
183+
});
184+
}

0 commit comments

Comments
 (0)