Skip to content

Commit a868122

Browse files
AlbinoGeekclaude
andcommitted
feat(batch_commit): sequential multi-commit tool
First mutating tool in the server. Accepts an array of {message, files} entries, stages and commits each in order, stops on first failure. Validates all file paths against the git toplevel before staging. Returns per-commit results with SHA, or error details including which commits succeeded before the failure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9c8d9cd commit a868122

File tree

2 files changed

+168
-0
lines changed

2 files changed

+168
-0
lines changed

src/server/batch-commit-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 { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
5+
import { gitTopLevel, spawnGitAsync } from "./git.js";
6+
import { jsonRespond, spreadDefined } from "./json.js";
7+
import { requireGitAndRoots } from "./roots.js";
8+
import { WorkspacePickSchema } from "./schemas.js";
9+
10+
const CommitEntrySchema = z.object({
11+
message: z.string().min(1).describe("Commit message."),
12+
files: z.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
13+
});
14+
15+
interface CommitResult {
16+
index: number;
17+
ok: boolean;
18+
sha?: string;
19+
message: string;
20+
files: string[];
21+
error?: string;
22+
detail?: string;
23+
}
24+
25+
export function registerBatchCommitTool(server: FastMCP): void {
26+
server.addTool({
27+
name: "batch_commit",
28+
description:
29+
"Create multiple sequential git commits in a single call. " +
30+
"Each entry stages the listed files then commits with the given message. " +
31+
"Stops on first failure. See docs/mcp-tools.md.",
32+
annotations: {
33+
readOnlyHint: false,
34+
destructiveHint: false,
35+
idempotentHint: false,
36+
},
37+
parameters: WorkspacePickSchema.extend({
38+
commits: z
39+
.array(CommitEntrySchema)
40+
.min(1)
41+
.max(50)
42+
.describe("Commits to create, applied in order."),
43+
}),
44+
execute: async (args) => {
45+
const pre = requireGitAndRoots(server, args, undefined);
46+
if (!pre.ok) return jsonRespond(pre.error);
47+
48+
const rootInput = pre.roots[0];
49+
if (!rootInput) {
50+
return jsonRespond({ error: "no_workspace_root" });
51+
}
52+
53+
const gitTop = gitTopLevel(rootInput);
54+
if (!gitTop) {
55+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
56+
}
57+
58+
const results: CommitResult[] = [];
59+
60+
for (let i = 0; i < args.commits.length; i++) {
61+
const entry = args.commits[i];
62+
if (!entry) break;
63+
64+
// --- Validate all paths are under the git toplevel ---
65+
const escapedPaths: string[] = [];
66+
for (const rel of entry.files) {
67+
const abs = resolvePathForRepo(rel, gitTop);
68+
if (!isStrictlyUnderGitTop(abs, gitTop)) {
69+
escapedPaths.push(rel);
70+
}
71+
}
72+
if (escapedPaths.length > 0) {
73+
results.push({
74+
index: i,
75+
ok: false,
76+
message: entry.message,
77+
files: entry.files,
78+
error: "path_escapes_repository",
79+
detail: escapedPaths.join(", "),
80+
});
81+
break;
82+
}
83+
84+
// --- Stage files ---
85+
const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
86+
if (!addResult.ok) {
87+
results.push({
88+
index: i,
89+
ok: false,
90+
message: entry.message,
91+
files: entry.files,
92+
error: "stage_failed",
93+
detail: (addResult.stderr || addResult.stdout).trim(),
94+
});
95+
break;
96+
}
97+
98+
// --- Commit ---
99+
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
100+
if (!commitResult.ok) {
101+
results.push({
102+
index: i,
103+
ok: false,
104+
message: entry.message,
105+
files: entry.files,
106+
error: "commit_failed",
107+
detail: (commitResult.stderr || commitResult.stdout).trim(),
108+
});
109+
break;
110+
}
111+
112+
// --- Extract SHA from commit output ---
113+
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
114+
results.push({
115+
index: i,
116+
ok: true,
117+
sha: shaMatch?.[1],
118+
message: entry.message,
119+
files: entry.files,
120+
});
121+
}
122+
123+
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
124+
125+
if (args.format === "json") {
126+
return jsonRespond({
127+
ok: allOk,
128+
committed: results.filter((r) => r.ok).length,
129+
total: args.commits.length,
130+
results: results.map((r) => ({
131+
index: r.index,
132+
ok: r.ok,
133+
...spreadDefined("sha", r.sha),
134+
message: r.message,
135+
files: r.files,
136+
...spreadDefined("error", r.error),
137+
...spreadDefined("detail", r.detail),
138+
})),
139+
});
140+
}
141+
142+
// --- Markdown ---
143+
const lines: string[] = [];
144+
const header = allOk
145+
? `# Batch commit: ${results.length}/${args.commits.length} committed`
146+
: `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
147+
lines.push(header, "");
148+
149+
for (const r of results) {
150+
const icon = r.ok ? "✓" : "✗";
151+
const sha = r.sha ? ` \`${r.sha}\`` : "";
152+
lines.push(`${icon}${sha} ${r.message}`);
153+
if (!r.ok && r.detail) {
154+
lines.push(` Error: ${r.error}${r.detail}`);
155+
}
156+
}
157+
158+
if (!allOk && results.length < args.commits.length) {
159+
const skipped = args.commits.length - results.length;
160+
lines.push("", `${skipped} remaining commit(s) skipped.`);
161+
}
162+
163+
return lines.join("\n");
164+
},
165+
});
166+
}

src/server/tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FastMCP } from "fastmcp";
22

3+
import { registerBatchCommitTool } from "./batch-commit-tool.js";
34
import { registerGitInventoryTool } from "./git-inventory-tool.js";
45
import { registerGitParityTool } from "./git-parity-tool.js";
56
import { registerGitStatusTool } from "./git-status-tool.js";
@@ -11,5 +12,6 @@ export function registerRethunkGitTools(server: FastMCP): void {
1112
registerGitInventoryTool(server);
1213
registerGitParityTool(server);
1314
registerListPresetsTool(server);
15+
registerBatchCommitTool(server);
1416
registerPresetsResource(server);
1517
}

0 commit comments

Comments
 (0)