@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.
Files changed (66) hide show
  1. package/AGENTS.md +21 -15
  2. package/CHANGELOG.md +114 -39
  3. package/HUMANS.md +20 -39
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +187 -46
  7. package/dist/server/error-codes.js +25 -7
  8. package/dist/server/git-blame-tool.js +6 -3
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +220 -11
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +36 -25
  13. package/dist/server/git-diff-tool.js +55 -27
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +26 -6
  16. package/dist/server/git-log-tool.js +35 -4
  17. package/dist/server/git-merge-tool.js +70 -12
  18. package/dist/server/git-parity-tool.js +13 -4
  19. package/dist/server/git-push-tool.js +3 -1
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-revert-tool.js +160 -0
  22. package/dist/server/git-show-tool.js +7 -3
  23. package/dist/server/git-stash-tool.js +110 -67
  24. package/dist/server/git-tag-tool.js +7 -6
  25. package/dist/server/git-worktree-tool.js +65 -53
  26. package/dist/server/git.js +116 -24
  27. package/dist/server/inventory.js +90 -32
  28. package/dist/server/presets-resource.js +37 -20
  29. package/dist/server/presets.js +87 -15
  30. package/dist/server/roots.js +13 -1
  31. package/dist/server/schemas.js +9 -2
  32. package/dist/server/test-harness.js +11 -4
  33. package/dist/server/tool-parameter-schemas.js +44 -55
  34. package/dist/server/tools.js +36 -17
  35. package/dist/server.js +1 -1
  36. package/docs/install.md +52 -5
  37. package/docs/mcp-tools.md +385 -178
  38. package/package.json +6 -6
  39. package/schemas/batch_commit.json +2 -2
  40. package/schemas/git_blame.json +1 -1
  41. package/schemas/git_branch.json +54 -0
  42. package/schemas/git_cherry_pick.json +12 -2
  43. package/schemas/git_cherry_pick_continue.json +34 -0
  44. package/schemas/{git_reflog.json → git_conflicts.json} +12 -12
  45. package/schemas/git_diff.json +11 -3
  46. package/schemas/git_grep.json +85 -0
  47. package/schemas/git_inventory.json +19 -5
  48. package/schemas/git_log.json +7 -6
  49. package/schemas/git_merge.json +2 -2
  50. package/schemas/git_parity.json +0 -5
  51. package/schemas/git_revert.json +47 -0
  52. package/schemas/git_show.json +1 -1
  53. package/schemas/git_stash_push.json +47 -0
  54. package/schemas/git_status.json +0 -5
  55. package/schemas/git_worktree_add.json +1 -1
  56. package/schemas/git_worktree_remove.json +1 -1
  57. package/schemas/index.json +28 -23
  58. package/schemas/list_presets.json +0 -5
  59. package/tool-parameters.schema.json +326 -167
  60. package/dist/server/git-branch-list-tool.js +0 -132
  61. package/dist/server/git-fetch-tool.js +0 -257
  62. package/dist/server/git-reflog-tool.js +0 -120
  63. package/schemas/git_branch_list.json +0 -30
  64. package/schemas/git_fetch.json +0 -46
  65. package/schemas/git_stash_list.json +0 -24
  66. 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 { jsonRespond, spreadDefined } from "./json.js";
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
- destructiveHint: false,
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
- return `# Stash ${verb} (failed)\n✗ ${stashRef}\n\n\`\`\`\n${output}\n\`\`\``;
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 { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
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, delete, or inspect git tags. Create annotated tags (with message) or lightweight tags (ref only). " +
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: no shell metacharacters
73
- if (!isSafeGitUpstreamToken(tag)) {
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 (!isSafeGitUpstreamToken(ref)) {
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
- // git_worktree_list
11
- // ---------------------------------------------------------------------------
12
- export function registerGitWorktreeListTool(server) {
13
- server.addTool({
14
- name: "git_worktree_list",
15
- description: "List all git worktrees for the repository (`git worktree list --porcelain`).",
16
- annotations: {
17
- readOnlyHint: true,
18
- },
19
- parameters: WorkspacePickSchema,
20
- execute: async (args) => {
21
- const pre = requireSingleRepo(server, args);
22
- if (!pre.ok)
23
- return jsonRespond(pre.error);
24
- const { gitTop } = pre;
25
- const trees = await listWorktrees(gitTop);
26
- if (args.format === "json") {
27
- return jsonRespond({ worktrees: trees });
28
- }
29
- if (trees.length === 0) {
30
- return "# Worktrees\n_(none)_";
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(args.branch)) {
68
+ if (!isSafeGitRefToken(branch)) {
75
69
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
76
70
  }
77
- if (isProtectedBranch(args.branch)) {
78
- return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
71
+ if (isProtectedBranch(branch)) {
72
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch });
79
73
  }
80
- if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
74
+ if (baseRef !== undefined && !isSafeGitRefToken(baseRef)) {
81
75
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
82
76
  }
83
- // Resolve path
84
- const wtPath = resolve(gitTop, args.path);
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/${args.branch}`,
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, args.branch];
93
+ gitArgs = ["worktree", "add", "--", wtPath, branch];
96
94
  }
97
95
  else {
98
- gitArgs = ["worktree", "add", "-b", args.branch, wtPath];
99
- if (args.baseRef)
100
- gitArgs.push(args.baseRef);
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: args.branch,
112
+ branch,
115
113
  created: !branchExists,
116
- ...spreadDefined("baseRef", !branchExists ? (args.baseRef ?? "HEAD") : undefined),
114
+ ...spreadDefined("baseRef", !branchExists ? (baseRef ?? "HEAD") : undefined),
117
115
  });
118
116
  }
119
- const createdNote = branchExists ? "" : ` (new branch from ${args.baseRef ?? "HEAD"})`;
120
- return `# Worktree added\n✓ ${wtPath} → ${args.branch}${createdNote}`;
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 trees = await listWorktrees(gitTop);
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", wtPath];
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({