@rethunk/mcp-multi-root-git 3.1.0 → 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 +21 -15
- package/CHANGELOG.md +114 -39
- package/HUMANS.md +20 -39
- package/README.md +30 -13
- package/dist/repo-paths.js +57 -13
- package/dist/server/batch-commit-tool.js +187 -46
- package/dist/server/error-codes.js +25 -7
- package/dist/server/git-blame-tool.js +6 -3
- package/dist/server/git-branch-tool.js +151 -0
- package/dist/server/git-cherry-pick-tool.js +220 -11
- package/dist/server/git-conflicts-tool.js +230 -0
- package/dist/server/git-diff-summary-tool.js +36 -25
- package/dist/server/git-diff-tool.js +55 -27
- package/dist/server/git-grep-tool.js +188 -0
- package/dist/server/git-inventory-tool.js +26 -6
- package/dist/server/git-log-tool.js +35 -4
- package/dist/server/git-merge-tool.js +70 -12
- package/dist/server/git-parity-tool.js +13 -4
- package/dist/server/git-push-tool.js +3 -1
- package/dist/server/git-refs.js +48 -17
- package/dist/server/git-revert-tool.js +160 -0
- package/dist/server/git-show-tool.js +7 -3
- package/dist/server/git-stash-tool.js +110 -67
- package/dist/server/git-tag-tool.js +7 -6
- package/dist/server/git-worktree-tool.js +65 -53
- package/dist/server/git.js +116 -24
- package/dist/server/inventory.js +90 -32
- package/dist/server/presets-resource.js +37 -20
- package/dist/server/presets.js +87 -15
- package/dist/server/roots.js +13 -1
- package/dist/server/schemas.js +9 -2
- package/dist/server/test-harness.js +11 -4
- package/dist/server/tool-parameter-schemas.js +44 -55
- package/dist/server/tools.js +36 -17
- package/dist/server.js +1 -1
- package/docs/install.md +52 -5
- package/docs/mcp-tools.md +385 -178
- package/package.json +6 -6
- package/schemas/batch_commit.json +2 -2
- package/schemas/git_blame.json +1 -1
- package/schemas/git_branch.json +54 -0
- package/schemas/git_cherry_pick.json +12 -2
- package/schemas/git_cherry_pick_continue.json +34 -0
- package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
- package/schemas/git_diff.json +11 -3
- package/schemas/git_grep.json +85 -0
- package/schemas/git_inventory.json +19 -5
- package/schemas/git_log.json +7 -6
- package/schemas/git_merge.json +2 -2
- package/schemas/git_parity.json +0 -5
- package/schemas/git_revert.json +47 -0
- package/schemas/git_show.json +1 -1
- package/schemas/git_stash_push.json +47 -0
- package/schemas/git_status.json +0 -5
- package/schemas/git_worktree_add.json +1 -1
- package/schemas/git_worktree_remove.json +1 -1
- package/schemas/index.json +28 -23
- package/schemas/list_presets.json +0 -5
- package/tool-parameters.schema.json +326 -167
- package/dist/server/git-branch-list-tool.js +0 -132
- package/dist/server/git-fetch-tool.js +0 -257
- package/dist/server/git-reflog-tool.js +0 -120
- package/schemas/git_branch_list.json +0 -30
- package/schemas/git_fetch.json +0 -46
- package/schemas/git_stash_list.json +0 -24
- package/schemas/git_worktree_list.json +0 -24
|
@@ -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";
|
|
@@ -20,6 +22,9 @@ const FileEntrySchema = z.union([
|
|
|
20
22
|
.min(1)
|
|
21
23
|
.max(1000000)
|
|
22
24
|
.describe("End line number (1-indexed, inclusive)."),
|
|
25
|
+
})
|
|
26
|
+
.refine((l) => l.from <= l.to, {
|
|
27
|
+
message: "lines.from must be <= lines.to",
|
|
23
28
|
})
|
|
24
29
|
.describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
|
|
25
30
|
}),
|
|
@@ -30,7 +35,10 @@ const CommitEntrySchema = z.object({
|
|
|
30
35
|
.array(FileEntrySchema)
|
|
31
36
|
.min(1)
|
|
32
37
|
.describe("Paths to stage, relative to git root. String or `{ path, lines }` for hunk-level staging. " +
|
|
33
|
-
"
|
|
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."),
|
|
34
42
|
});
|
|
35
43
|
const PushModeSchema = z
|
|
36
44
|
.enum(["never", "after"])
|
|
@@ -42,7 +50,30 @@ const DryRunSchema = z
|
|
|
42
50
|
.boolean()
|
|
43
51
|
.optional()
|
|
44
52
|
.default(false)
|
|
45
|
-
.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
|
+
}
|
|
46
77
|
/**
|
|
47
78
|
* Parses a unified diff to extract hunks that overlap with a given line range.
|
|
48
79
|
* Returns a partial patch containing only the overlapping hunks, including header lines.
|
|
@@ -159,23 +190,42 @@ async function stageFile(gitTop, filePath, lines) {
|
|
|
159
190
|
error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
|
|
160
191
|
};
|
|
161
192
|
}
|
|
162
|
-
// Line range case: extract overlapping hunks and apply patch
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
166
222
|
}
|
|
167
|
-
const partialPatch = extractOverlappingHunks(
|
|
223
|
+
const partialPatch = extractOverlappingHunks(diffStdout, lines.from, lines.to);
|
|
168
224
|
if (!partialPatch) {
|
|
169
225
|
return { ok: false, error: "No hunks found in line range" };
|
|
170
226
|
}
|
|
171
|
-
//
|
|
172
|
-
const
|
|
173
|
-
if (!gitDirResult.ok) {
|
|
174
|
-
return { ok: false, error: (gitDirResult.stderr || gitDirResult.stdout).trim() };
|
|
175
|
-
}
|
|
176
|
-
const gitDir = gitDirResult.stdout.trim();
|
|
177
|
-
// Write partial patch to temp file in the git dir and apply it to the index
|
|
178
|
-
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`);
|
|
179
229
|
const { writeFileSync, unlinkSync } = await import("node:fs");
|
|
180
230
|
writeFileSync(tempPatchFile, partialPatch, "utf8");
|
|
181
231
|
const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
|
|
@@ -226,7 +276,11 @@ export function registerBatchCommitTool(server) {
|
|
|
226
276
|
server.addTool({
|
|
227
277
|
name: "batch_commit",
|
|
228
278
|
description: "Create multiple sequential git commits in one call. " +
|
|
229
|
-
"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. " +
|
|
230
284
|
'Optional `push: "after"` pushes after all commits succeed. `dryRun: true` previews without writing.',
|
|
231
285
|
annotations: {
|
|
232
286
|
readOnlyHint: false,
|
|
@@ -248,18 +302,18 @@ export function registerBatchCommitTool(server) {
|
|
|
248
302
|
return jsonRespond(pre.error);
|
|
249
303
|
const gitTop = pre.gitTop;
|
|
250
304
|
const results = [];
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
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;
|
|
255
308
|
if (args.dryRun) {
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
258
|
-
|
|
259
|
-
.
|
|
260
|
-
.
|
|
261
|
-
|
|
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
|
+
});
|
|
262
315
|
}
|
|
316
|
+
indexTreeBefore = wt.stdout.trim();
|
|
263
317
|
}
|
|
264
318
|
for (let i = 0; i < args.commits.length; i++) {
|
|
265
319
|
const entry = args.commits[i];
|
|
@@ -268,21 +322,38 @@ export function registerBatchCommitTool(server) {
|
|
|
268
322
|
// Normalize file entries to { path, lines? } format
|
|
269
323
|
const fileEntries = [];
|
|
270
324
|
const filePaths = [];
|
|
325
|
+
let invalidLineRange = false;
|
|
271
326
|
for (const fileEntry of entry.files) {
|
|
272
327
|
if (typeof fileEntry === "string") {
|
|
273
328
|
fileEntries.push({ path: fileEntry });
|
|
274
329
|
filePaths.push(fileEntry);
|
|
275
330
|
}
|
|
276
331
|
else {
|
|
332
|
+
if (fileEntry.lines.from > fileEntry.lines.to) {
|
|
333
|
+
invalidLineRange = true;
|
|
334
|
+
filePaths.push(fileEntry.path);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
277
337
|
fileEntries.push(fileEntry);
|
|
278
338
|
filePaths.push(fileEntry.path);
|
|
279
339
|
}
|
|
280
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
|
+
}
|
|
281
352
|
// --- Validate all paths are under the git toplevel ---
|
|
282
353
|
const escapedPaths = [];
|
|
283
354
|
for (const path of filePaths) {
|
|
284
355
|
const abs = resolvePathForRepo(path, gitTop);
|
|
285
|
-
if (!
|
|
356
|
+
if (!assertRelativePathUnderTop(path, abs, gitTop)) {
|
|
286
357
|
escapedPaths.push(path);
|
|
287
358
|
}
|
|
288
359
|
}
|
|
@@ -297,9 +368,23 @@ export function registerBatchCommitTool(server) {
|
|
|
297
368
|
});
|
|
298
369
|
break;
|
|
299
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
|
+
}
|
|
300
384
|
// --- Stage files (with optional line ranges) ---
|
|
301
385
|
let stagingFailed = false;
|
|
302
386
|
let stagingError = "";
|
|
387
|
+
const stagedSoFar = [];
|
|
303
388
|
for (const fileEntry of fileEntries) {
|
|
304
389
|
const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
|
|
305
390
|
if (!stageResult.ok) {
|
|
@@ -307,16 +392,14 @@ export function registerBatchCommitTool(server) {
|
|
|
307
392
|
stagingError = stageResult.error || "Unknown error";
|
|
308
393
|
break;
|
|
309
394
|
}
|
|
310
|
-
|
|
311
|
-
// file in this same commit entry may still fail, and anything already staged
|
|
312
|
-
// must still be unstaged so a failed dry run never leaves index state behind.
|
|
313
|
-
// Excludes paths that were already staged before this call (so pre-existing
|
|
314
|
-
// staged state survives).
|
|
315
|
-
if (args.dryRun && !preStagedPaths.has(fileEntry.path)) {
|
|
316
|
-
stagedFilesForCleanup.add(fileEntry.path);
|
|
317
|
-
}
|
|
395
|
+
stagedSoFar.push(fileEntry.path);
|
|
318
396
|
}
|
|
319
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
|
+
}
|
|
320
403
|
results.push({
|
|
321
404
|
index: i,
|
|
322
405
|
ok: false,
|
|
@@ -328,10 +411,16 @@ export function registerBatchCommitTool(server) {
|
|
|
328
411
|
});
|
|
329
412
|
break;
|
|
330
413
|
}
|
|
331
|
-
// --- Dry-run mode: collect preview
|
|
414
|
+
// --- Dry-run mode: collect preview scoped to this entry's paths ---
|
|
332
415
|
if (args.dryRun) {
|
|
333
|
-
//
|
|
334
|
-
const diffStatResult = await spawnGitAsync(gitTop, [
|
|
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
|
+
]);
|
|
335
424
|
const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
|
|
336
425
|
results.push({
|
|
337
426
|
index: i,
|
|
@@ -341,11 +430,55 @@ export function registerBatchCommitTool(server) {
|
|
|
341
430
|
staged: filePaths,
|
|
342
431
|
...spreadDefined("diffStat", diffStat || undefined),
|
|
343
432
|
});
|
|
344
|
-
|
|
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]);
|
|
345
468
|
}
|
|
346
|
-
// --- Commit ---
|
|
347
469
|
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
348
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
|
+
}
|
|
349
482
|
const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
|
|
350
483
|
results.push({
|
|
351
484
|
index: i,
|
|
@@ -358,6 +491,15 @@ export function registerBatchCommitTool(server) {
|
|
|
358
491
|
});
|
|
359
492
|
break;
|
|
360
493
|
}
|
|
494
|
+
if (indexSnap && unrelatedStaged.length > 0) {
|
|
495
|
+
await spawnGitAsync(gitTop, [
|
|
496
|
+
"restore",
|
|
497
|
+
`--source=${indexSnap}`,
|
|
498
|
+
"--staged",
|
|
499
|
+
"--",
|
|
500
|
+
...unrelatedStaged,
|
|
501
|
+
]);
|
|
502
|
+
}
|
|
361
503
|
// --- Extract SHA from commit output ---
|
|
362
504
|
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
363
505
|
const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
|
|
@@ -370,10 +512,9 @@ export function registerBatchCommitTool(server) {
|
|
|
370
512
|
...spreadDefined("output", gitOutput || undefined),
|
|
371
513
|
});
|
|
372
514
|
}
|
|
373
|
-
// --- In dry-run mode,
|
|
374
|
-
if (args.dryRun &&
|
|
375
|
-
|
|
376
|
-
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]);
|
|
377
518
|
}
|
|
378
519
|
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
379
520
|
// --- Optional push after all commits succeed (not in dry-run mode) ---
|
|
@@ -29,7 +29,13 @@ export const ERROR_CODES = {
|
|
|
29
29
|
INVALID_PATHS: "invalid_paths",
|
|
30
30
|
INVALID_REMOTE_OR_BRANCH: "invalid_remote_or_branch",
|
|
31
31
|
INVALID_SINCE: "invalid_since",
|
|
32
|
+
/** Canonical path-escape wire string (grep/diff/blame/show/stash). */
|
|
32
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
|
+
*/
|
|
33
39
|
PATH_ESCAPES_REPOSITORY: "path_escapes_repository",
|
|
34
40
|
UNSAFE_RANGE_TOKEN: "unsafe_range_token",
|
|
35
41
|
UNSAFE_REF_TOKEN: "unsafe_ref_token",
|
|
@@ -40,8 +46,11 @@ export const ERROR_CODES = {
|
|
|
40
46
|
ONTO_DETACHED_HEAD: "onto_detached_head",
|
|
41
47
|
PROTECTED_BRANCH: "protected_branch",
|
|
42
48
|
WORKING_TREE_DIRTY: "working_tree_dirty",
|
|
43
|
-
// Branch
|
|
44
|
-
|
|
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",
|
|
45
54
|
// Commit / stage
|
|
46
55
|
COMMIT_FAILED: "commit_failed",
|
|
47
56
|
STAGE_FAILED: "stage_failed",
|
|
@@ -49,16 +58,15 @@ export const ERROR_CODES = {
|
|
|
49
58
|
GIT_DIFF_FAILED: "git_diff_failed",
|
|
50
59
|
// Log
|
|
51
60
|
GIT_LOG_FAILED: "git_log_failed",
|
|
61
|
+
// Grep (pickaxe)
|
|
62
|
+
GIT_GREP_FAILED: "git_grep_failed",
|
|
52
63
|
// Show
|
|
53
64
|
GIT_SHOW_FAILED: "git_show_failed",
|
|
54
65
|
// Blame
|
|
55
66
|
GIT_BLAME_FAILED: "git_blame_failed",
|
|
56
|
-
// Reflog
|
|
57
|
-
REFLOG_FAILED: "reflog_failed",
|
|
58
67
|
// Stash
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// (uses UNSAFE_REMOTE_TOKEN and UNSAFE_REF_TOKEN)
|
|
68
|
+
STASH_APPLY_FAILED: "stash_apply_failed",
|
|
69
|
+
STASH_PUSH_FAILED: "stash_push_failed",
|
|
62
70
|
// Push
|
|
63
71
|
PUSH_DETACHED_HEAD: "push_detached_head",
|
|
64
72
|
PUSH_FAILED: "push_failed",
|
|
@@ -66,16 +74,26 @@ export const ERROR_CODES = {
|
|
|
66
74
|
// Merge
|
|
67
75
|
CANNOT_FAST_FORWARD: "cannot_fast_forward",
|
|
68
76
|
DESTINATION_NOT_FOUND: "destination_not_found",
|
|
77
|
+
MERGE_ABORT_FAILED: "merge_abort_failed",
|
|
69
78
|
MERGE_BASE_FAILED: "merge_base_failed",
|
|
70
79
|
MERGE_CONFLICTS: "merge_conflicts",
|
|
71
80
|
MERGE_FAILED: "merge_failed",
|
|
81
|
+
REBASE_ABORT_FAILED: "rebase_abort_failed",
|
|
72
82
|
REBASE_CONFLICTS: "rebase_conflicts",
|
|
73
83
|
SOURCE_NOT_FOUND: "source_not_found",
|
|
74
84
|
// Cherry-pick
|
|
75
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",
|
|
76
92
|
RANGE_RESOLUTION_FAILED: "range_resolution_failed",
|
|
77
93
|
// Reset
|
|
78
94
|
RESET_FAILED: "reset_failed",
|
|
95
|
+
// Revert
|
|
96
|
+
REVERT_ABORT_FAILED: "revert_abort_failed",
|
|
79
97
|
// Tag
|
|
80
98
|
REF_NOT_FOUND: "ref_not_found",
|
|
81
99
|
TAG_CREATE_FAILED: "tag_create_failed",
|
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
4
|
import { spawnGitAsync } from "./git.js";
|
|
5
|
-
import {
|
|
5
|
+
import { isSafeGitCommitIsh } from "./git-refs.js";
|
|
6
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
7
7
|
import { requireSingleRepo } from "./roots.js";
|
|
8
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -182,7 +182,10 @@ export function registerGitBlameTool(server) {
|
|
|
182
182
|
},
|
|
183
183
|
parameters: WorkspacePickSchema.extend({
|
|
184
184
|
path: z.string().min(1).describe("Repo-relative path to the file to blame."),
|
|
185
|
-
ref: z
|
|
185
|
+
ref: z
|
|
186
|
+
.string()
|
|
187
|
+
.optional()
|
|
188
|
+
.describe('Optional commit-ish (SHA, branch, tag) to blame at. Ancestor notation is accepted (e.g. "HEAD~3", "main^2").'),
|
|
186
189
|
startLine: z
|
|
187
190
|
.number()
|
|
188
191
|
.int()
|
|
@@ -218,7 +221,7 @@ export function registerGitBlameTool(server) {
|
|
|
218
221
|
}
|
|
219
222
|
// Ref validation
|
|
220
223
|
if (args.ref !== undefined) {
|
|
221
|
-
if (!
|
|
224
|
+
if (!isSafeGitCommitIsh(args.ref)) {
|
|
222
225
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
|
|
223
226
|
}
|
|
224
227
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isProtectedBranch, isSafeGitAncestorRef, isSafeGitRefToken } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
/** Resolve a ref (branch/commit-ish) to its full SHA; `null` if unresolvable. */
|
|
12
|
+
async function getRefSha(gitTop, ref) {
|
|
13
|
+
const result = await spawnGitAsync(gitTop, [
|
|
14
|
+
"rev-parse",
|
|
15
|
+
"--verify",
|
|
16
|
+
"--quiet",
|
|
17
|
+
`${ref}^{commit}`,
|
|
18
|
+
]);
|
|
19
|
+
if (!result.ok)
|
|
20
|
+
return null;
|
|
21
|
+
const sha = result.stdout.trim();
|
|
22
|
+
return sha === "" ? null : sha;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Tool registration
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
export function registerGitBranchTool(server) {
|
|
28
|
+
server.addTool({
|
|
29
|
+
name: "git_branch",
|
|
30
|
+
description: "Create, delete, or rename a local git branch. `action: 'create'` makes `name` from `from` " +
|
|
31
|
+
"(default HEAD); `action: 'delete'` removes `name` (`force: true` for an unmerged branch, `-D`); " +
|
|
32
|
+
"`action: 'rename'` renames `name` to `newName`. Refuses protected branch names " +
|
|
33
|
+
"(main/master/dev/develop/stable/trunk/prod/production/release*/hotfix*) in any role.",
|
|
34
|
+
annotations: {
|
|
35
|
+
readOnlyHint: false,
|
|
36
|
+
destructiveHint: true,
|
|
37
|
+
idempotentHint: false,
|
|
38
|
+
},
|
|
39
|
+
parameters: WorkspacePickSchema.extend({
|
|
40
|
+
action: z.enum(["create", "delete", "rename"]).describe("Which branch operation to perform."),
|
|
41
|
+
name: z
|
|
42
|
+
.string()
|
|
43
|
+
.min(1)
|
|
44
|
+
.describe("Branch name to create/delete, or the existing branch name for rename."),
|
|
45
|
+
from: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("Commit-ish to base a new branch on (create only). Default: HEAD."),
|
|
49
|
+
newName: z.string().optional().describe("New branch name (required for rename)."),
|
|
50
|
+
force: z
|
|
51
|
+
.boolean()
|
|
52
|
+
.optional()
|
|
53
|
+
.default(false)
|
|
54
|
+
.describe("Force-delete an unmerged branch (`git branch -D`). Delete only; never overrides protected-branch rejection."),
|
|
55
|
+
}),
|
|
56
|
+
execute: async (args) => {
|
|
57
|
+
const pre = requireSingleRepo(server, args);
|
|
58
|
+
if (!pre.ok)
|
|
59
|
+
return jsonRespond(pre.error);
|
|
60
|
+
const { gitTop } = pre;
|
|
61
|
+
const name = args.name.trim();
|
|
62
|
+
if (!isSafeGitRefToken(name)) {
|
|
63
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: name });
|
|
64
|
+
}
|
|
65
|
+
if (isProtectedBranch(name)) {
|
|
66
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: name });
|
|
67
|
+
}
|
|
68
|
+
if (args.action === "create") {
|
|
69
|
+
const fromRef = (args.from ?? "HEAD").trim();
|
|
70
|
+
if (!isSafeGitAncestorRef(fromRef)) {
|
|
71
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: fromRef });
|
|
72
|
+
}
|
|
73
|
+
const sha = await getRefSha(gitTop, fromRef);
|
|
74
|
+
if (!sha) {
|
|
75
|
+
return jsonRespond({ error: ERROR_CODES.REF_NOT_FOUND, ref: fromRef });
|
|
76
|
+
}
|
|
77
|
+
const createResult = await spawnGitAsync(gitTop, ["branch", name, fromRef]);
|
|
78
|
+
if (!createResult.ok) {
|
|
79
|
+
return jsonRespond({
|
|
80
|
+
error: ERROR_CODES.BRANCH_CREATE_FAILED,
|
|
81
|
+
detail: (createResult.stderr || createResult.stdout).trim(),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return respond(args.format, { action: "create", branch: name, sha }, fromRef);
|
|
85
|
+
}
|
|
86
|
+
if (args.action === "delete") {
|
|
87
|
+
const sha = await getRefSha(gitTop, name);
|
|
88
|
+
if (!sha) {
|
|
89
|
+
return jsonRespond({ error: ERROR_CODES.REF_NOT_FOUND, ref: name });
|
|
90
|
+
}
|
|
91
|
+
const deleteArgs = ["branch", args.force ? "-D" : "-d", name];
|
|
92
|
+
const deleteResult = await spawnGitAsync(gitTop, deleteArgs);
|
|
93
|
+
if (!deleteResult.ok) {
|
|
94
|
+
return jsonRespond({
|
|
95
|
+
error: ERROR_CODES.BRANCH_DELETE_FAILED,
|
|
96
|
+
detail: (deleteResult.stderr || deleteResult.stdout).trim(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return respond(args.format, { action: "delete", branch: name, sha });
|
|
100
|
+
}
|
|
101
|
+
// action === "rename"
|
|
102
|
+
const newName = args.newName?.trim();
|
|
103
|
+
if (!newName) {
|
|
104
|
+
return jsonRespond({ error: ERROR_CODES.MISSING_NEW_NAME });
|
|
105
|
+
}
|
|
106
|
+
if (!isSafeGitRefToken(newName)) {
|
|
107
|
+
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: newName });
|
|
108
|
+
}
|
|
109
|
+
if (isProtectedBranch(newName)) {
|
|
110
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: newName });
|
|
111
|
+
}
|
|
112
|
+
const renameResult = await spawnGitAsync(gitTop, ["branch", "-m", name, newName]);
|
|
113
|
+
if (!renameResult.ok) {
|
|
114
|
+
return jsonRespond({
|
|
115
|
+
error: ERROR_CODES.BRANCH_RENAME_FAILED,
|
|
116
|
+
detail: (renameResult.stderr || renameResult.stdout).trim(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const sha = await getRefSha(gitTop, newName);
|
|
120
|
+
if (!sha) {
|
|
121
|
+
return jsonRespond({
|
|
122
|
+
error: ERROR_CODES.BRANCH_RENAME_FAILED,
|
|
123
|
+
detail: `renamed '${name}' to '${newName}' but could not resolve its SHA`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return respond(args.format, { action: "rename", branch: newName, sha }, undefined, name);
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Output rendering
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
function respond(format, result, from, renamedFrom) {
|
|
134
|
+
if (format === "json") {
|
|
135
|
+
return jsonRespond(result);
|
|
136
|
+
}
|
|
137
|
+
const lines = [];
|
|
138
|
+
if (result.action === "create") {
|
|
139
|
+
lines.push(`# Branch created: ${result.branch}`);
|
|
140
|
+
lines.push("");
|
|
141
|
+
lines.push(`**From:** ${from ?? "HEAD"}`);
|
|
142
|
+
}
|
|
143
|
+
else if (result.action === "delete") {
|
|
144
|
+
lines.push(`# Branch deleted: ${result.branch}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
lines.push(`# Branch renamed: ${renamedFrom ?? "?"} → ${result.branch}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(`**SHA:** \`${result.sha}\``);
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|