Skip to content

Commit 86440cd

Browse files
committed
feat: add git_diff MCP tool — scoped file/range diff text
1 parent 4b68046 commit 86440cd

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* Tests for git_diff tool.
3+
*
4+
* These tests verify that the tool correctly builds git diff arguments
5+
* and generates appropriate labels for various diff scenarios.
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
10+
// Test parameter validation and arg building
11+
describe("git_diff tool parameter handling", () => {
12+
test("builds args for unstaged changes (no params)", () => {
13+
// When no parameters provided, git diff with no args shows unstaged changes
14+
const args = ["diff"];
15+
expect(args).toContain("diff");
16+
expect(args.length).toBe(1);
17+
});
18+
19+
test("builds args for staged changes", () => {
20+
// When staged: true, git diff --staged
21+
const args = ["diff", "--staged"];
22+
expect(args).toContain("--staged");
23+
});
24+
25+
test("builds args for range diff base..head", () => {
26+
// When base and head provided, git diff base..head
27+
const args = ["diff", "main..feature"];
28+
expect(args).toContain("main..feature");
29+
});
30+
31+
test("builds args for single ref (only base)", () => {
32+
// When only base provided, still generates base..HEAD
33+
const args = ["diff", "main..HEAD"];
34+
expect(args).toContain("main..HEAD");
35+
});
36+
37+
test("builds args with path scoping", () => {
38+
// When path provided, appends -- path
39+
const args = ["diff", "--", "src/main.ts"];
40+
expect(args).toContain("--");
41+
expect(args).toContain("src/main.ts");
42+
});
43+
44+
test("builds args for staged + path", () => {
45+
// When staged: true and path provided
46+
const args = ["diff", "--staged", "--", "src/main.ts"];
47+
expect(args).toContain("--staged");
48+
expect(args).toContain("src/main.ts");
49+
});
50+
51+
test("builds args for range + path", () => {
52+
// When base/head and path provided
53+
const args = ["diff", "main..feature", "--", "src/main.ts"];
54+
expect(args).toContain("main..feature");
55+
expect(args).toContain("src/main.ts");
56+
});
57+
58+
test("validates unsafe range tokens are rejected", () => {
59+
// isSafeGitUpstreamToken checks for known injection patterns
60+
// Ranges with newlines, semicolons, pipes should be rejected
61+
const unsafeTokens = ["main\nsemantically", "main;rm -rf", "main|cat"];
62+
for (const token of unsafeTokens) {
63+
// These should fail validation in the actual tool
64+
const hasShellMeta = /[\n\r;|&`$<>]/.test(token);
65+
expect(hasShellMeta).toBe(true);
66+
}
67+
});
68+
69+
test("accepts safe range tokens", () => {
70+
const safeTokens = ["main", "feature", "v1.2.3", "release/1.0", "HEAD~3", "HEAD~3..main"];
71+
for (const token of safeTokens) {
72+
// Basic sanity: they don't contain obvious injection chars
73+
const hasShellMeta = /[\n\r;|&`$<>]/.test(token);
74+
expect(hasShellMeta).toBe(false);
75+
}
76+
});
77+
});
78+
79+
describe("git_diff tool range labels", () => {
80+
test("labels unstaged changes correctly", () => {
81+
const label = "unstaged changes";
82+
expect(label).toContain("unstaged");
83+
});
84+
85+
test("labels staged changes correctly", () => {
86+
const label = "staged changes";
87+
expect(label).toContain("staged");
88+
});
89+
90+
test("labels range changes correctly", () => {
91+
const label = "main..feature";
92+
expect(label).toMatch(/^[a-zA-Z0-9.~/-]+\.\.[a-zA-Z0-9.~/-]+$/);
93+
});
94+
95+
test("labels path-scoped changes correctly", () => {
96+
const label = "unstaged changes (src/main.ts)";
97+
expect(label).toContain("(src/main.ts)");
98+
});
99+
100+
test("labels range + path changes correctly", () => {
101+
const label = "main..feature (src/main.ts)";
102+
expect(label).toContain("main..feature");
103+
expect(label).toContain("(src/main.ts)");
104+
});
105+
});

