@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,73 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
|
|
2
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
3
4
|
import { spawnGitAsync } from "./git.js";
|
|
4
|
-
import {
|
|
5
|
+
import { conflictPaths } from "./git-refs.js";
|
|
6
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
7
|
import { requireSingleRepo } from "./roots.js";
|
|
6
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
9
|
// ---------------------------------------------------------------------------
|
|
8
|
-
// git_stash_list
|
|
9
|
-
// ---------------------------------------------------------------------------
|
|
10
|
-
export function registerGitStashListTool(server) {
|
|
11
|
-
server.addTool({
|
|
12
|
-
name: "git_stash_list",
|
|
13
|
-
description: "List all git stashes.",
|
|
14
|
-
annotations: {
|
|
15
|
-
readOnlyHint: true,
|
|
16
|
-
},
|
|
17
|
-
parameters: WorkspacePickSchema,
|
|
18
|
-
execute: async (args) => {
|
|
19
|
-
const pre = requireSingleRepo(server, args);
|
|
20
|
-
if (!pre.ok)
|
|
21
|
-
return jsonRespond(pre.error);
|
|
22
|
-
const { gitTop } = pre;
|
|
23
|
-
// List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
|
|
24
|
-
// git stash list uses git-log format (%s, %h) not for-each-ref format %(subject).
|
|
25
|
-
const r = await spawnGitAsync(gitTop, ["stash", "list", "--format=%gd|%s|%h"]);
|
|
26
|
-
if (!r.ok) {
|
|
27
|
-
// If there are no stashes, git still returns ok=true with empty output
|
|
28
|
-
// Only treat as error if git itself failed
|
|
29
|
-
return jsonRespond({
|
|
30
|
-
error: ERROR_CODES.STASH_LIST_FAILED,
|
|
31
|
-
detail: (r.stderr || r.stdout).trim(),
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
|
-
const stashes = [];
|
|
35
|
-
const lines = (r.stdout || "")
|
|
36
|
-
.split("\n")
|
|
37
|
-
.map((l) => l.trim())
|
|
38
|
-
.filter((l) => l.length > 0);
|
|
39
|
-
for (const line of lines) {
|
|
40
|
-
const parts = line.split("|");
|
|
41
|
-
// parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
|
|
42
|
-
const sha = parts[parts.length - 1];
|
|
43
|
-
const message = parts.slice(1, -1).join("|");
|
|
44
|
-
// Parse the real stash index from the canonical stash@{N} ref in parts[0].
|
|
45
|
-
const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
|
|
46
|
-
if (!indexMatch || parts.length < 3 || !message || !sha) {
|
|
47
|
-
// Malformed line — skip without affecting index tracking.
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
stashes.push({
|
|
51
|
-
index: Number(indexMatch[1]),
|
|
52
|
-
message,
|
|
53
|
-
sha,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
if (args.format === "json") {
|
|
57
|
-
return jsonRespond({ stashes });
|
|
58
|
-
}
|
|
59
|
-
if (stashes.length === 0) {
|
|
60
|
-
return "# Stashes\n_(none)_";
|
|
61
|
-
}
|
|
62
|
-
const lines_out = ["# Stashes", ""];
|
|
63
|
-
for (const s of stashes) {
|
|
64
|
-
lines_out.push(`- **stash@{${s.index}}** — ${s.message} (\`${s.sha}\`)`);
|
|
65
|
-
}
|
|
66
|
-
return lines_out.join("\n");
|
|
67
|
-
},
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
10
|
// git_stash_apply
|
|
72
11
|
// ---------------------------------------------------------------------------
|
|
73
12
|
export function registerGitStashApplyTool(server) {
|
|
@@ -76,7 +15,8 @@ export function registerGitStashApplyTool(server) {
|
|
|
76
15
|
description: "Apply or pop a git stash. `index` defaults to 0. `pop: true` removes stash after applying.",
|
|
77
16
|
annotations: {
|
|
78
17
|
readOnlyHint: false,
|
|
79
|
-
|
|
18
|
+
// pop:true deletes a stash entry — treat the tool as destructive for client filters.
|
|
19
|
+
destructiveHint: true,
|
|
80
20
|
idempotentHint: false,
|
|
81
21
|
},
|
|
82
22
|
parameters: WorkspacePickSchema.extend({
|
|
@@ -104,19 +44,122 @@ export function registerGitStashApplyTool(server) {
|
|
|
104
44
|
const r = await spawnGitAsync(gitTop, ["stash", cmd, stashRef]);
|
|
105
45
|
const applied = r.ok;
|
|
106
46
|
const output = (r.stdout || r.stderr).trim();
|
|
47
|
+
// On apply/pop failure, surface unresolved conflict paths when present
|
|
48
|
+
// (mirrors merge/cherry-pick/revert). Leave the tree as git left it —
|
|
49
|
+
// stash conflicts are not auto-aborted.
|
|
50
|
+
const paths = applied ? [] : await conflictPaths(gitTop);
|
|
107
51
|
if (args.format === "json") {
|
|
108
52
|
return jsonRespond({
|
|
53
|
+
...spreadWhen(!applied, { error: ERROR_CODES.STASH_APPLY_FAILED }),
|
|
109
54
|
applied,
|
|
110
55
|
stashIndex: args.index,
|
|
111
56
|
popped: args.pop,
|
|
112
|
-
...spreadDefined("output", output),
|
|
57
|
+
...spreadDefined("output", output || undefined),
|
|
58
|
+
...spreadWhen(paths.length > 0, { conflictPaths: paths }),
|
|
113
59
|
});
|
|
114
60
|
}
|
|
115
61
|
const verb = args.pop ? "popped" : "applied";
|
|
116
62
|
if (applied) {
|
|
117
63
|
return `# Stash ${verb}\n✓ ${stashRef} → ${verb}`;
|
|
118
64
|
}
|
|
119
|
-
|
|
65
|
+
const conflictBlock = paths.length > 0 ? `\n\nConflicts:\n${paths.map((p) => `- ${p}`).join("\n")}` : "";
|
|
66
|
+
return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\`${conflictBlock}`;
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// git_stash_push
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
export function registerGitStashPushTool(server) {
|
|
74
|
+
server.addTool({
|
|
75
|
+
name: "git_stash_push",
|
|
76
|
+
description: "Stash working-tree changes (`git stash push`). Optional `message` for the stash subject, " +
|
|
77
|
+
"`includeUntracked` (-u) to also stash untracked files, `keepIndex` (--keep-index) to leave " +
|
|
78
|
+
"staged changes in the index, and `paths` to scope the stash to specific files. " +
|
|
79
|
+
"Returns the new stash ref/SHA, or `stashed: false` if there was nothing to stash.",
|
|
80
|
+
annotations: {
|
|
81
|
+
readOnlyHint: false,
|
|
82
|
+
destructiveHint: false,
|
|
83
|
+
idempotentHint: false,
|
|
84
|
+
},
|
|
85
|
+
parameters: WorkspacePickSchema.extend({
|
|
86
|
+
message: z
|
|
87
|
+
.string()
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Stash subject message (`git stash push -m <message>`)."),
|
|
90
|
+
includeUntracked: z
|
|
91
|
+
.boolean()
|
|
92
|
+
.optional()
|
|
93
|
+
.default(false)
|
|
94
|
+
.describe("Include untracked files in the stash (`git stash push -u`)."),
|
|
95
|
+
keepIndex: z
|
|
96
|
+
.boolean()
|
|
97
|
+
.optional()
|
|
98
|
+
.default(false)
|
|
99
|
+
.describe("Keep staged changes in the index after stashing (`git stash push --keep-index`)."),
|
|
100
|
+
paths: z
|
|
101
|
+
.array(z.string())
|
|
102
|
+
.optional()
|
|
103
|
+
.describe("Scope the stash to specific paths, relative to git root (must resolve within repo root)."),
|
|
104
|
+
}),
|
|
105
|
+
execute: async (args) => {
|
|
106
|
+
const pre = requireSingleRepo(server, args);
|
|
107
|
+
if (!pre.ok)
|
|
108
|
+
return jsonRespond(pre.error);
|
|
109
|
+
const { gitTop } = pre;
|
|
110
|
+
// Union + dedup + confine paths within the repo (escaping-attempt rejected).
|
|
111
|
+
const rawPaths = [];
|
|
112
|
+
if (Array.isArray(args.paths)) {
|
|
113
|
+
for (const p of args.paths) {
|
|
114
|
+
if (typeof p === "string" && p.trim()) {
|
|
115
|
+
rawPaths.push(p.trim());
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const dedupedPaths = [...new Set(rawPaths)];
|
|
120
|
+
for (const p of dedupedPaths) {
|
|
121
|
+
const resolved = resolvePathForRepo(p, gitTop);
|
|
122
|
+
if (!assertRelativePathUnderTop(p, resolved, gitTop)) {
|
|
123
|
+
return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const stashArgs = ["stash", "push"];
|
|
127
|
+
if (args.includeUntracked)
|
|
128
|
+
stashArgs.push("-u");
|
|
129
|
+
if (args.keepIndex)
|
|
130
|
+
stashArgs.push("--keep-index");
|
|
131
|
+
if (args.message)
|
|
132
|
+
stashArgs.push("-m", args.message);
|
|
133
|
+
if (dedupedPaths.length > 0)
|
|
134
|
+
stashArgs.push("--", ...dedupedPaths);
|
|
135
|
+
const r = await spawnGitAsync(gitTop, stashArgs);
|
|
136
|
+
const output = (r.stdout || r.stderr).trim();
|
|
137
|
+
if (!r.ok) {
|
|
138
|
+
return jsonRespond({
|
|
139
|
+
error: ERROR_CODES.STASH_PUSH_FAILED,
|
|
140
|
+
detail: output,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// `git stash push` exits 0 with this message when there is nothing to stash.
|
|
144
|
+
if (/no local changes to save/i.test(output)) {
|
|
145
|
+
if (args.format === "json") {
|
|
146
|
+
return jsonRespond({ stashed: false, reason: "no_local_changes" });
|
|
147
|
+
}
|
|
148
|
+
return "# Stash push\n_(no local changes to save)_";
|
|
149
|
+
}
|
|
150
|
+
const shaResult = await spawnGitAsync(gitTop, ["rev-parse", "stash@{0}"]);
|
|
151
|
+
const sha = shaResult.ok ? shaResult.stdout.trim() : "";
|
|
152
|
+
const subjectResult = await spawnGitAsync(gitTop, ["log", "-1", "--format=%s", "stash@{0}"]);
|
|
153
|
+
const message = subjectResult.ok ? subjectResult.stdout.trim() : "";
|
|
154
|
+
if (args.format === "json") {
|
|
155
|
+
return jsonRespond({
|
|
156
|
+
stashed: true,
|
|
157
|
+
ref: "stash@{0}",
|
|
158
|
+
sha,
|
|
159
|
+
message,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return `# Stash pushed\n✓ stash@{0} — ${message}${sha ? ` (\`${sha}\`)` : ""}`;
|
|
120
163
|
},
|
|
121
164
|
});
|
|
122
165
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { ERROR_CODES } from "./error-codes.js";
|
|
3
|
-
import {
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { isSafeGitCommitIsh, isSafeGitRefToken } from "./git-refs.js";
|
|
4
5
|
import { jsonRespond } from "./json.js";
|
|
5
6
|
import { requireSingleRepo } from "./roots.js";
|
|
6
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
@@ -38,7 +39,7 @@ async function getTagType(gitTop, tag) {
|
|
|
38
39
|
export function registerGitTagTool(server) {
|
|
39
40
|
server.addTool({
|
|
40
41
|
name: "git_tag",
|
|
41
|
-
description: "Create
|
|
42
|
+
description: "Create or delete git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
|
|
42
43
|
"Returns tag name, type, and SHA.",
|
|
43
44
|
annotations: {
|
|
44
45
|
readOnlyHint: false,
|
|
@@ -69,8 +70,8 @@ export function registerGitTagTool(server) {
|
|
|
69
70
|
if (!tag) {
|
|
70
71
|
return jsonRespond({ error: ERROR_CODES.EMPTY_TAG_NAME });
|
|
71
72
|
}
|
|
72
|
-
// Validate tag name
|
|
73
|
-
if (!
|
|
73
|
+
// Validate tag name with ref-token rules (rejects .lock, //, etc.).
|
|
74
|
+
if (!isSafeGitRefToken(tag)) {
|
|
74
75
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_TAG_TOKEN, tag });
|
|
75
76
|
}
|
|
76
77
|
// Handle deletion
|
|
@@ -91,9 +92,9 @@ export function registerGitTagTool(server) {
|
|
|
91
92
|
}
|
|
92
93
|
return `Deleted tag: ${tag}`;
|
|
93
94
|
}
|
|
94
|
-
// Determine the ref to tag (default HEAD)
|
|
95
|
+
// Determine the ref to tag (default HEAD). Commit-ish allows HEAD~1 / main^2.
|
|
95
96
|
const ref = (args.ref ?? "HEAD").trim();
|
|
96
|
-
if (!
|
|
97
|
+
if (!isSafeGitCommitIsh(ref)) {
|
|
97
98
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
|
|
98
99
|
}
|
|
99
100
|
// Get the SHA of the ref to tag
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ERROR_CODES } from "./error-codes.js";
|
|
4
4
|
import { spawnGitAsync } from "./git.js";
|
|
@@ -6,38 +6,29 @@ import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs
|
|
|
6
6
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
7
7
|
import { requireSingleRepo } from "./roots.js";
|
|
8
8
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const lines = ["# Worktrees", ""];
|
|
33
|
-
for (const t of trees) {
|
|
34
|
-
const branchPart = t.branch ?? "(detached)";
|
|
35
|
-
const headPart = t.head ? ` @ ${t.head.slice(0, 7)}` : "";
|
|
36
|
-
lines.push(`- \`${t.path}\` ${branchPart}${headPart}`);
|
|
37
|
-
}
|
|
38
|
-
return lines.join("\n");
|
|
39
|
-
},
|
|
40
|
-
});
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a worktree path for add/remove.
|
|
11
|
+
*
|
|
12
|
+
* Sibling worktrees outside `gitTop` are intentional (absolute or relative).
|
|
13
|
+
* Still refuse empty/whitespace, NUL bytes, and leading-dash / option-like
|
|
14
|
+
* path tokens so git does not treat the operand as a flag. Callers must pass
|
|
15
|
+
* the resolved path after `--` in argv.
|
|
16
|
+
*/
|
|
17
|
+
function resolveWorktreePath(gitTop, rawPath) {
|
|
18
|
+
const trimmed = rawPath.trim();
|
|
19
|
+
if (trimmed.length === 0 || trimmed.includes("\0")) {
|
|
20
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
21
|
+
}
|
|
22
|
+
// Reject option-like tokens before resolve (relative "-foo") and after
|
|
23
|
+
// (absolute paths whose basename still starts with `-`, e.g. "/tmp/-evil").
|
|
24
|
+
if (trimmed.startsWith("-") || basename(trimmed).startsWith("-")) {
|
|
25
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
26
|
+
}
|
|
27
|
+
const wtPath = resolve(gitTop, trimmed);
|
|
28
|
+
if (basename(wtPath).startsWith("-")) {
|
|
29
|
+
return { ok: false, error: ERROR_CODES.INVALID_PATHS, path: rawPath };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, path: wtPath };
|
|
41
32
|
}
|
|
42
33
|
// ---------------------------------------------------------------------------
|
|
43
34
|
// git_worktree_add
|
|
@@ -55,7 +46,8 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
55
46
|
path: z
|
|
56
47
|
.string()
|
|
57
48
|
.min(1)
|
|
58
|
-
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel)."
|
|
49
|
+
.describe("Filesystem path for the new worktree (relative paths resolved from git toplevel). " +
|
|
50
|
+
"Sibling directories outside the repo toplevel are allowed; leading `-` and NUL are rejected."),
|
|
59
51
|
branch: z
|
|
60
52
|
.string()
|
|
61
53
|
.min(1)
|
|
@@ -70,34 +62,40 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
70
62
|
if (!pre.ok)
|
|
71
63
|
return jsonRespond(pre.error);
|
|
72
64
|
const { gitTop } = pre;
|
|
65
|
+
const branch = args.branch.trim();
|
|
66
|
+
const baseRef = args.baseRef !== undefined ? args.baseRef.trim() : undefined;
|
|
73
67
|
// Validate branch
|
|
74
|
-
if (!isSafeGitRefToken(
|
|
68
|
+
if (!isSafeGitRefToken(branch)) {
|
|
75
69
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
|
|
76
70
|
}
|
|
77
|
-
if (isProtectedBranch(
|
|
78
|
-
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch
|
|
71
|
+
if (isProtectedBranch(branch)) {
|
|
72
|
+
return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch });
|
|
79
73
|
}
|
|
80
|
-
if (
|
|
74
|
+
if (baseRef !== undefined && !isSafeGitRefToken(baseRef)) {
|
|
81
75
|
return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
|
|
82
76
|
}
|
|
83
|
-
|
|
84
|
-
|
|
77
|
+
const resolved = resolveWorktreePath(gitTop, args.path);
|
|
78
|
+
if (!resolved.ok) {
|
|
79
|
+
return jsonRespond({ error: resolved.error, path: resolved.path });
|
|
80
|
+
}
|
|
81
|
+
const wtPath = resolved.path;
|
|
85
82
|
// Check if branch already exists
|
|
86
83
|
const branchCheck = await spawnGitAsync(gitTop, [
|
|
87
84
|
"show-ref",
|
|
88
85
|
"--verify",
|
|
89
86
|
"--quiet",
|
|
90
|
-
`refs/heads/${
|
|
87
|
+
`refs/heads/${branch}`,
|
|
91
88
|
]);
|
|
92
89
|
const branchExists = branchCheck.ok;
|
|
90
|
+
// Path must follow `--` so a leading-dash path cannot be parsed as an option.
|
|
93
91
|
let gitArgs;
|
|
94
92
|
if (branchExists) {
|
|
95
|
-
gitArgs = ["worktree", "add", wtPath,
|
|
93
|
+
gitArgs = ["worktree", "add", "--", wtPath, branch];
|
|
96
94
|
}
|
|
97
95
|
else {
|
|
98
|
-
gitArgs = ["worktree", "add", "-b",
|
|
99
|
-
if (
|
|
100
|
-
gitArgs.push(
|
|
96
|
+
gitArgs = ["worktree", "add", "-b", branch, "--", wtPath];
|
|
97
|
+
if (baseRef)
|
|
98
|
+
gitArgs.push(baseRef);
|
|
101
99
|
}
|
|
102
100
|
const r = await spawnGitAsync(gitTop, gitArgs);
|
|
103
101
|
if (!r.ok) {
|
|
@@ -111,13 +109,13 @@ export function registerGitWorktreeAddTool(server) {
|
|
|
111
109
|
return jsonRespond({
|
|
112
110
|
ok: true,
|
|
113
111
|
path: wtPath,
|
|
114
|
-
branch
|
|
112
|
+
branch,
|
|
115
113
|
created: !branchExists,
|
|
116
|
-
...spreadDefined("baseRef", !branchExists ? (
|
|
114
|
+
...spreadDefined("baseRef", !branchExists ? (baseRef ?? "HEAD") : undefined),
|
|
117
115
|
});
|
|
118
116
|
}
|
|
119
|
-
const createdNote = branchExists ? "" : ` (new branch from ${
|
|
120
|
-
return `# Worktree added\n✓ ${wtPath} → ${
|
|
117
|
+
const createdNote = branchExists ? "" : ` (new branch from ${baseRef ?? "HEAD"})`;
|
|
118
|
+
return `# Worktree added\n✓ ${wtPath} → ${branch}${createdNote}`;
|
|
121
119
|
},
|
|
122
120
|
});
|
|
123
121
|
}
|
|
@@ -137,7 +135,8 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
137
135
|
path: z
|
|
138
136
|
.string()
|
|
139
137
|
.min(1)
|
|
140
|
-
.describe("Path of the worktree to remove. Must not be the main worktree."
|
|
138
|
+
.describe("Path of the worktree to remove. Must not be the main worktree. " +
|
|
139
|
+
"Sibling paths outside the repo toplevel are allowed; leading `-` and NUL are rejected."),
|
|
141
140
|
force: z
|
|
142
141
|
.boolean()
|
|
143
142
|
.optional()
|
|
@@ -149,20 +148,33 @@ export function registerGitWorktreeRemoveTool(server) {
|
|
|
149
148
|
if (!pre.ok)
|
|
150
149
|
return jsonRespond(pre.error);
|
|
151
150
|
const { gitTop } = pre;
|
|
151
|
+
const resolved = resolveWorktreePath(gitTop, args.path);
|
|
152
|
+
if (!resolved.ok) {
|
|
153
|
+
return jsonRespond({ error: resolved.error, path: resolved.path });
|
|
154
|
+
}
|
|
155
|
+
const wtPath = resolved.path;
|
|
152
156
|
// Refuse to remove the main worktree
|
|
153
|
-
const wtPath = resolve(gitTop, args.path);
|
|
154
157
|
if (wtPath === gitTop) {
|
|
155
158
|
return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
|
|
156
159
|
}
|
|
157
160
|
// Verify it exists in the worktree list
|
|
158
|
-
const
|
|
161
|
+
const listed = await listWorktrees(gitTop);
|
|
162
|
+
if (!listed.ok) {
|
|
163
|
+
return jsonRespond({
|
|
164
|
+
ok: false,
|
|
165
|
+
error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
|
|
166
|
+
detail: listed.detail,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const trees = listed.worktrees;
|
|
159
170
|
const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
|
|
160
171
|
if (!isRegistered) {
|
|
161
172
|
return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
|
|
162
173
|
}
|
|
163
|
-
const removeArgs = ["worktree", "remove"
|
|
174
|
+
const removeArgs = ["worktree", "remove"];
|
|
164
175
|
if (args.force)
|
|
165
176
|
removeArgs.push("--force");
|
|
177
|
+
removeArgs.push("--", wtPath);
|
|
166
178
|
const r = await spawnGitAsync(gitTop, removeArgs);
|
|
167
179
|
if (!r.ok) {
|
|
168
180
|
return jsonRespond({
|