@rethunk/mcp-multi-root-git 2.9.1 → 4.0.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.
- package/AGENTS.md +23 -19
- package/CHANGELOG.md +145 -39
- package/HUMANS.md +23 -42
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +203 -53
- package/dist/server/error-codes.js +31 -16
- package/dist/server/git-blame-tool.js +66 -19
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +221 -12
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +39 -28
- package/dist/server/git-diff-tool.js +56 -31
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +30 -10
- package/dist/server/git-log-tool.js +37 -6
- package/dist/server/git-merge-tool.js +71 -13
- package/dist/server/git-parity-tool.js +15 -6
- package/dist/server/git-push-tool.js +4 -2
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-reset-soft-tool.js +1 -1
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +5 -10
- package/dist/server/git-stash-tool.js +112 -78
- package/dist/server/git-status-tool.js +2 -2
- package/dist/server/git-tag-tool.js +8 -13
- package/dist/server/git-worktree-tool.js +67 -59
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/list-presets-tool.js +2 -8
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +52 -79
- package/dist/server/schemas.js +18 -19
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +47 -58
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +472 -284
- package/package.json +6 -6
- package/schemas/batch_commit.json +5 -17
- package/schemas/git_blame.json +13 -11
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +13 -15
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/git_conflicts.json +38 -0
- package/schemas/git_diff.json +12 -10
- package/schemas/git_diff_summary.json +1 -15
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +32 -23
- package/schemas/git_log.json +20 -24
- package/schemas/git_merge.json +3 -15
- package/schemas/git_parity.json +13 -23
- package/schemas/git_push.json +1 -13
- package/schemas/git_reset_soft.json +1 -13
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +2 -8
- package/schemas/git_stash_apply.json +2 -8
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +13 -23
- package/schemas/git_tag.json +1 -7
- package/schemas/git_worktree_add.json +2 -14
- package/schemas/git_worktree_remove.json +2 -14
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +13 -23
- package/tool-parameters.schema.json +407 -423
- package/dist/server/git-branch-list-tool.js +0 -138
- package/dist/server/git-fetch-tool.js +0 -266
- package/dist/server/git-reflog-tool.js +0 -126
- package/schemas/git_branch_list.json +0 -36
- package/schemas/git_fetch.json +0 -52
- package/schemas/git_reflog.json +0 -44
- package/schemas/git_stash_list.json +0 -30
- package/schemas/git_worktree_list.json +0 -30
package/dist/repo-paths.js
CHANGED
|
@@ -1,26 +1,70 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
-
function
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
function isErrno(err, codes) {
|
|
4
|
+
const code = err?.code;
|
|
5
|
+
return typeof code === "string" && codes.includes(code);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Canonicalize for confinement: realpath the longest existing prefix, then
|
|
9
|
+
* append any missing trailing segments lexically.
|
|
10
|
+
*
|
|
11
|
+
* Fail closed (`null`) when realpath fails for a reason other than a missing
|
|
12
|
+
* path component (ENOENT/ENOTDIR), or when nothing on the chain resolves —
|
|
13
|
+
* never return the unresolved input (avoids symlink+missing-leaf false-accept).
|
|
14
|
+
*/
|
|
15
|
+
function realpathForConfinement(absPath) {
|
|
16
|
+
const abs = resolve(absPath);
|
|
17
|
+
const missing = [];
|
|
18
|
+
let cur = abs;
|
|
19
|
+
for (;;) {
|
|
20
|
+
try {
|
|
21
|
+
const resolved = realpathSync(cur);
|
|
22
|
+
return missing.length === 0 ? resolved : join(resolved, ...missing);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (!isErrno(err, ["ENOENT", "ENOTDIR"])) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const parent = dirname(cur);
|
|
29
|
+
if (parent === cur) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
missing.unshift(basename(cur));
|
|
33
|
+
cur = parent;
|
|
34
|
+
}
|
|
9
35
|
}
|
|
10
36
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const topR = realPathOrSelf(resolve(gitTop));
|
|
14
|
-
const rel = relative(topR, absR);
|
|
37
|
+
/** True when `relative(top, path)` is inside or equal to top (not `..` / `../…`). */
|
|
38
|
+
function isRelativeInsideTop(rel) {
|
|
15
39
|
if (rel === "")
|
|
16
40
|
return true;
|
|
17
|
-
|
|
41
|
+
// Exact `..` or `../` / `..\` only — do not treat a segment named `..foo` as escape.
|
|
42
|
+
if (rel === ".." || rel.startsWith(`..${sep}`))
|
|
43
|
+
return false;
|
|
44
|
+
return !isAbsolute(rel);
|
|
45
|
+
}
|
|
46
|
+
export function isStrictlyUnderGitTop(absPath, gitTop) {
|
|
47
|
+
let topR;
|
|
48
|
+
try {
|
|
49
|
+
topR = realpathSync(resolve(gitTop));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const absR = realpathForConfinement(absPath);
|
|
55
|
+
if (absR === null)
|
|
56
|
+
return false;
|
|
57
|
+
return isRelativeInsideTop(relative(topR, absR));
|
|
18
58
|
}
|
|
19
59
|
export function resolvePathForRepo(p, gitTop) {
|
|
20
60
|
const t = p.trim();
|
|
21
61
|
return isAbsolute(t) ? resolve(t) : resolve(gitTop, t);
|
|
22
62
|
}
|
|
23
63
|
/** Resolved path must lie inside git toplevel (relative or absolute user input). */
|
|
24
|
-
export function assertRelativePathUnderTop(
|
|
64
|
+
export function assertRelativePathUnderTop(userPath, absResolved, gitTop) {
|
|
65
|
+
// Use the caller-supplied path: reject mismatched abs vs resolvePathForRepo.
|
|
66
|
+
if (resolvePathForRepo(userPath, gitTop) !== absResolved) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
25
69
|
return isStrictlyUnderGitTop(absResolved, gitTop);
|
|
26
70
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
2
4
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
5
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
4
6
|
import { ERROR_CODES } from "./error-codes.js";
|
|
5
7
|
import { spawnGitAsync } from "./git.js";
|
|
6
8
|
import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
@@ -13,8 +15,16 @@ const FileEntrySchema = z.union([
|
|
|
13
15
|
path: z.string().min(1).describe("File path relative to git root."),
|
|
14
16
|
lines: z
|
|
15
17
|
.object({
|
|
16
|
-
from: z.number().int().min(1).describe("Start line number (1-indexed)."),
|
|
17
|
-
to: z
|
|
18
|
+
from: z.number().int().min(1).max(1000000).describe("Start line number (1-indexed)."),
|
|
19
|
+
to: z
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.min(1)
|
|
23
|
+
.max(1000000)
|
|
24
|
+
.describe("End line number (1-indexed, inclusive)."),
|
|
25
|
+
})
|
|
26
|
+
.refine((l) => l.from <= l.to, {
|
|
27
|
+
message: "lines.from must be <= lines.to",
|
|
18
28
|
})
|
|
19
29
|
.describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
|
|
20
30
|
}),
|
|
@@ -25,7 +35,10 @@ const CommitEntrySchema = z.object({
|
|
|
25
35
|
.array(FileEntrySchema)
|
|
26
36
|
.min(1)
|
|
27
37
|
.describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
|
|
28
|
-
"
|
|
38
|
+
"Each path is staged individually (`git add` / `git apply --cached` / `git rm --cached`); " +
|
|
39
|
+
"on mid-entry stage failure, already-staged paths for that entry are unstaged. " +
|
|
40
|
+
"Deleted tracked files are staged via `git rm --cached`. Cannot combine `lines` with a deleted file. " +
|
|
41
|
+
"Rejects `.`, the repo root, and directory pathspecs."),
|
|
29
42
|
});
|
|
30
43
|
const PushModeSchema = z
|
|
31
44
|
.enum(["never", "after"])
|
|
@@ -37,7 +50,30 @@ const DryRunSchema = z
|
|
|
37
50
|
.boolean()
|
|
38
51
|
.optional()
|
|
39
52
|
.default(false)
|
|
40
|
-
.describe("Stage files and return a preview without writing commits;
|
|
53
|
+
.describe("Stage files and return a preview without writing commits; restores the index afterwards. Response is marked DRY RUN.");
|
|
54
|
+
/**
|
|
55
|
+
* True when `path` would stage the whole tree or a directory (not a single file).
|
|
56
|
+
* Rejects `.`, `./`, paths resolving to gitTop, trailing-slash directory forms,
|
|
57
|
+
* and on-disk directories.
|
|
58
|
+
*/
|
|
59
|
+
function isWholeTreeOrDirectoryPathspec(path, gitTop) {
|
|
60
|
+
const t = path.trim();
|
|
61
|
+
if (t === "" || t === "." || t === "./")
|
|
62
|
+
return true;
|
|
63
|
+
if (t.endsWith("/") || t.endsWith("/.") || t.endsWith("/.."))
|
|
64
|
+
return true;
|
|
65
|
+
const abs = resolvePathForRepo(path, gitTop);
|
|
66
|
+
if (resolve(abs) === resolve(gitTop))
|
|
67
|
+
return true;
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(abs) && statSync(abs).isDirectory())
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// ignore stat errors — treat as non-directory
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
41
77
|
/**
|
|
42
78
|
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
43
79
|
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
@@ -119,7 +155,10 @@ function extractOverlappingHunks(diffContent, fromLine, toLine) {
|
|
|
119
155
|
i++;
|
|
120
156
|
}
|
|
121
157
|
}
|
|
122
|
-
|
|
158
|
+
// Trailing newline is required: when the selected hunk(s) aren't the last hunk in the
|
|
159
|
+
// real diff, the last copied line is a content line that had a newline after it in the
|
|
160
|
+
// original diff output. Dropping it produces a patch `git apply` rejects as corrupt.
|
|
161
|
+
return result.length > fileHeaderLines.length ? `${result.join("\n")}\n` : null;
|
|
123
162
|
}
|
|
124
163
|
/**
|
|
125
164
|
* Stages a file with optional line range. If lines are provided, only hunks
|
|
@@ -151,23 +190,42 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
151
190
|
error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
|
|
152
191
|
};
|
|
153
192
|
}
|
|
154
|
-
// Line range case: extract overlapping hunks and apply patch
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
193
|
+
// Line range case: extract overlapping hunks and apply patch.
|
|
194
|
+
// Tracked files: unstaged worktree vs index (`git diff -- path`).
|
|
195
|
+
// Untracked files: synthesize a new-file diff via `--no-index` (exit 1 with
|
|
196
|
+
// differences is expected — treat non-empty stdout as success).
|
|
197
|
+
const tracked = await spawnGitAsync(gitTop, ["ls-files", "--error-unmatch", "--", filePath]);
|
|
198
|
+
let diffStdout;
|
|
199
|
+
if (tracked.ok) {
|
|
200
|
+
const diffResult = await spawnGitAsync(gitTop, ["diff", "--", filePath]);
|
|
201
|
+
if (!diffResult.ok && !diffResult.stdout.trim()) {
|
|
202
|
+
return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
|
|
203
|
+
}
|
|
204
|
+
diffStdout = diffResult.stdout;
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const diffResult = await spawnGitAsync(gitTop, [
|
|
208
|
+
"diff",
|
|
209
|
+
"--no-index",
|
|
210
|
+
"--",
|
|
211
|
+
"/dev/null",
|
|
212
|
+
filePath,
|
|
213
|
+
]);
|
|
214
|
+
// --no-index exits 1 when files differ; accept stdout as the patch body.
|
|
215
|
+
if (!diffResult.stdout.trim()) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
error: (diffResult.stderr || diffResult.stdout || "No hunks found in line range").trim(),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
diffStdout = diffResult.stdout;
|
|
158
222
|
}
|
|
159
|
-
const partialPatch = extractOverlappingHunks(
|
|
223
|
+
const partialPatch = extractOverlappingHunks(diffStdout, lines.from, lines.to);
|
|
160
224
|
if (!partialPatch) {
|
|
161
225
|
return { ok: false, error: "No hunks found in line range" };
|
|
162
226
|
}
|
|
163
|
-
//
|
|
164
|
-
const
|
|
165
|
-
if (!gitDirResult.ok) {
|
|
166
|
-
return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
|
|
167
|
-
}
|
|
168
|
-
const gitDir = gitDirResult.stdout.trim();
|
|
169
|
-
// Write partial patch to temp file in the git dir and apply it to the index
|
|
170
|
-
const tempPatchFile = `${gitDir}/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
|
|
227
|
+
// Write partial patch to a temp file outside the git dir (avoids orphan files in .git)
|
|
228
|
+
const tempPatchFile = join(tmpdir(), `.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
|
171
229
|
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
172
230
|
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
173
231
|
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
@@ -218,14 +276,18 @@ export function registerBatchCommitTool(server) {
|
|
|
218
276
|
server.addTool({
|
|
219
277
|
name: "batch_commit",
|
|
220
278
|
description: "Create multiple sequential git commits in one call. " +
|
|
221
|
-
"Each entry stages its files then commits.
|
|
279
|
+
"Each entry stages its files then commits. Unrelated pre-staged index paths " +
|
|
280
|
+
"are temporarily unstaged around the commit so they are not included " +
|
|
281
|
+
"(hunk-level staging is preserved — pathspec commit mode is not used). " +
|
|
282
|
+
"Stops on first failure; mid-entry stage failures unstage that entry's " +
|
|
283
|
+
"already-staged paths. " +
|
|
222
284
|
'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
|
|
223
285
|
annotations: {
|
|
224
286
|
readOnlyHint: false,
|
|
225
287
|
destructiveHint: false,
|
|
226
288
|
idempotentHint: false,
|
|
227
289
|
},
|
|
228
|
-
parameters: WorkspacePickSchema.
|
|
290
|
+
parameters: WorkspacePickSchema.extend({
|
|
229
291
|
commits: z
|
|
230
292
|
.array(CommitEntrySchema)
|
|
231
293
|
.min(1)
|
|
@@ -240,18 +302,18 @@ export function registerBatchCommitTool(server) {
|
|
|
240
302
|
return jsonRespond(pre.error);
|
|
241
303
|
const gitTop = pre.gitTop;
|
|
242
304
|
const results = [];
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
let preStagedPaths = new Set();
|
|
305
|
+
// Snapshot the full index before dry-run so cleanup restores pre-staged
|
|
306
|
+
// paths even when dryRun stages additional hunks onto the same paths.
|
|
307
|
+
let indexTreeBefore;
|
|
247
308
|
if (args.dryRun) {
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
.
|
|
253
|
-
|
|
309
|
+
const wt = await spawnGitAsync(gitTop, ["write-tree"]);
|
|
310
|
+
if (!wt.ok) {
|
|
311
|
+
return jsonRespond({
|
|
312
|
+
error: ERROR_CODES.COMMIT_FAILED,
|
|
313
|
+
detail: (wt.stderr || wt.stdout).trim() || "failed to snapshot index before dryRun",
|
|
314
|
+
});
|
|
254
315
|
}
|
|
316
|
+
indexTreeBefore = wt.stdout.trim();
|
|
255
317
|
}
|
|
256
318
|
for (let i = 0; i < args.commits.length; i++) {
|
|
257
319
|
const entry = args.commits[i];
|
|
@@ -260,21 +322,38 @@ export function registerBatchCommitTool(server) {
|
|
|
260
322
|
// Normalize file entries to { path, lines? } format
|
|
261
323
|
const fileEntries = [];
|
|
262
324
|
const filePaths = [];
|
|
325
|
+
let invalidLineRange = false;
|
|
263
326
|
for (const fileEntry of entry.files) {
|
|
264
327
|
if (typeof fileEntry === "string") {
|
|
265
328
|
fileEntries.push({ path: fileEntry });
|
|
266
329
|
filePaths.push(fileEntry);
|
|
267
330
|
}
|
|
268
331
|
else {
|
|
332
|
+
if (fileEntry.lines.from > fileEntry.lines.to) {
|
|
333
|
+
invalidLineRange = true;
|
|
334
|
+
filePaths.push(fileEntry.path);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
269
337
|
fileEntries.push(fileEntry);
|
|
270
338
|
filePaths.push(fileEntry.path);
|
|
271
339
|
}
|
|
272
340
|
}
|
|
341
|
+
if (invalidLineRange) {
|
|
342
|
+
results.push({
|
|
343
|
+
index: i,
|
|
344
|
+
ok: false,
|
|
345
|
+
message: entry.message,
|
|
346
|
+
files: filePaths,
|
|
347
|
+
error: ERROR_CODES.INVALID_LINE_RANGE,
|
|
348
|
+
detail: "lines.from must be <= lines.to",
|
|
349
|
+
});
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
273
352
|
// --- Validate all paths are under the git toplevel ---
|
|
274
353
|
const escapedPaths = [];
|
|
275
354
|
for (const path of filePaths) {
|
|
276
355
|
const abs = resolvePathForRepo(path, gitTop);
|
|
277
|
-
if (!
|
|
356
|
+
if (!assertRelativePathUnderTop(path, abs, gitTop)) {
|
|
278
357
|
escapedPaths.push(path);
|
|
279
358
|
}
|
|
280
359
|
}
|
|
@@ -289,9 +368,23 @@ export function registerBatchCommitTool(server) {
|
|
|
289
368
|
});
|
|
290
369
|
break;
|
|
291
370
|
}
|
|
371
|
+
// --- Reject `.` / repo-root / directory pathspecs ---
|
|
372
|
+
const invalidPaths = filePaths.filter((p) => isWholeTreeOrDirectoryPathspec(p, gitTop));
|
|
373
|
+
if (invalidPaths.length > 0) {
|
|
374
|
+
results.push({
|
|
375
|
+
index: i,
|
|
376
|
+
ok: false,
|
|
377
|
+
message: entry.message,
|
|
378
|
+
files: filePaths,
|
|
379
|
+
error: ERROR_CODES.INVALID_PATHS,
|
|
380
|
+
detail: `directory or whole-tree pathspec rejected: ${invalidPaths.join(", ")}`,
|
|
381
|
+
});
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
292
384
|
// --- Stage files (with optional line ranges) ---
|
|
293
385
|
let stagingFailed = false;
|
|
294
386
|
let stagingError = "";
|
|
387
|
+
const stagedSoFar = [];
|
|
295
388
|
for (const fileEntry of fileEntries) {
|
|
296
389
|
const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
|
|
297
390
|
if (!stageResult.ok) {
|
|
@@ -299,8 +392,14 @@ export function registerBatchCommitTool(server) {
|
|
|
299
392
|
stagingError = stageResult.error || "Unknown error";
|
|
300
393
|
break;
|
|
301
394
|
}
|
|
395
|
+
stagedSoFar.push(fileEntry.path);
|
|
302
396
|
}
|
|
303
397
|
if (stagingFailed) {
|
|
398
|
+
// Unstage anything this entry already staged (live + dryRun).
|
|
399
|
+
// dryRun final cleanup also restores via read-tree when available.
|
|
400
|
+
if (stagedSoFar.length > 0) {
|
|
401
|
+
await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...stagedSoFar]);
|
|
402
|
+
}
|
|
304
403
|
results.push({
|
|
305
404
|
index: i,
|
|
306
405
|
ok: false,
|
|
@@ -312,19 +411,16 @@ export function registerBatchCommitTool(server) {
|
|
|
312
411
|
});
|
|
313
412
|
break;
|
|
314
413
|
}
|
|
315
|
-
//
|
|
316
|
-
// already staged before this call (so pre-existing staged state survives).
|
|
414
|
+
// --- Dry-run mode: collect preview scoped to this entry's paths ---
|
|
317
415
|
if (args.dryRun) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
// Get diff stat for this staged entry
|
|
327
|
-
const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
|
|
416
|
+
// Path-scoped stat so multi-entry previews do not accumulate prior entries.
|
|
417
|
+
const diffStatResult = await spawnGitAsync(gitTop, [
|
|
418
|
+
"diff",
|
|
419
|
+
"--staged",
|
|
420
|
+
"--stat",
|
|
421
|
+
"--",
|
|
422
|
+
...filePaths,
|
|
423
|
+
]);
|
|
328
424
|
const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
|
|
329
425
|
results.push({
|
|
330
426
|
index: i,
|
|
@@ -334,11 +430,55 @@ export function registerBatchCommitTool(server) {
|
|
|
334
430
|
staged: filePaths,
|
|
335
431
|
...spreadDefined("diffStat", diffStat || undefined),
|
|
336
432
|
});
|
|
337
|
-
|
|
433
|
+
// Unstage this entry before the next so the next entry's staging starts clean
|
|
434
|
+
// relative to the snapshot (final read-tree still restores pre-call index).
|
|
435
|
+
await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...filePaths]);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
// --- Commit: isolate entry files from unrelated pre-staged index paths ---
|
|
439
|
+
// `git commit -- <paths>` uses --only (worktree) mode and would squash
|
|
440
|
+
// hunk-level staging. Instead: snapshot index, temporarily unstage
|
|
441
|
+
// unrelated staged paths, commit from the index, then restore them.
|
|
442
|
+
const stagedNamesResult = await spawnGitAsync(gitTop, ["diff", "--cached", "--name-only"]);
|
|
443
|
+
const stagedNames = stagedNamesResult.ok
|
|
444
|
+
? stagedNamesResult.stdout
|
|
445
|
+
.split("\n")
|
|
446
|
+
.map((s) => s.trim())
|
|
447
|
+
.filter(Boolean)
|
|
448
|
+
: [];
|
|
449
|
+
const entryPathSet = new Set(filePaths);
|
|
450
|
+
const unrelatedStaged = stagedNames.filter((p) => !entryPathSet.has(p));
|
|
451
|
+
let indexSnap;
|
|
452
|
+
if (unrelatedStaged.length > 0) {
|
|
453
|
+
const wt = await spawnGitAsync(gitTop, ["write-tree"]);
|
|
454
|
+
if (!wt.ok) {
|
|
455
|
+
results.push({
|
|
456
|
+
index: i,
|
|
457
|
+
ok: false,
|
|
458
|
+
message: entry.message,
|
|
459
|
+
files: filePaths,
|
|
460
|
+
error: ERROR_CODES.COMMIT_FAILED,
|
|
461
|
+
detail: (wt.stderr || wt.stdout).trim() ||
|
|
462
|
+
"failed to snapshot index for pre-staged path isolation",
|
|
463
|
+
});
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
indexSnap = wt.stdout.trim();
|
|
467
|
+
await spawnGitAsync(gitTop, ["restore", "--staged", "--", ...unrelatedStaged]);
|
|
338
468
|
}
|
|
339
|
-
// --- Commit ---
|
|
340
469
|
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
341
470
|
if (!commitResult.ok) {
|
|
471
|
+
// Restore unrelated staged paths even on failure so we don't leave the
|
|
472
|
+
// index worse than when we entered this entry.
|
|
473
|
+
if (indexSnap && unrelatedStaged.length > 0) {
|
|
474
|
+
await spawnGitAsync(gitTop, [
|
|
475
|
+
"restore",
|
|
476
|
+
`--source=${indexSnap}`,
|
|
477
|
+
"--staged",
|
|
478
|
+
"--",
|
|
479
|
+
...unrelatedStaged,
|
|
480
|
+
]);
|
|
481
|
+
}
|
|
342
482
|
const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
|
|
343
483
|
results.push({
|
|
344
484
|
index: i,
|
|
@@ -351,6 +491,15 @@ export function registerBatchCommitTool(server) {
|
|
|
351
491
|
});
|
|
352
492
|
break;
|
|
353
493
|
}
|
|
494
|
+
if (indexSnap && unrelatedStaged.length > 0) {
|
|
495
|
+
await spawnGitAsync(gitTop, [
|
|
496
|
+
"restore",
|
|
497
|
+
`--source=${indexSnap}`,
|
|
498
|
+
"--staged",
|
|
499
|
+
"--",
|
|
500
|
+
...unrelatedStaged,
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
354
503
|
// --- Extract SHA from commit output ---
|
|
355
504
|
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
356
505
|
const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
|
|
@@ -363,10 +512,9 @@ export function registerBatchCommitTool(server) {
|
|
|
363
512
|
...spreadDefined("output", gitOutput || undefined),
|
|
364
513
|
});
|
|
365
514
|
}
|
|
366
|
-
// --- In dry-run mode,
|
|
367
|
-
if (args.dryRun &&
|
|
368
|
-
|
|
369
|
-
await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
|
|
515
|
+
// --- In dry-run mode, restore the full pre-call index ---
|
|
516
|
+
if (args.dryRun && indexTreeBefore) {
|
|
517
|
+
await spawnGitAsync(gitTop, ["read-tree", indexTreeBefore]);
|
|
370
518
|
}
|
|
371
519
|
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
372
520
|
// --- Optional push after all commits succeed (not in dry-run mode) ---
|
|
@@ -381,8 +529,10 @@ export function registerBatchCommitTool(server) {
|
|
|
381
529
|
index: r.index,
|
|
382
530
|
ok: r.ok,
|
|
383
531
|
...spreadDefined("sha", r.sha),
|
|
384
|
-
message
|
|
385
|
-
|
|
532
|
+
// message/files are the caller's own request echoed back — only worth
|
|
533
|
+
// repeating on failure, where the caller needs them to diagnose without
|
|
534
|
+
// cross-referencing the request.
|
|
535
|
+
...spreadWhen(!r.ok, { message: r.message, files: r.files }),
|
|
386
536
|
...spreadDefined("staged", r.staged),
|
|
387
537
|
...spreadDefined("diffStat", r.diffStat),
|
|
388
538
|
...spreadDefined("error", r.error),
|
|
@@ -14,15 +14,12 @@ export const ERROR_CODES = {
|
|
|
14
14
|
// Repository resolution
|
|
15
15
|
NOT_A_GIT_REPOSITORY: "not_a_git_repository",
|
|
16
16
|
NO_WORKSPACE_ROOT: "no_workspace_root",
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY: "absolute_git_roots_single_repo_only",
|
|
24
|
-
ABSOLUTE_GIT_ROOTS_TOO_MANY: "absolute_git_roots_too_many",
|
|
25
|
-
INVALID_ABSOLUTE_GIT_ROOT: "invalid_absolute_git_root",
|
|
17
|
+
// `root` array validation
|
|
18
|
+
INVALID_ROOT_PATH: "invalid_root_path",
|
|
19
|
+
ROOT_LIST_EMPTY: "root_list_empty",
|
|
20
|
+
ROOT_LIST_NESTED_OR_PRESET_CONFLICT: "root_list_nested_or_preset_conflict",
|
|
21
|
+
ROOT_LIST_PRESET_CONFLICT: "root_list_preset_conflict",
|
|
22
|
+
ROOT_LIST_TOO_MANY: "root_list_too_many",
|
|
26
23
|
// Presets
|
|
27
24
|
PRESET_FILE_INVALID: "preset_file_invalid",
|
|
28
25
|
PRESET_NOT_FOUND: "preset_not_found",
|
|
@@ -32,7 +29,13 @@ export const ERROR_CODES = {
|
|
|
32
29
|
INVALID_PATHS: "invalid_paths",
|
|
33
30
|
INVALID_REMOTE_OR_BRANCH: "invalid_remote_or_branch",
|
|
34
31
|
INVALID_SINCE: "invalid_since",
|
|
32
|
+
/** Canonical path-escape wire string (grep/diff/blame/show/stash). */
|
|
35
33
|
PATH_ESCAPES_REPO: "path_escapes_repo",
|
|
34
|
+
/**
|
|
35
|
+
* Legacy path-escape alias used by `batch_commit`. Same failure class as
|
|
36
|
+
* `PATH_ESCAPES_REPO`; clients should treat both as equivalent until a
|
|
37
|
+
* future contract bump unifies them (Worker P owns batch_commit migration).
|
|
38
|
+
*/
|
|
36
39
|
PATH_ESCAPES_REPOSITORY: "path_escapes_repository",
|
|
37
40
|
UNSAFE_RANGE_TOKEN: "unsafe_range_token",
|
|
38
41
|
UNSAFE_REF_TOKEN: "unsafe_ref_token",
|
|
@@ -43,8 +46,11 @@ export const ERROR_CODES = {
|
|
|
43
46
|
ONTO_DETACHED_HEAD: "onto_detached_head",
|
|
44
47
|
PROTECTED_BRANCH: "protected_branch",
|
|
45
48
|
WORKING_TREE_DIRTY: "working_tree_dirty",
|
|
46
|
-
// Branch
|
|
47
|
-
|
|
49
|
+
// Branch create/delete/rename
|
|
50
|
+
BRANCH_CREATE_FAILED: "branch_create_failed",
|
|
51
|
+
BRANCH_DELETE_FAILED: "branch_delete_failed",
|
|
52
|
+
BRANCH_RENAME_FAILED: "branch_rename_failed",
|
|
53
|
+
MISSING_NEW_NAME: "missing_new_name",
|
|
48
54
|
// Commit / stage
|
|
49
55
|
COMMIT_FAILED: "commit_failed",
|
|
50
56
|
STAGE_FAILED: "stage_failed",
|
|
@@ -52,16 +58,15 @@ export const ERROR_CODES = {
|
|
|
52
58
|
GIT_DIFF_FAILED: "git_diff_failed",
|
|
53
59
|
// Log
|
|
54
60
|
GIT_LOG_FAILED: "git_log_failed",
|
|
61
|
+
// Grep (pickaxe)
|
|
62
|
+
GIT_GREP_FAILED: "git_grep_failed",
|
|
55
63
|
// Show
|
|
56
64
|
GIT_SHOW_FAILED: "git_show_failed",
|
|
57
65
|
// Blame
|
|
58
66
|
GIT_BLAME_FAILED: "git_blame_failed",
|
|
59
|
-
// Reflog
|
|
60
|
-
REFLOG_FAILED: "reflog_failed",
|
|
61
67
|
// Stash
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// (uses UNSAFE_REMOTE_TOKEN and UNSAFE_REF_TOKEN)
|
|
68
|
+
STASH_APPLY_FAILED: "stash_apply_failed",
|
|
69
|
+
STASH_PUSH_FAILED: "stash_push_failed",
|
|
65
70
|
// Push
|
|
66
71
|
PUSH_DETACHED_HEAD: "push_detached_head",
|
|
67
72
|
PUSH_FAILED: "push_failed",
|
|
@@ -69,16 +74,26 @@ export const ERROR_CODES = {
|
|
|
69
74
|
// Merge
|
|
70
75
|
CANNOT_FAST_FORWARD: "cannot_fast_forward",
|
|
71
76
|
DESTINATION_NOT_FOUND: "destination_not_found",
|
|
77
|
+
MERGE_ABORT_FAILED: "merge_abort_failed",
|
|
72
78
|
MERGE_BASE_FAILED: "merge_base_failed",
|
|
73
79
|
MERGE_CONFLICTS: "merge_conflicts",
|
|
74
80
|
MERGE_FAILED: "merge_failed",
|
|
81
|
+
REBASE_ABORT_FAILED: "rebase_abort_failed",
|
|
75
82
|
REBASE_CONFLICTS: "rebase_conflicts",
|
|
76
83
|
SOURCE_NOT_FOUND: "source_not_found",
|
|
77
84
|
// Cherry-pick
|
|
78
85
|
CHECKOUT_FAILED: "checkout_failed",
|
|
86
|
+
CHERRY_PICK_ABORT_FAILED: "cherry_pick_abort_failed",
|
|
87
|
+
CHERRY_PICK_CONTINUE_FAILED: "cherry_pick_continue_failed",
|
|
88
|
+
CHERRY_PICK_IN_PROGRESS: "cherry_pick_in_progress",
|
|
89
|
+
CHERRY_PICK_TOO_MANY_COMMITS: "cherry_pick_too_many_commits",
|
|
90
|
+
CHERRY_PICK_UNRESOLVED_PATHS: "cherry_pick_unresolved_paths",
|
|
91
|
+
NO_CHERRY_PICK_IN_PROGRESS: "no_cherry_pick_in_progress",
|
|
79
92
|
RANGE_RESOLUTION_FAILED: "range_resolution_failed",
|
|
80
93
|
// Reset
|
|
81
94
|
RESET_FAILED: "reset_failed",
|
|
95
|
+
// Revert
|
|
96
|
+
REVERT_ABORT_FAILED: "revert_abort_failed",
|
|
82
97
|
// Tag
|
|
83
98
|
REF_NOT_FOUND: "ref_not_found",
|
|
84
99
|
TAG_CREATE_FAILED: "tag_create_failed",
|