Skip to content

Commit fe7b1ba

Browse files
committed
feat: add git_stash_list and git_stash_apply MCP tools
1 parent 130445f commit fe7b1ba

3 files changed

Lines changed: 274 additions & 0 deletions

File tree

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

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Unit tests for src/server/git-stash-tool.ts.
3+
*
4+
* These tests verify the tool schema and response structure only.
5+
* Integration tests (actual git stash operations) are typically run manually
6+
* or as part of e2e test suites with real git repos.
7+
*/
8+
9+
import { describe, expect, test } from "bun:test";
10+
import { z } from "zod";
11+
12+
describe("git_stash_tool schemas", () => {
13+
// Simulating schema validation for git_stash_list
14+
const GitStashListParamsSchema = z.object({
15+
workspaceRoot: z.string().optional(),
16+
rootIndex: z.number().int().min(0).optional(),
17+
format: z.enum(["markdown", "json"]).optional().default("markdown"),
18+
});
19+
20+
test("git_stash_list: accepts valid workspaceRoot", () => {
21+
const params = { workspaceRoot: "/repo", format: "json" };
22+
expect(() => GitStashListParamsSchema.parse(params)).not.toThrow();
23+
});
24+
25+
test("git_stash_list: accepts valid rootIndex", () => {
26+
const params = { rootIndex: 0 };
27+
expect(() => GitStashListParamsSchema.parse(params)).not.toThrow();
28+
});
29+
30+
test("git_stash_list: defaults format to markdown", () => {
31+
const params = {};
32+
const parsed = GitStashListParamsSchema.parse(params);
33+
expect(parsed.format).toBe("markdown");
34+
});
35+
36+
test("git_stash_list: rejects negative rootIndex", () => {
37+
const params = { rootIndex: -1 };
38+
expect(() => GitStashListParamsSchema.parse(params)).toThrow();
39+
});
40+
41+
// Simulating schema validation for git_stash_apply
42+
const GitStashApplyParamsSchema = z.object({
43+
workspaceRoot: z.string().optional(),
44+
rootIndex: z.number().int().min(0).optional(),
45+
format: z.enum(["markdown", "json"]).optional().default("markdown"),
46+
index: z.number().int().min(0).optional().default(0),
47+
pop: z.boolean().optional().default(false),
48+
});
49+
50+
test("git_stash_apply: defaults index to 0", () => {
51+
const params = {};
52+
const parsed = GitStashApplyParamsSchema.parse(params);
53+
expect(parsed.index).toBe(0);
54+
});
55+
56+
test("git_stash_apply: defaults pop to false", () => {
57+
const params = {};
58+
const parsed = GitStashApplyParamsSchema.parse(params);
59+
expect(parsed.pop).toBe(false);
60+
});
61+
62+
test("git_stash_apply: accepts custom index", () => {
63+
const params = { index: 5 };
64+
const parsed = GitStashApplyParamsSchema.parse(params);
65+
expect(parsed.index).toBe(5);
66+
});
67+
68+
test("git_stash_apply: accepts pop true", () => {
69+
const params = { pop: true };
70+
const parsed = GitStashApplyParamsSchema.parse(params);
71+
expect(parsed.pop).toBe(true);
72+
});
73+
74+
test("git_stash_apply: rejects negative index", () => {
75+
const params = { index: -1 };
76+
expect(() => GitStashApplyParamsSchema.parse(params)).toThrow();
77+
});
78+
});
79+
80+
describe("git_stash response structures", () => {
81+
test("stash list response with no stashes returns empty array", () => {
82+
const response = { stashes: [] };
83+
expect(response.stashes).toHaveLength(0);
84+
});
85+
86+
test("stash list response includes index, message, and sha", () => {
87+
const response = {
88+
stashes: [
89+
{ index: 0, message: "WIP on main: abc1234", sha: "abc1234" },
90+
{ index: 1, message: "WIP on feature: def5678", sha: "def5678" },
91+
],
92+
};
93+
expect(response.stashes).toHaveLength(2);
94+
expect(response.stashes[0]).toHaveProperty("index");
95+
expect(response.stashes[0]).toHaveProperty("message");
96+
expect(response.stashes[0]).toHaveProperty("sha");
97+
});
98+
99+
test("stash apply response includes applied, stashIndex, popped, and optional output", () => {
100+
const response = {
101+
applied: true,
102+
stashIndex: 0,
103+
popped: false,
104+
output: "Applied without conflict",
105+
};
106+
expect(response.applied).toBe(true);
107+
expect(response.stashIndex).toBe(0);
108+
expect(response.popped).toBe(false);
109+
});
110+
111+
test("stash apply response with output field", () => {
112+
const response = {
113+
applied: false,
114+
stashIndex: 0,
115+
popped: false,
116+
output: "error: Your local changes to the following files would be overwritten",
117+
};
118+
expect(response.applied).toBe(false);
119+
expect(response.output).toBeDefined();
120+
});
121+
});