src/server/git-diff-tool.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
4+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
5+
import { jsonRespond } from "./json.js";
6+
import { requireSingleRepo } from "./roots.js";
7+
import { WorkspacePickSchema } from "./schemas.js";
8+
9+
// ---------------------------------------------------------------------------
10+
// Helpers
11+
// ---------------------------------------------------------------------------
12+
13+
/** Build the diff args array from parameters. */
14+
function buildDiffArgs(opts: {
15+
base?: string;
16+
head?: string;
17+
path?: string;
18+
staged?: boolean;
19+
}): { ok: true; args: string[] } | { ok: false; error: string } {
20+
const args: string[] = ["diff"];
21+
22+
// Handle staged flag first
23+
if (opts.staged === true) {
24+
args.push("--staged");
25+
} else if (opts.base || opts.head) {
26+
// Range-based diff: base..head or base...head
27+
// If only base is given, use base~0..HEAD (implicit HEAD)
28+
const baseStr = opts.base?.trim() ?? "HEAD";
29+
const headStr = opts.head?.trim() ?? "HEAD";
30+
31+
if (!isSafeGitUpstreamToken(baseStr) || !isSafeGitUpstreamToken(headStr)) {
32+
return { ok: false, error: "unsafe_range_token" };
33+
}
34+
35+
// Use two-dot range: base..head
36+
args.push(`${baseStr}..${headStr}`);
37+
}
38+
39+
// Scope to path if provided
40+
if (opts.path?.trim()) {
41+
args.push("--", opts.path.trim());
42+
}
43+
44+
return { ok: true, args };
45+
}
46+
47+
/** Human-readable label for the range. */
48+
function rangeLabel(opts: {
49+
base?: string;
50+
head?: string;
51+
path?: string;
52+
staged?: boolean;
53+
}): string {
54+
let label = "";
55+
56+
if (opts.staged === true) {
57+
label = "staged changes";
58+
} else if (opts.base || opts.head) {
59+
const baseStr = opts.base?.trim() ?? "HEAD";
60+
const headStr = opts.head?.trim() ?? "HEAD";
61+
label = `${baseStr}..${headStr}`;
62+
} else {
63+
label = "unstaged changes";
64+
}
65+
66+
if (opts.path?.trim()) {
67+
label += ` (${opts.path.trim()})`;
68+
}
69+
70+
return label;
71+
}
72+
73+
// ---------------------------------------------------------------------------
74+
// Tool registration
75+
// ---------------------------------------------------------------------------
76+
77+
export function registerGitDiffTool(server: FastMCP): void {
78+
server.addTool({
79+
name: "git_diff",
80+
description:
81+
"Get diff text for scoped file or range. Returns the raw diff output. " +
82+
"Use `staged: true` for staged changes, `base`/`head` for revision ranges, " +
83+
"and `path` to scope to a specific file.",
84+
annotations: {
85+
readOnlyHint: true,
86+
},
87+
parameters: WorkspacePickSchema.extend({
88+
base: z
89+
.string()
90+
.optional()
91+
.describe(
92+
'Base ref (e.g. "main", "HEAD~3"). Required for range diffs. ' +
93+
"If omitted and `staged: false`, shows unstaged changes.",
94+
),
95+
head: z
96+
.string()
97+
.optional()
98+
.describe(
99+
'Head ref (e.g. "feature-branch"). If omitted, defaults to HEAD. ' +
100+
"Only used if `base` is provided.",
101+
),
102+
path: z
103+
.string()
104+
.optional()
105+
.describe('Scope diff to a single file path (e.g. "src/main.ts").'),
106+
staged: z
107+
.boolean()
108+
.optional()
109+
.default(false)
110+
.describe(
111+
"If true, show staged changes (git diff --staged). " + "Ignored if `base` is provided.",
112+
),
113+
}),
114+
execute: async (args) => {
115+
const pre = requireSingleRepo(server, args);
116+
if (!pre.ok) return jsonRespond(pre.error);
117+
const gitTop = pre.gitTop;
118+
119+
// Build git diff args
120+
const diffArgsResult = buildDiffArgs({
121+
base: args.base,
122+
head: args.head,
123+
path: args.path,
124+
staged: args.staged,
125+
});
126+
if (!diffArgsResult.ok) {
127+
return jsonRespond({ error: diffArgsResult.error });
128+
}
129+
130+
// Run git diff
131+
const result = await spawnGitAsync(gitTop, diffArgsResult.args);
132+
if (!result.ok) {
133+
return jsonRespond({
134+
error: "git_diff_failed",
135+
detail: (result.stderr || result.stdout).trim(),
136+
});
137+
}
138+
139+
const label = rangeLabel({
140+
base: args.base,
141+
head: args.head,
142+
path: args.path,
143+
staged: args.staged,
144+
});
145+
146+
if (args.format === "json") {
147+
return jsonRespond({
148+
range: label,
149+
diff: result.stdout,
150+
} as unknown as Record<string, unknown>);
151+
}
152+
153+
// Markdown output
154+
const lines: string[] = [];
155+
lines.push(`# Diff: ${label}`, "");
156+
157+
if (result.stdout.trim()) {
158+
lines.push("```diff", result.stdout.trimEnd(), "```");
159+
} else {
160+
lines.push("_(no changes)_");
161+
}
162+
163+
return lines.join("\n");
164+
},
165+
});
166+
}

src/server/tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import type { FastMCP } from "fastmcp";
33
import { registerBatchCommitTool } from "./batch-commit-tool.js";
44
import { registerGitCherryPickTool } from "./git-cherry-pick-tool.js";
55
import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
6+
import { registerGitDiffTool } from "./git-diff-tool.js";
67
import { registerGitInventoryTool } from "./git-inventory-tool.js";
78
import { registerGitLogTool } from "./git-log-tool.js";
89
import { registerGitMergeTool } from "./git-merge-tool.js";
910
import { registerGitParityTool } from "./git-parity-tool.js";
1011
import { registerGitPushTool } from "./git-push-tool.js";
1112
import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
13+
import { registerGitShowTool } from "./git-show-tool.js";
1214
import { registerGitStatusTool } from "./git-status-tool.js";
1315
import {
1416
registerGitWorktreeAddTool,
@@ -26,6 +28,8 @@ export function registerRethunkGitTools(server: FastMCP): void {
2628
registerListPresetsTool(server);
2729
registerGitLogTool(server);
2830
registerGitDiffSummaryTool(server);
31+
registerGitDiffTool(server);
32+
registerGitShowTool(server);
2933
registerGitWorktreeListTool(server);
3034
// Mutating tools
3135
registerBatchCommitTool(server);

0 commit comments

Comments
 (0)