@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.
Files changed (75) hide show
  1. package/AGENTS.md +23 -19
  2. package/CHANGELOG.md +145 -39
  3. package/HUMANS.md +23 -42
  4. package/README.md +30 -13
  5. package/dist/repo-paths.js +57 -13
  6. package/dist/server/batch-commit-tool.js +203 -53
  7. package/dist/server/error-codes.js +31 -16
  8. package/dist/server/git-blame-tool.js +66 -19
  9. package/dist/server/git-branch-tool.js +151 -0
  10. package/dist/server/git-cherry-pick-tool.js +221 -12
  11. package/dist/server/git-conflicts-tool.js +230 -0
  12. package/dist/server/git-diff-summary-tool.js +39 -28
  13. package/dist/server/git-diff-tool.js +56 -31
  14. package/dist/server/git-grep-tool.js +188 -0
  15. package/dist/server/git-inventory-tool.js +30 -10
  16. package/dist/server/git-log-tool.js +37 -6
  17. package/dist/server/git-merge-tool.js +71 -13
  18. package/dist/server/git-parity-tool.js +15 -6
  19. package/dist/server/git-push-tool.js +4 -2
  20. package/dist/server/git-refs.js +48 -17
  21. package/dist/server/git-reset-soft-tool.js +1 -1
  22. package/dist/server/git-revert-tool.js +160 -0
  23. package/dist/server/git-show-tool.js +5 -10
  24. package/dist/server/git-stash-tool.js +112 -78
  25. package/dist/server/git-status-tool.js +2 -2
  26. package/dist/server/git-tag-tool.js +8 -13
  27. package/dist/server/git-worktree-tool.js +67 -59
  28. package/dist/server/git.js +116 -24
  29. package/dist/server/inventory.js +90 -32
  30. package/dist/server/list-presets-tool.js +2 -8
  31. package/dist/server/presets-resource.js +37 -20
  32. package/dist/server/presets.js +87 -15
  33. package/dist/server/roots.js +52 -79
  34. package/dist/server/schemas.js +18 -19
  35. package/dist/server/test-harness.js +11 -4
  36. package/dist/server/tool-parameter-schemas.js +47 -58
  37. package/dist/server/tools.js +36 -17
  38. package/dist/server.js +1 -1
  39. package/docs/install.md +52 -5
  40. package/docs/mcp-tools.md +472 -284
  41. package/package.json +6 -6
  42. package/schemas/batch_commit.json +5 -17
  43. package/schemas/git_blame.json +13 -11
  44. package/schemas/git_branch.json +54 -0
  45. package/schemas/git_cherry_pick.json +13 -15
  46. package/schemas/git_cherry_pick_continue.json +34 -0
  47. package/schemas/git_conflicts.json +38 -0
  48. package/schemas/git_diff.json +12 -10
  49. package/schemas/git_diff_summary.json +1 -15
  50. package/schemas/git_grep.json +85 -0
  51. package/schemas/git_inventory.json +32 -23
  52. package/schemas/git_log.json +20 -24
  53. package/schemas/git_merge.json +3 -15
  54. package/schemas/git_parity.json +13 -23
  55. package/schemas/git_push.json +1 -13
  56. package/schemas/git_reset_soft.json +1 -13
  57. package/schemas/git_revert.json +47 -0
  58. package/schemas/git_show.json +2 -8
  59. package/schemas/git_stash_apply.json +2 -8
  60. package/schemas/git_stash_push.json +47 -0
  61. package/schemas/git_status.json +13 -23
  62. package/schemas/git_tag.json +1 -7
  63. package/schemas/git_worktree_add.json +2 -14
  64. package/schemas/git_worktree_remove.json +2 -14
  65. package/schemas/index.json +28 -23
  66. package/schemas/list_presets.json +13 -23
  67. package/tool-parameters.schema.json +407 -423
  68. package/dist/server/git-branch-list-tool.js +0 -138
  69. package/dist/server/git-fetch-tool.js +0 -266
  70. package/dist/server/git-reflog-tool.js +0 -126
  71. package/schemas/git_branch_list.json +0 -36
  72. package/schemas/git_fetch.json +0 -52
  73. package/schemas/git_reflog.json +0 -44
  74. package/schemas/git_stash_list.json +0 -30
  75. package/schemas/git_worktree_list.json +0 -30