src/server/git-stash-tool.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { FastMCP } from "fastmcp";
2+
import { z } from "zod";
3+
4+
import { spawnGitAsync } from "./git.js";
5+
import { jsonRespond, spreadDefined } from "./json.js";
6+
import { requireSingleRepo } from "./roots.js";
7+
import { WorkspacePickSchema } from "./schemas.js";
8+
9+
// ---------------------------------------------------------------------------
10+
// git_stash_list
11+
// ---------------------------------------------------------------------------
12+
13+
export function registerGitStashListTool(server: FastMCP): void {
14+
server.addTool({
15+
name: "git_stash_list",
16+
description:
17+
"List all git stashes. Returns array of `{ index: number, message: string, sha: string }`.",
18+
annotations: {
19+
readOnlyHint: true,
20+
},
21+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true }).pick({
22+
workspaceRoot: true,
23+
rootIndex: true,
24+
format: true,
25+
}),
26+
execute: async (args) => {
27+
const pre = requireSingleRepo(server, args);
28+
if (!pre.ok) return jsonRespond(pre.error);
29+
const { gitTop } = pre;
30+
31+
// List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
32+
const r = await spawnGitAsync(gitTop, [
33+
"stash",
34+
"list",
35+
"--format=%(refname:short)|%(subject)|%(objectname:short)",
36+
]);
37+
38+
if (!r.ok) {
39+
// If there are no stashes, git still returns ok=true with empty output
40+
// Only treat as error if git itself failed
41+
return jsonRespond({
42+
error: "stash_list_failed",
43+
detail: (r.stderr || r.stdout).trim(),
44+
});
45+
}
46+
47+
const stashes: Array<{ index: number; message: string; sha: string }> = [];
48+
const lines = (r.stdout || "")
49+
.split("\n")
50+
.map((l) => l.trim())
51+
.filter((l) => l.length > 0);
52+
53+
for (let i = 0; i < lines.length; i++) {
54+
const line = lines[i];
55+
if (line) {
56+
const parts = line.split("|");
57+
if (parts.length >= 3 && parts[1] && parts[2]) {
58+
stashes.push({
59+
index: i,
60+
message: parts[1],
61+
sha: parts[2],
62+
});
63+
}
64+
}
65+
}
66+
67+
if (args.format === "json") {
68+
return jsonRespond({ stashes });
69+
}
70+
71+
if (stashes.length === 0) {
72+
return "# Stashes\n_(none)_";
73+
}
74+
75+
const lines_out: string[] = ["# Stashes", ""];
76+
for (const s of stashes) {
77+
lines_out.push(`- **stash@{${s.index}}** — ${s.message} (\`${s.sha}\`)`);
78+
}
79+
return lines_out.join("\n");
80+
},
81+
});
82+
}
83+
84+
// ---------------------------------------------------------------------------
85+
// git_stash_apply
86+
// ---------------------------------------------------------------------------
87+
88+
export function registerGitStashApplyTool(server: FastMCP): void {
89+
server.addTool({
90+
name: "git_stash_apply",
91+
description:
92+
"Apply or pop a git stash. `index` defaults to 0 (stash@{0}). " +
93+
"Set `pop: true` to run `git stash pop` instead of `git stash apply` (removes stash after applying). " +
94+
"Returns `{ applied: boolean, stashIndex: number, popped: boolean, output: string }`.",
95+
annotations: {
96+
readOnlyHint: false,
97+
destructiveHint: false,
98+
idempotentHint: false,
99+
},
100+
parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
101+
.pick({
102+
workspaceRoot: true,
103+
rootIndex: true,
104+
format: true,
105+
})
106+
.extend({
107+
index: z
108+
.number()
109+
.int()
110+
.min(0)
111+
.optional()
112+
.default(0)
113+
.describe("Stash index (defaults to 0 for stash@{0})."),
114+
pop: z
115+
.boolean()
116+
.optional()
117+
.default(false)
118+
.describe(
119+
"If true, runs `git stash pop` instead of `git stash apply` (removes stash after applying).",
120+
),
121+
}),
122+
execute: async (args) => {
123+
const pre = requireSingleRepo(server, args);
124+
if (!pre.ok) return jsonRespond(pre.error);
125+
const { gitTop } = pre;
126+
127+
const stashRef = `stash@{${args.index}}`;
128+
const cmd = args.pop ? "pop" : "apply";
129+
const r = await spawnGitAsync(gitTop, ["stash", cmd, stashRef]);
130+
131+
const applied = r.ok;
132+
const output = (r.stdout || r.stderr).trim();
133+
134+
if (args.format === "json") {
135+
return jsonRespond({
136+
applied,
137+
stashIndex: args.index,
138+
popped: args.pop,
139+
...spreadDefined("output", output),
140+
});
141+
}
142+
143+
const verb = args.pop ? "popped" : "applied";
144+
if (applied) {
145+
return `# Stash ${verb}\n✓ ${stashRef}${verb}`;
146+
}
147+
return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\``;
148+
},
149+
});
150+
}

src/server/tools.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { registerGitParityTool } from "./git-parity-tool.js";
1111
import { registerGitPushTool } from "./git-push-tool.js";
1212
import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
1313
import { registerGitShowTool } from "./git-show-tool.js";
14+
import { registerGitStashApplyTool, registerGitStashListTool } from "./git-stash-tool.js";
1415
import { registerGitStatusTool } from "./git-status-tool.js";
1516
import { registerGitTagTool } from "./git-tag-tool.js";
1617
import {
@@ -32,6 +33,7 @@ export function registerRethunkGitTools(server: FastMCP): void {
3233
registerGitDiffTool(server);
3334
registerGitShowTool(server);
3435
registerGitWorktreeListTool(server);
36+
registerGitStashListTool(server);
3537
// Mutating tools
3638
registerBatchCommitTool(server);
3739
registerGitPushTool(server);
@@ -41,6 +43,7 @@ export function registerRethunkGitTools(server: FastMCP): void {
4143
registerGitTagTool(server);
4244
registerGitWorktreeAddTool(server);
4345
registerGitWorktreeRemoveTool(server);
46+
registerGitStashApplyTool(server);
4447
// Resources
4548
registerPresetsResource(server);
4649
}

0 commit comments

Comments
 (0)