@@ -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,42 +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.pick({
20
- workspaceRoot: true,
21
- rootIndex: true,
22
- format: true,
23
- }),
24
- execute: async (args) => {
25
- const pre = requireSingleRepo(server, args);
26
- if (!pre.ok)
27
- return jsonRespond(pre.error);
28
- const { gitTop } = pre;
29
- const trees = await listWorktrees(gitTop);
30
- if (args.format === "json") {
31
- return jsonRespond({ worktrees: trees });
32
- }
33
- if (trees.length === 0) {
34
- return "# Worktrees\n_(none)_";
35
- }
36
- const lines = ["# Worktrees", ""];
37
- for (const t of trees) {
38
- const branchPart = t.branch ?? "(detached)";
39
- const headPart = t.head ? ` @ ${t.head.slice(0, 7)}` : "";
40
- lines.push(`- \`${t.path}\` ${branchPart}${headPart}`);
41
- }
42
- return lines.join("\n");
43
- },
44
- });
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 };
45
32
  }
46
33
  // ---------------------------------------------------------------------------
47
34
  // git_worktree_add
@@ -55,11 +42,12 @@ export function registerGitWorktreeAddTool(server) {
55
42
  destructiveHint: false,
56
43
  idempotentHint: false,
57
44
  },
58
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
45
+ parameters: WorkspacePickSchema.extend({
59
46
  path: z
60
47
  .string()
61
48
  .min(1)
62
- .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."),
63
51
  branch: z
64
52
  .string()
65
53
  .min(1)
@@ -74,34 +62,40 @@ export function registerGitWorktreeAddTool(server) {
74
62
  if (!pre.ok)
75
63
  return jsonRespond(pre.error);
76
64
  const { gitTop } = pre;
65
+ const branch = args.branch.trim();
66
+ const baseRef = args.baseRef !== undefined ? args.baseRef.trim() : undefined;
77
67
  // Validate branch
78
- if (!isSafeGitRefToken(args.branch)) {
68
+ if (!isSafeGitRefToken(branch)) {
79
69
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
80
70
  }
81
- if (isProtectedBranch(args.branch)) {
82
- return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
71
+ if (isProtectedBranch(branch)) {
72
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch });
83
73
  }
84
- if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
74
+ if (baseRef !== undefined && !isSafeGitRefToken(baseRef)) {
85
75
  return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
86
76
  }
87
- // Resolve path
88
- 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;
89
82
  // Check if branch already exists
90
83
  const branchCheck = await spawnGitAsync(gitTop, [
91
84
  "show-ref",
92
85
  "--verify",
93
86
  "--quiet",
94
- `refs/heads/${args.branch}`,
87
+ `refs/heads/${branch}`,
95
88
  ]);
96
89
  const branchExists = branchCheck.ok;
90
+ // Path must follow `--` so a leading-dash path cannot be parsed as an option.
97
91
  let gitArgs;
98
92
  if (branchExists) {
99
- gitArgs = ["worktree", "add", wtPath, args.branch];
93
+ gitArgs = ["worktree", "add", "--", wtPath, branch];
100
94
  }
101
95
  else {
102
- gitArgs = ["worktree", "add", "-b", args.branch, wtPath];
103
- if (args.baseRef)
104
- gitArgs.push(args.baseRef);
96
+ gitArgs = ["worktree", "add", "-b", branch, "--", wtPath];
97
+ if (baseRef)
98
+ gitArgs.push(baseRef);
105
99
  }
106
100
  const r = await spawnGitAsync(gitTop, gitArgs);
107
101
  if (!r.ok) {
@@ -115,13 +109,13 @@ export function registerGitWorktreeAddTool(server) {
115
109
  return jsonRespond({
116
110
  ok: true,
117
111
  path: wtPath,
118
- branch: args.branch,
112
+ branch,
119
113
  created: !branchExists,
120
- ...spreadDefined("baseRef", !branchExists ? (args.baseRef ?? "HEAD") : undefined),
114
+ ...spreadDefined("baseRef", !branchExists ? (baseRef ?? "HEAD") : undefined),
121
115
  });
122
116
  }
123
- const createdNote = branchExists ? "" : ` (new branch from ${args.baseRef ?? "HEAD"})`;
124
- 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}`;
125
119
  },
126
120
  });
127
121
  }
@@ -137,11 +131,12 @@ export function registerGitWorktreeRemoveTool(server) {
137
131
  destructiveHint: true,
138
132
  idempotentHint: false,
139
133
  },
140
- parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true }).extend({
134
+ parameters: WorkspacePickSchema.extend({
141
135
  path: z
142
136
  .string()
143
137
  .min(1)
144
- .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."),
145
140
  force: z
146
141
  .boolean()
147
142
  .optional()
@@ -153,20 +148,33 @@ export function registerGitWorktreeRemoveTool(server) {
153
148
  if (!pre.ok)
154
149
  return jsonRespond(pre.error);
155
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;
156
156
  // Refuse to remove the main worktree
157
- const wtPath = resolve(gitTop, args.path);
158
157
  if (wtPath === gitTop) {
159
158
  return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
160
159
  }
161
160
  // Verify it exists in the worktree list
162
- 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;
163
170
  const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
164
171
  if (!isRegistered) {
165
172
  return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
166
173
  }
167
- const removeArgs = ["worktree", "remove", wtPath];
174
+ const removeArgs = ["worktree", "remove"];
168
175
  if (args.force)
169
176
  removeArgs.push("--force");
177
+ removeArgs.push("--", wtPath);
170
178
  const r = await spawnGitAsync(gitTop, removeArgs);
171
179
  if (!r.ok) {
172
180
  return jsonRespond({
@@ -7,13 +7,11 @@ import { ERROR_CODES } from "./error-codes.js";
7
7
  * Parallel git subprocesses for inventory rows and git_status submodule rows.
8
8
  * Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
9
9
  */
10
- function resolveGitSubprocessParallelism() {
11
- const env = process.env.GIT_SUBPROCESS_PARALLELISM;
12
- if (env) {
13
- const n = Number.parseInt(env, 10);
10
+ export function resolveGitSubprocessParallelism(envValue = process.env.GIT_SUBPROCESS_PARALLELISM, cpuCount = cpus().length) {
11
+ if (envValue) {
12
+ const n = Number.parseInt(envValue, 10);
14
13
  if (!Number.isNaN(n) && n >= 1) {
15
- const cpuCount = cpus().length;
16
- const maxParallel = cpuCount * 2;
14
+ const maxParallel = Math.max(1, cpuCount * 2);
17
15
  return Math.min(n, maxParallel);
18
16
  }
19
17
  }
@@ -26,10 +24,9 @@ export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
26
24
  * A value of 0 (or negative/NaN) disables the timeout — use for operations
27
25
  * like large clones where unbounded wait is intentional.
28
26
  */
29
- function resolveGitSubprocessTimeoutMs() {
30
- const env = process.env.GIT_SUBPROCESS_TIMEOUT_MS;
31
- if (env) {
32
- const n = Number.parseInt(env, 10);
27
+ export function resolveGitSubprocessTimeoutMs(envValue = process.env.GIT_SUBPROCESS_TIMEOUT_MS) {
28
+ if (envValue) {
29
+ const n = Number.parseInt(envValue, 10);
33
30
  if (!Number.isNaN(n) && n > 0)
34
31
  return n;
35
32
  // 0 or negative → disabled
@@ -39,10 +36,31 @@ function resolveGitSubprocessTimeoutMs() {
39
36
  return 120_000;
40
37
  }
41
38
  export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
39
+ /**
40
+ * Max combined stdout+stderr bytes retained from spawnGitAsync.
41
+ * Env: GIT_SUBPROCESS_MAX_BUFFER_BYTES (default 16 MiB). Exceeding kills the child.
42
+ */
43
+ export function resolveGitSubprocessMaxBufferBytes(envValue = process.env.GIT_SUBPROCESS_MAX_BUFFER_BYTES) {
44
+ if (envValue) {
45
+ const n = Number.parseInt(envValue, 10);
46
+ if (!Number.isNaN(n) && n >= 1024)
47
+ return n;
48
+ }
49
+ return 16 * 1024 * 1024;
50
+ }
51
+ export const GIT_SUBPROCESS_MAX_BUFFER_BYTES = resolveGitSubprocessMaxBufferBytes();
52
+ /** Delay after SIGTERM before escalating to SIGKILL (spawnGitAsync timeout/abort/overflow). */
53
+ export const GIT_SUBPROCESS_SIGKILL_ESCALATION_MS = 2_000;
54
+ /** Timeout for sync spawnSync helpers (gateGit, rev-parse). */
55
+ export const GIT_SYNC_TIMEOUT_MS = 30_000;
42
56
  let gitPathState = "unknown";
43
57
  const GIT_NOT_FOUND_BODY = {
44
58
  error: ERROR_CODES.GIT_NOT_FOUND,
45
59
  };
60
+ /** Test-only: reset the cached git-on-PATH probe. */
61
+ export function resetGitPathStateForTests() {
62
+ gitPathState = "unknown";
63
+ }
46
64
  export function gateGit() {
47
65
  if (gitPathState === "ok") {
48
66
  return { ok: true };
@@ -53,9 +71,16 @@ export function gateGit() {
53
71
  body: GIT_NOT_FOUND_BODY,
54
72
  };
55
73
  }
56
- const r = spawnSync("git", ["--version"], { encoding: "utf8" });
57
- if (r.status !== 0) {
58
- gitPathState = "missing";
74
+ const r = spawnSync("git", ["--version"], {
75
+ encoding: "utf8",
76
+ timeout: GIT_SYNC_TIMEOUT_MS,
77
+ });
78
+ if (r.error || r.status !== 0) {
79
+ // Do not cache "missing" on timeout — a wedged git may recover.
80
+ const timedOut = r.error !== undefined && r.error.code === "ETIMEDOUT";
81
+ if (!timedOut) {
82
+ gitPathState = "missing";
83
+ }
59
84
  return {
60
85
  ok: false,
61
86
  body: GIT_NOT_FOUND_BODY,
@@ -71,8 +96,9 @@ export function gitTopLevel(cwd) {
71
96
  const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
72
97
  cwd,
73
98
  encoding: "utf8",
99
+ timeout: GIT_SYNC_TIMEOUT_MS,
74
100
  });
75
- if (r.status !== 0)
101
+ if (r.error || r.status !== 0)
76
102
  return null;
77
103
  return r.stdout.trim();
78
104
  }
@@ -80,15 +106,17 @@ export function gitRevParseGitDir(cwd) {
80
106
  const r = spawnSync("git", ["rev-parse", "--git-dir"], {
81
107
  cwd,
82
108
  encoding: "utf8",
109
+ timeout: GIT_SYNC_TIMEOUT_MS,
83
110
  });
84
- return r.status === 0;
111
+ return !r.error && r.status === 0;
85
112
  }
86
113
  export function gitRevParseHead(cwd) {
87
114
  const r = spawnSync("git", ["rev-parse", "HEAD"], {
88
115
  cwd,
89
116
  encoding: "utf8",
117
+ timeout: GIT_SYNC_TIMEOUT_MS,
90
118
  });
91
- if (r.status !== 0) {
119
+ if (r.error || r.status !== 0) {
92
120
  return { ok: false, text: (r.stderr || r.stdout || "git rev-parse HEAD failed").trim() };
93
121
  }
94
122
  return { ok: true, sha: r.stdout.trim(), text: r.stdout.trim() };
@@ -163,17 +191,70 @@ export async function asyncPool(items, concurrency, fn) {
163
191
  }
164
192
  export function spawnGitAsync(cwd, args, opts) {
165
193
  return new Promise((resolveP) => {
166
- const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
194
+ const child = spawn("git", args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
167
195
  let stdout = "";
168
196
  let stderr = "";
169
197
  let settled = false;
198
+ let stdoutBytes = 0;
199
+ let stderrBytes = 0;
200
+ let sigkillTimer;
201
+ const maxBuffer = opts?.maxBufferBytes ?? GIT_SUBPROCESS_MAX_BUFFER_BYTES;
202
+ const sigkillAfter = opts?.sigkillAfterMs ?? GIT_SUBPROCESS_SIGKILL_ESCALATION_MS;
203
+ if (!opts?.holdStdin) {
204
+ child.stdin?.end();
205
+ }
170
206
  child.stdout?.setEncoding("utf8");
171
207
  child.stderr?.setEncoding("utf8");
208
+ function escalateKill() {
209
+ try {
210
+ child.kill("SIGTERM");
211
+ }
212
+ catch {
213
+ /* already dead */
214
+ }
215
+ if (sigkillTimer !== undefined)
216
+ return;
217
+ sigkillTimer = setTimeout(() => {
218
+ sigkillTimer = undefined;
219
+ try {
220
+ if (!settled && child.exitCode === null && child.signalCode === null) {
221
+ child.kill("SIGKILL");
222
+ }
223
+ }
224
+ catch {
225
+ /* already dead */
226
+ }
227
+ }, sigkillAfter);
228
+ // Do not keep the process alive solely for SIGKILL escalation.
229
+ if (typeof sigkillTimer === "object" && "unref" in sigkillTimer) {
230
+ sigkillTimer.unref();
231
+ }
232
+ }
233
+ function onChunk(stream, chunk) {
234
+ const byteLen = Buffer.byteLength(chunk, "utf8");
235
+ if (stream === "stdout") {
236
+ stdoutBytes += byteLen;
237
+ stdout += chunk;
238
+ }
239
+ else {
240
+ stderrBytes += byteLen;
241
+ stderr += chunk;
242
+ }
243
+ if (stdoutBytes + stderrBytes > maxBuffer) {
244
+ escalateKill();
245
+ settle({
246
+ ok: false,
247
+ stdout,
248
+ stderr: `${stderr}\n<git output exceeded ${maxBuffer} bytes>`,
249
+ truncated: true,
250
+ });
251
+ }
252
+ }
172
253
  child.stdout?.on("data", (c) => {
173
- stdout += c;
254
+ onChunk("stdout", c);
174
255
  });
175
256
  child.stderr?.on("data", (c) => {
176
- stderr += c;
257
+ onChunk("stderr", c);
177
258
  });
178
259
  const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
179
260
  let timer;
@@ -183,10 +264,20 @@ export function spawnGitAsync(cwd, args, opts) {
183
264
  clearTimeout(timer);
184
265
  timer = undefined;
185
266
  }
267
+ if (sigkillTimer !== undefined) {
268
+ clearTimeout(sigkillTimer);
269
+ sigkillTimer = undefined;
270
+ }
186
271
  if (abortListener !== undefined && opts?.signal) {
187
272
  opts.signal.removeEventListener("abort", abortListener);
188
273
  abortListener = undefined;
189
274
  }
275
+ try {
276
+ child.stdin?.destroy();
277
+ }
278
+ catch {
279
+ /* ignore */
280
+ }
190
281
  }
191
282
  function settle(result) {
192
283
  if (settled)
@@ -195,15 +286,18 @@ export function spawnGitAsync(cwd, args, opts) {
195
286
  cleanup();
196
287
  resolveP(result);
197
288
  }
289
+ // Register lifecycle handlers before any early kill so close/error are observed.
290
+ child.on("error", () => settle({ ok: false, stdout, stderr }));
291
+ child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
198
292
  // AbortSignal: kill immediately if already aborted, else listen
199
293
  if (opts?.signal) {
200
294
  if (opts.signal.aborted) {
201
- child.kill("SIGTERM");
295
+ escalateKill();
202
296
  settle({ ok: false, stdout, stderr, aborted: true });
203
297
  return;
204
298
  }
205
299
  abortListener = () => {
206
- child.kill("SIGTERM");
300
+ escalateKill();
207
301
  settle({ ok: false, stdout, stderr, aborted: true });
208
302
  };
209
303
  opts.signal.addEventListener("abort", abortListener, { once: true });
@@ -211,7 +305,7 @@ export function spawnGitAsync(cwd, args, opts) {
211
305
  // Timeout: set timer if effectiveTimeout > 0
212
306
  if (effectiveTimeout > 0) {
213
307
  timer = setTimeout(() => {
214
- child.kill("SIGTERM");
308
+ escalateKill();
215
309
  settle({
216
310
  ok: false,
217
311
  stdout,
@@ -220,8 +314,6 @@ export function spawnGitAsync(cwd, args, opts) {
220
314
  });
221
315
  }, effectiveTimeout);
222
316
  }
223
- child.on("error", () => settle({ ok: false, stdout, stderr }));
224
- child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
225
317
  });
226
318
  }
227
319
  function gitStatusFailText(r) {
@@ -22,6 +22,15 @@ export function buildInventorySectionMarkdown(e) {
22
22
  else if (e.upstreamNote) {
23
23
  lines.push(`upstream: ${e.upstreamNote}`);
24
24
  }
25
+ if (e.compareRefs) {
26
+ const cr = e.compareRefs;
27
+ if (cr.ahead !== undefined && cr.behind !== undefined) {
28
+ lines.push(`${cr.left}...${cr.right}: ahead ${cr.ahead}, behind ${cr.behind}`);
29
+ }
30
+ else if (cr.note) {
31
+ lines.push(`compareRefs ${cr.left}...${cr.right}: ${cr.note}`);
32
+ }
33
+ }
25
34
  const single = lines.length === 1 ? lines[0] : undefined;
26
35
  if (single !== undefined && !single.includes("\n")) {
27
36
  return ["", header, single];
@@ -53,7 +62,48 @@ function buildEntry(params) {
53
62
  out.upstreamNote = params.upstreamNote;
54
63
  return out;
55
64
  }
56
- export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch) {
65
+ /**
66
+ * Ahead = commits reachable from `right` but not `left` (`left..right`).
67
+ * Behind = commits reachable from `left` but not `right` (`right..left`).
68
+ */
69
+ async function fetchCompareAheadBehind(absPath, left, right) {
70
+ const [aheadR, behindR] = await Promise.all([
71
+ spawnGitAsync(absPath, ["rev-list", "--count", `${left}..${right}`]),
72
+ spawnGitAsync(absPath, ["rev-list", "--count", `${right}..${left}`]),
73
+ ]);
74
+ return {
75
+ ahead: aheadR.ok ? aheadR.stdout.trim() : null,
76
+ behind: behindR.ok ? behindR.stdout.trim() : null,
77
+ };
78
+ }
79
+ async function attachCompareRefs(entry, absPath, compareRefs) {
80
+ if (!compareRefs)
81
+ return entry;
82
+ const left = compareRefs.left;
83
+ const right = compareRefs.right;
84
+ const [leftOk, rightOk] = await Promise.all([
85
+ spawnGitAsync(absPath, ["rev-parse", "--verify", left]),
86
+ spawnGitAsync(absPath, ["rev-parse", "--verify", right]),
87
+ ]);
88
+ if (!leftOk.ok || !rightOk.ok) {
89
+ entry.compareRefs = {
90
+ left,
91
+ right,
92
+ note: `(ref unreadable: ${[!leftOk.ok ? left : "", !rightOk.ok ? right : ""].filter(Boolean).join(", ")})`,
93
+ };
94
+ return entry;
95
+ }
96
+ const { ahead, behind } = await fetchCompareAheadBehind(absPath, left, right);
97
+ entry.compareRefs = {
98
+ left,
99
+ right,
100
+ ...(ahead != null ? { ahead } : {}),
101
+ ...(behind != null ? { behind } : {}),
102
+ ...(ahead == null || behind == null ? { note: "(counts unreadable)" } : {}),
103
+ };
104
+ return entry;
105
+ }
106
+ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch, compareRefs) {
57
107
  const [snap, headR] = await Promise.all([
58
108
  gitStatusSnapshotAsync(absPath),
59
109
  spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
@@ -62,11 +112,12 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
62
112
  const headAbbrev = headR.ok ? headR.stdout.trim() : "";
63
113
  const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
64
114
  const base = { label, absPath, branchStatus, detached, headAbbrev };
115
+ let entry;
65
116
  if (fixedRemote !== undefined && fixedBranch !== undefined) {
66
117
  const ref = `${fixedRemote}/${fixedBranch}`;
67
118
  const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
68
119
  if (!verify.ok) {
69
- return buildEntry({
120
+ entry = buildEntry({
70
121
  ...base,
71
122
  upstreamMode: "fixed",
72
123
  upstreamRef: ref,
@@ -75,36 +126,43 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
75
126
  upstreamNote: `(no local ref ${ref} or unreadable)`,
76
127
  });
77
128
  }
78
- const { ahead, behind } = await fetchAheadBehind(absPath, ref);
79
- return buildEntry({
80
- ...base,
81
- upstreamMode: "fixed",
82
- upstreamRef: ref,
83
- ahead,
84
- behind,
85
- upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
86
- });
129
+ else {
130
+ const { ahead, behind } = await fetchAheadBehind(absPath, ref);
131
+ entry = buildEntry({
132
+ ...base,
133
+ upstreamMode: "fixed",
134
+ upstreamRef: ref,
135
+ ahead,
136
+ behind,
137
+ upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
138
+ });
139
+ }
87
140
  }
88
- const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
89
- if (!upVerify.ok) {
90
- return buildEntry({
91
- ...base,
92
- upstreamMode: "auto",
93
- upstreamRef: null,
94
- ahead: null,
95
- behind: null,
96
- upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
97
- });
141
+ else {
142
+ const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
143
+ if (!upVerify.ok) {
144
+ entry = buildEntry({
145
+ ...base,
146
+ upstreamMode: "auto",
147
+ upstreamRef: null,
148
+ ahead: null,
149
+ behind: null,
150
+ upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
151
+ });
152
+ }
153
+ else {
154
+ const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
155
+ const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
156
+ const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
157
+ entry = buildEntry({
158
+ ...base,
159
+ upstreamMode: "auto",
160
+ upstreamRef,
161
+ ahead,
162
+ behind,
163
+ upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
164
+ });
165
+ }
98
166
  }
99
- const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
100
- const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
101
- const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
102
- return buildEntry({
103
- ...base,
104
- upstreamMode: "auto",
105
- upstreamRef,
106
- ahead,
107
- behind,
108
- upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
109
- });
167
+ return attachCompareRefs(entry, absPath, compareRefs);
110
168
  }
@@ -4,7 +4,7 @@ import { gitTopLevel } from "./git.js";
4
4
  import { jsonRespond, spreadDefined } from "./json.js";
5
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
6
6
  import { requireGitAndRoots } from "./roots.js";
7
- import { WorkspacePickSchema } from "./schemas.js";
7
+ import { RootPickSchema } from "./schemas.js";
8
8
  export function registerListPresetsTool(server) {
9
9
  server.addTool({
10
10
  name: "list_presets",
@@ -12,13 +12,7 @@ export function registerListPresetsTool(server) {
12
12
  annotations: {
13
13
  readOnlyHint: true,
14
14
  },
15
- parameters: WorkspacePickSchema.pick({
16
- workspaceRoot: true,
17
- rootIndex: true,
18
- allWorkspaceRoots: true,
19
- absoluteGitRoots: true,
20
- format: true,
21
- }),
15
+ parameters: RootPickSchema,
22
16
  execute: async (args) => {
23
17
  const pre = requireGitAndRoots(server, args, undefined);
24
18
  if (!pre.ok) {