@rethunk/mcp-multi-root-git 2.6.0 → 2.8.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 (38) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +1 -1
  4. package/dist/server/batch-commit-tool.js +7 -6
  5. package/dist/server/error-codes.js +95 -0
  6. package/dist/server/git-blame-tool.js +227 -0
  7. package/dist/server/git-branch-list-tool.js +140 -0
  8. package/dist/server/git-cherry-pick-tool.js +11 -10
  9. package/dist/server/git-diff-summary-tool.js +3 -2
  10. package/dist/server/git-diff-tool.js +56 -12
  11. package/dist/server/git-fetch-tool.js +142 -14
  12. package/dist/server/git-inventory-tool.js +5 -4
  13. package/dist/server/git-log-tool.js +10 -5
  14. package/dist/server/git-merge-tool.js +15 -14
  15. package/dist/server/git-parity-tool.js +3 -2
  16. package/dist/server/git-push-tool.js +9 -5
  17. package/dist/server/git-reflog-tool.js +126 -0
  18. package/dist/server/git-reset-soft-tool.js +4 -3
  19. package/dist/server/git-show-tool.js +83 -23
  20. package/dist/server/git-stash-tool.js +2 -1
  21. package/dist/server/git-tag-tool.js +8 -7
  22. package/dist/server/git-worktree-tool.js +8 -7
  23. package/dist/server/git.js +70 -4
  24. package/dist/server/list-presets-tool.js +2 -1
  25. package/dist/server/presets-resource.js +3 -2
  26. package/dist/server/presets.js +11 -5
  27. package/dist/server/roots.js +11 -10
  28. package/dist/server/tool-parameter-schemas.js +9 -0
  29. package/dist/server/tools.js +6 -0
  30. package/docs/mcp-tools.md +119 -6
  31. package/package.json +2 -2
  32. package/schemas/git_blame.json +52 -0
  33. package/schemas/git_branch_list.json +36 -0
  34. package/schemas/git_diff.json +14 -1
  35. package/schemas/git_reflog.json +44 -0
  36. package/schemas/git_show.json +12 -1
  37. package/schemas/index.json +15 -0
  38. package/tool-parameters.schema.json +152 -2
@@ -0,0 +1,126 @@
1
+ import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
3
+ import { spawnGitAsync } from "./git.js";
4
+ import { 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
+ // Constants
10
+ // ---------------------------------------------------------------------------
11
+ const MAX_ENTRIES_HARD_CAP = 200;
12
+ const DEFAULT_MAX_ENTRIES = 30;
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Run git reflog for a single ref and return structured data.
18
+ * Uses NUL-delimited fields within each line for robust parsing.
19
+ */
20
+ async function runGitReflog(opts) {
21
+ const { top, ref, maxEntries } = opts;
22
+ // %h = short sha, %H = full sha, %gd = selector (HEAD@{0}), %gs = reflog subject
23
+ const reflogArgs = [
24
+ "reflog",
25
+ "show",
26
+ ref,
27
+ "--format=%h%x00%H%x00%gd%x00%gs",
28
+ `-n`,
29
+ String(maxEntries),
30
+ ];
31
+ const r = await spawnGitAsync(top, reflogArgs);
32
+ if (!r.ok) {
33
+ return {
34
+ error: ERROR_CODES.REFLOG_FAILED,
35
+ detail: (r.stderr || r.stdout || "git reflog failed").trim(),
36
+ };
37
+ }
38
+ const entries = [];
39
+ const lines = r.stdout.split("\n");
40
+ for (const line of lines) {
41
+ if (!line.trim())
42
+ continue;
43
+ const parts = line.split("\x00");
44
+ // Expect 4 fields: shaShort, shaFull, selector, message
45
+ if (parts.length < 4)
46
+ continue;
47
+ const [, shaFull, selector, message] = parts;
48
+ if (!shaFull)
49
+ continue;
50
+ entries.push({
51
+ sha: shaFull.trim(),
52
+ selector: (selector ?? "").trim(),
53
+ message: (message ?? "").trim(),
54
+ });
55
+ }
56
+ return { ref, entries };
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Markdown rendering
60
+ // ---------------------------------------------------------------------------
61
+ function renderReflogMarkdown(result) {
62
+ const lines = [];
63
+ lines.push(`## Reflog (${result.ref})`);
64
+ lines.push("");
65
+ if (result.entries.length === 0) {
66
+ lines.push("_(no reflog entries)_");
67
+ }
68
+ else {
69
+ for (const entry of result.entries) {
70
+ lines.push(`${entry.selector} ${entry.sha.slice(0, 7)} ${entry.message}`);
71
+ }
72
+ }
73
+ return lines.join("\n");
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Tool registration
77
+ // ---------------------------------------------------------------------------
78
+ export function registerGitReflogTool(server) {
79
+ server.addTool({
80
+ name: "git_reflog",
81
+ description: "Show the reflog for a ref (default HEAD). Returns a list of recent HEAD movements with selector (e.g. HEAD@{0}), full SHA, and message. Useful for recovering lost commits or inspecting reset/checkout history.",
82
+ annotations: {
83
+ readOnlyHint: true,
84
+ },
85
+ parameters: WorkspacePickSchema.omit({ absoluteGitRoots: true, allWorkspaceRoots: true })
86
+ .pick({
87
+ workspaceRoot: true,
88
+ rootIndex: true,
89
+ format: true,
90
+ })
91
+ .extend({
92
+ ref: z
93
+ .string()
94
+ .optional()
95
+ .default("HEAD")
96
+ .describe("Ref whose reflog to show (branch name, HEAD, etc.). Default: HEAD."),
97
+ maxEntries: z
98
+ .number()
99
+ .int()
100
+ .min(1)
101
+ .max(MAX_ENTRIES_HARD_CAP)
102
+ .optional()
103
+ .default(DEFAULT_MAX_ENTRIES)
104
+ .describe(`Maximum reflog entries to return (hard cap ${MAX_ENTRIES_HARD_CAP}). Default ${DEFAULT_MAX_ENTRIES}.`),
105
+ }),
106
+ execute: async (args) => {
107
+ const pre = requireSingleRepo(server, args);
108
+ if (!pre.ok)
109
+ return jsonRespond(pre.error);
110
+ const top = pre.gitTop;
111
+ const ref = args.ref ?? "HEAD";
112
+ if (!isSafeGitRefToken(ref)) {
113
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
114
+ }
115
+ const maxEntries = Math.min(args.maxEntries ?? DEFAULT_MAX_ENTRIES, MAX_ENTRIES_HARD_CAP);
116
+ const result = await runGitReflog({ top, ref, maxEntries });
117
+ if ("error" in result) {
118
+ return jsonRespond(result);
119
+ }
120
+ if (args.format === "json") {
121
+ return jsonRespond(result);
122
+ }
123
+ return renderReflogMarkdown(result);
124
+ },
125
+ });
126
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { spawnGitAsync } from "./git.js";
3
4
  import { isSafeGitAncestorRef, isWorkingTreeClean } from "./git-refs.js";
4
5
  import { jsonRespond, spreadDefined } from "./json.js";
@@ -30,12 +31,12 @@ export function registerGitResetSoftTool(server) {
30
31
  const { gitTop } = pre;
31
32
  // Validate ref — allow ancestor notation (~N, ^N).
32
33
  if (!isSafeGitAncestorRef(args.ref)) {
33
- return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
34
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
34
35
  }
35
36
  // Refuse when the working tree is dirty (unstaged or untracked changes).
36
37
  if (!(await isWorkingTreeClean(gitTop))) {
37
38
  return jsonRespond({
38
- error: "working_tree_dirty",
39
+ error: ERROR_CODES.WORKING_TREE_DIRTY,
39
40
  detail: "git_reset_soft requires a clean working tree. " +
40
41
  "Commit or stash pending changes first.",
41
42
  });
@@ -47,7 +48,7 @@ export function registerGitResetSoftTool(server) {
47
48
  const r = await spawnGitAsync(gitTop, ["reset", "--soft", args.ref]);
48
49
  if (!r.ok) {
49
50
  return jsonRespond({
50
- error: "reset_failed",
51
+ error: ERROR_CODES.RESET_FAILED,
51
52
  detail: (r.stderr || r.stdout).trim(),
52
53
  });
53
54
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { spawnGitAsync } from "./git.js";
4
5
  import { isSafeGitAncestorRef } from "./git-refs.js";
5
6
  import { jsonRespond } from "./json.js";
@@ -9,22 +10,35 @@ import { WorkspacePickSchema } from "./schemas.js";
9
10
  // Helpers
10
11
  // ---------------------------------------------------------------------------
11
12
  /**
12
- * Run git show for a single ref, optionally limiting to a specific path.
13
- * Returns commit message and diff (or file content if path is specified).
13
+ * Run git show for a single ref, optionally limiting to specific paths and/or
14
+ * showing only the --stat diffstat rather than the full patch.
15
+ * Returns commit message and diff/stat output.
14
16
  */
15
17
  async function runGitShow(opts) {
16
- const { top, ref, path } = opts;
17
- // Build git show args. Shows commit message + full diff (or file content when path given).
18
- const showArgs = ["show", ref];
19
- if (path) {
20
- // When path is specified, show that path at the ref without --no-patch
21
- // to get the full content at that ref
22
- showArgs.push("--", path);
18
+ const { top, ref, path, paths, stat } = opts;
19
+ // Merge single path + paths array into a unified list (deduped, order preserved).
20
+ const effectivePaths = [];
21
+ if (path)
22
+ effectivePaths.push(path);
23
+ if (paths) {
24
+ for (const p of paths) {
25
+ if (!effectivePaths.includes(p))
26
+ effectivePaths.push(p);
27
+ }
28
+ }
29
+ // Build git show args. Shows commit message + full diff (or --stat diffstat).
30
+ const showArgs = ["show"];
31
+ if (stat) {
32
+ showArgs.push("--stat");
33
+ }
34
+ showArgs.push(ref);
35
+ if (effectivePaths.length > 0) {
36
+ showArgs.push("--", ...effectivePaths);
23
37
  }
24
38
  const r = await spawnGitAsync(top, showArgs);
25
39
  if (!r.ok) {
26
40
  return {
27
- error: "git_show_failed",
41
+ error: ERROR_CODES.GIT_SHOW_FAILED,
28
42
  };
29
43
  }
30
44
  // Parse the output. For a commit, git show outputs:
@@ -32,10 +46,9 @@ async function runGitShow(opts) {
32
46
  // - Blank line
33
47
  // - Commit message (may contain multiple lines and blank lines)
34
48
  // - Blank line (separator before diff)
35
- // - Diff (if --no-patch not used) or file content
49
+ // - Diff or --stat diffstat section
36
50
  const output = r.stdout;
37
51
  let message = "";
38
- let diff = "";
39
52
  const lines = output.split("\n");
40
53
  let inHeader = true;
41
54
  let inMessage = false;
@@ -50,9 +63,14 @@ async function runGitShow(opts) {
50
63
  inMessage = true;
51
64
  continue;
52
65
  }
53
- // In message: collect until we see "diff --git" which marks the start of the diff section
54
66
  if (inMessage) {
55
- if (line.startsWith("diff --git")) {
67
+ // In stat mode: content starts at the first line that looks like a stat entry
68
+ // (indented file path) or the summary line "N files changed".
69
+ // In diff mode: content starts at "diff --git".
70
+ const isStatLine = stat &&
71
+ (line.match(/^\s+\S.*\|/) !== null || line.match(/^\s*\d+ files? changed/) !== null);
72
+ const isDiffLine = !stat && line.startsWith("diff --git");
73
+ if (isStatLine || isDiffLine) {
56
74
  inMessage = false;
57
75
  contentLines.push(line);
58
76
  }
@@ -66,16 +84,26 @@ async function runGitShow(opts) {
66
84
  }
67
85
  }
68
86
  message = messageLines.join("\n").trim();
69
- diff = contentLines.join("\n").trim();
87
+ const contentStr = contentLines.join("\n").trim();
70
88
  const result = {
71
89
  ref,
72
90
  message,
73
91
  };
74
- if (path) {
92
+ // Reflect single legacy path (for backward-compat) and new paths[] in result.
93
+ if (path && !paths) {
75
94
  result.path = path;
76
95
  }
77
- if (diff) {
78
- result.diff = diff;
96
+ else if (effectivePaths.length > 0) {
97
+ result.paths = effectivePaths;
98
+ }
99
+ if (stat) {
100
+ result.stat = true;
101
+ if (contentStr) {
102
+ result.statOutput = contentStr;
103
+ }
104
+ }
105
+ else if (contentStr) {
106
+ result.diff = contentStr;
79
107
  }
80
108
  return result;
81
109
  }
@@ -88,13 +116,27 @@ function renderShowMarkdown(result) {
88
116
  if (result.path) {
89
117
  lines.push(`_path: ${result.path}_`);
90
118
  }
119
+ else if (result.paths && result.paths.length > 0) {
120
+ lines.push(`_paths: ${result.paths.join(", ")}_`);
121
+ }
122
+ if (result.stat) {
123
+ lines.push("_mode: stat_");
124
+ }
91
125
  lines.push("");
92
126
  lines.push("## Commit message");
93
127
  lines.push("");
94
128
  lines.push("```");
95
129
  lines.push(result.message);
96
130
  lines.push("```");
97
- if (result.diff) {
131
+ if (result.stat && result.statOutput) {
132
+ lines.push("");
133
+ lines.push("## Stat");
134
+ lines.push("");
135
+ lines.push("```");
136
+ lines.push(result.statOutput);
137
+ lines.push("```");
138
+ }
139
+ else if (result.diff) {
98
140
  lines.push("");
99
141
  lines.push("## Diff");
100
142
  lines.push("");
@@ -110,7 +152,7 @@ function renderShowMarkdown(result) {
110
152
  export function registerGitShowTool(server) {
111
153
  server.addTool({
112
154
  name: "git_show",
113
- description: "Inspect commit content by ref/SHA. Returns commit message and diff (or file content at a specific path).",
155
+ description: "Inspect commit content by ref/SHA. Returns commit message and diff (or --stat diffstat when stat:true). Optionally filter to specific paths via path or paths[].",
114
156
  annotations: {
115
157
  readOnlyHint: true,
116
158
  },
@@ -128,7 +170,15 @@ export function registerGitShowTool(server) {
128
170
  path: z
129
171
  .string()
130
172
  .optional()
131
- .describe("Optional file path to inspect at the ref. If provided, shows that path's content at the ref instead of the diff."),
173
+ .describe("Optional single file path to inspect at the ref. Merged with `paths` when both are provided."),
174
+ paths: z
175
+ .array(z.string())
176
+ .optional()
177
+ .describe("Optional list of file paths to filter the shown diff/stat. Merged with `path` when both are provided."),
178
+ stat: z
179
+ .boolean()
180
+ .optional()
181
+ .describe("When true, show --stat diffstat (files changed summary) instead of the full patch."),
132
182
  }),
133
183
  execute: async (args) => {
134
184
  const pre = requireSingleRepo(server, args);
@@ -136,18 +186,28 @@ export function registerGitShowTool(server) {
136
186
  return jsonRespond(pre.error);
137
187
  const top = pre.gitTop;
138
188
  if (!isSafeGitAncestorRef(args.ref)) {
139
- return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
189
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.ref });
140
190
  }
141
191
  if (args.path !== undefined) {
142
192
  const resolved = resolvePathForRepo(args.path, top);
143
193
  if (!assertRelativePathUnderTop(args.path, resolved, top)) {
144
- return jsonRespond({ error: "path_escapes_repo", path: args.path });
194
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: args.path });
195
+ }
196
+ }
197
+ if (Array.isArray(args.paths)) {
198
+ for (const p of args.paths) {
199
+ const resolved = resolvePathForRepo(p, top);
200
+ if (!assertRelativePathUnderTop(p, resolved, top)) {
201
+ return jsonRespond({ error: ERROR_CODES.PATH_ESCAPES_REPO, path: p });
202
+ }
145
203
  }
146
204
  }
147
205
  const result = await runGitShow({
148
206
  top,
149
207
  ref: args.ref,
150
208
  path: args.path,
209
+ paths: args.paths,
210
+ stat: args.stat,
151
211
  });
152
212
  if ("error" in result) {
153
213
  return jsonRespond(result);
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { spawnGitAsync } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { requireSingleRepo } from "./roots.js";
@@ -30,7 +31,7 @@ export function registerGitStashListTool(server) {
30
31
  // If there are no stashes, git still returns ok=true with empty output
31
32
  // Only treat as error if git itself failed
32
33
  return jsonRespond({
33
- error: "stash_list_failed",
34
+ error: ERROR_CODES.STASH_LIST_FAILED,
34
35
  detail: (r.stderr || r.stdout).trim(),
35
36
  });
36
37
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
3
4
  import { jsonRespond } from "./json.js";
4
5
  import { requireSingleRepo } from "./roots.js";
@@ -72,18 +73,18 @@ export function registerGitTagTool(server) {
72
73
  const gitTop = pre.gitTop;
73
74
  const tag = args.tag.trim();
74
75
  if (!tag) {
75
- return jsonRespond({ error: "empty_tag_name" });
76
+ return jsonRespond({ error: ERROR_CODES.EMPTY_TAG_NAME });
76
77
  }
77
78
  // Validate tag name: no shell metacharacters
78
79
  if (!isSafeGitUpstreamToken(tag)) {
79
- return jsonRespond({ error: "unsafe_tag_token", tag });
80
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_TAG_TOKEN, tag });
80
81
  }
81
82
  // Handle deletion
82
83
  if (args.delete === true) {
83
84
  const delResult = await spawnGitAsync(gitTop, ["tag", "-d", tag]);
84
85
  if (!delResult.ok) {
85
86
  return jsonRespond({
86
- error: "tag_delete_failed",
87
+ error: ERROR_CODES.TAG_DELETE_FAILED,
87
88
  detail: (delResult.stderr || delResult.stdout).trim(),
88
89
  });
89
90
  }
@@ -99,13 +100,13 @@ export function registerGitTagTool(server) {
99
100
  // Determine the ref to tag (default HEAD)
100
101
  const ref = (args.ref ?? "HEAD").trim();
101
102
  if (!isSafeGitUpstreamToken(ref)) {
102
- return jsonRespond({ error: "unsafe_ref_token", ref });
103
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref });
103
104
  }
104
105
  // Get the SHA of the ref to tag
105
106
  const sha = await getRefSha(gitTop, ref);
106
107
  if (!sha) {
107
108
  return jsonRespond({
108
- error: "ref_not_found",
109
+ error: ERROR_CODES.REF_NOT_FOUND,
109
110
  ref,
110
111
  });
111
112
  }
@@ -122,7 +123,7 @@ export function registerGitTagTool(server) {
122
123
  const createResult = await spawnGitAsync(gitTop, tagArgs);
123
124
  if (!createResult.ok) {
124
125
  return jsonRespond({
125
- error: "tag_create_failed",
126
+ error: ERROR_CODES.TAG_CREATE_FAILED,
126
127
  detail: (createResult.stderr || createResult.stdout).trim(),
127
128
  });
128
129
  }
@@ -130,7 +131,7 @@ export function registerGitTagTool(server) {
130
131
  const tagType = await getTagType(gitTop, tag);
131
132
  if (!tagType) {
132
133
  return jsonRespond({
133
- error: "tag_verification_failed",
134
+ error: ERROR_CODES.TAG_VERIFICATION_FAILED,
134
135
  tag,
135
136
  });
136
137
  }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { z } from "zod";
3
+ import { ERROR_CODES } from "./error-codes.js";
3
4
  import { spawnGitAsync } from "./git.js";
4
5
  import { isProtectedBranch, isSafeGitRefToken, listWorktrees, } from "./git-refs.js";
5
6
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
@@ -76,13 +77,13 @@ export function registerGitWorktreeAddTool(server) {
76
77
  const { gitTop } = pre;
77
78
  // Validate branch
78
79
  if (!isSafeGitRefToken(args.branch)) {
79
- return jsonRespond({ error: "unsafe_ref_token", ref: args.branch });
80
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.branch });
80
81
  }
81
82
  if (isProtectedBranch(args.branch)) {
82
- return jsonRespond({ error: "protected_branch", branch: args.branch });
83
+ return jsonRespond({ error: ERROR_CODES.PROTECTED_BRANCH, branch: args.branch });
83
84
  }
84
85
  if (args.baseRef !== undefined && !isSafeGitRefToken(args.baseRef)) {
85
- return jsonRespond({ error: "unsafe_ref_token", ref: args.baseRef });
86
+ return jsonRespond({ error: ERROR_CODES.UNSAFE_REF_TOKEN, ref: args.baseRef });
86
87
  }
87
88
  // Resolve path
88
89
  const wtPath = resolve(gitTop, args.path);
@@ -107,7 +108,7 @@ export function registerGitWorktreeAddTool(server) {
107
108
  if (!r.ok) {
108
109
  return jsonRespond({
109
110
  ok: false,
110
- error: "worktree_add_failed",
111
+ error: ERROR_CODES.WORKTREE_ADD_FAILED,
111
112
  detail: (r.stderr || r.stdout).trim(),
112
113
  });
113
114
  }
@@ -158,13 +159,13 @@ export function registerGitWorktreeRemoveTool(server) {
158
159
  // Refuse to remove the main worktree
159
160
  const wtPath = resolve(gitTop, args.path);
160
161
  if (wtPath === gitTop) {
161
- return jsonRespond({ error: "cannot_remove_main_worktree", path: wtPath });
162
+ return jsonRespond({ error: ERROR_CODES.CANNOT_REMOVE_MAIN_WORKTREE, path: wtPath });
162
163
  }
163
164
  // Verify it exists in the worktree list
164
165
  const trees = await listWorktrees(gitTop);
165
166
  const isRegistered = trees.some((t) => t.path === wtPath || t.path === args.path);
166
167
  if (!isRegistered) {
167
- return jsonRespond({ error: "worktree_not_found", path: args.path });
168
+ return jsonRespond({ error: ERROR_CODES.WORKTREE_NOT_FOUND, path: args.path });
168
169
  }
169
170
  const removeArgs = ["worktree", "remove", wtPath];
170
171
  if (args.force)
@@ -173,7 +174,7 @@ export function registerGitWorktreeRemoveTool(server) {
173
174
  if (!r.ok) {
174
175
  return jsonRespond({
175
176
  ok: false,
176
- error: "worktree_remove_failed",
177
+ error: ERROR_CODES.WORKTREE_REMOVE_FAILED,
177
178
  detail: (r.stderr || r.stdout).trim(),
178
179
  ...spreadWhen((r.stderr || r.stdout).includes("contains modified") ||
179
180
  (r.stderr || r.stdout).includes("is not empty"), { hint: "Pass force: true to remove a worktree with uncommitted changes." }),
@@ -2,6 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { cpus } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { ERROR_CODES } from "./error-codes.js";
5
6
  /**
6
7
  * Parallel git subprocesses for inventory rows and git_status submodule rows.
7
8
  * Reads from GIT_SUBPROCESS_PARALLELISM env var (default 4), clamped to [1, 2×CPU_COUNT].
@@ -19,9 +20,28 @@ function resolveGitSubprocessParallelism() {
19
20
  return 4;
20
21
  }
21
22
  export const GIT_SUBPROCESS_PARALLELISM = resolveGitSubprocessParallelism();
23
+ /**
24
+ * Default timeout for git subprocesses spawned by spawnGitAsync.
25
+ * Reads from GIT_SUBPROCESS_TIMEOUT_MS env var (default 120000 ms = 2 min).
26
+ * A value of 0 (or negative/NaN) disables the timeout — use for operations
27
+ * like large clones where unbounded wait is intentional.
28
+ */
29
+ function resolveGitSubprocessTimeoutMs() {
30
+ const env = process.env.GIT_SUBPROCESS_TIMEOUT_MS;
31
+ if (env) {
32
+ const n = Number.parseInt(env, 10);
33
+ if (!Number.isNaN(n) && n > 0)
34
+ return n;
35
+ // 0 or negative → disabled
36
+ if (!Number.isNaN(n))
37
+ return 0;
38
+ }
39
+ return 120_000;
40
+ }
41
+ export const GIT_SUBPROCESS_TIMEOUT_MS = resolveGitSubprocessTimeoutMs();
22
42
  let gitPathState = "unknown";
23
43
  const GIT_NOT_FOUND_BODY = {
24
- error: "git_not_found",
44
+ error: ERROR_CODES.GIT_NOT_FOUND,
25
45
  };
26
46
  export function gateGit() {
27
47
  if (gitPathState === "ok") {
@@ -147,11 +167,12 @@ export async function asyncPool(items, concurrency, fn) {
147
167
  await Promise.all(Array.from({ length: n }, () => worker()));
148
168
  return results;
149
169
  }
150
- export function spawnGitAsync(cwd, args) {
170
+ export function spawnGitAsync(cwd, args, opts) {
151
171
  return new Promise((resolveP) => {
152
172
  const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
153
173
  let stdout = "";
154
174
  let stderr = "";
175
+ let settled = false;
155
176
  child.stdout?.setEncoding("utf8");
156
177
  child.stderr?.setEncoding("utf8");
157
178
  child.stdout?.on("data", (c) => {
@@ -160,8 +181,53 @@ export function spawnGitAsync(cwd, args) {
160
181
  child.stderr?.on("data", (c) => {
161
182
  stderr += c;
162
183
  });
163
- child.on("error", () => resolveP({ ok: false, stdout, stderr }));
164
- child.on("close", (code) => resolveP({ ok: code === 0, stdout, stderr }));
184
+ const effectiveTimeout = opts?.timeoutMs ?? GIT_SUBPROCESS_TIMEOUT_MS;
185
+ let timer;
186
+ let abortListener;
187
+ function cleanup() {
188
+ if (timer !== undefined) {
189
+ clearTimeout(timer);
190
+ timer = undefined;
191
+ }
192
+ if (abortListener !== undefined && opts?.signal) {
193
+ opts.signal.removeEventListener("abort", abortListener);
194
+ abortListener = undefined;
195
+ }
196
+ }
197
+ function settle(result) {
198
+ if (settled)
199
+ return;
200
+ settled = true;
201
+ cleanup();
202
+ resolveP(result);
203
+ }
204
+ // AbortSignal: kill immediately if already aborted, else listen
205
+ if (opts?.signal) {
206
+ if (opts.signal.aborted) {
207
+ child.kill("SIGTERM");
208
+ settle({ ok: false, stdout, stderr, aborted: true });
209
+ return;
210
+ }
211
+ abortListener = () => {
212
+ child.kill("SIGTERM");
213
+ settle({ ok: false, stdout, stderr, aborted: true });
214
+ };
215
+ opts.signal.addEventListener("abort", abortListener, { once: true });
216
+ }
217
+ // Timeout: set timer if effectiveTimeout > 0
218
+ if (effectiveTimeout > 0) {
219
+ timer = setTimeout(() => {
220
+ child.kill("SIGTERM");
221
+ settle({
222
+ ok: false,
223
+ stdout,
224
+ stderr: `${stderr}\n<git timed out after ${effectiveTimeout}ms>`,
225
+ timedOut: true,
226
+ });
227
+ }, effectiveTimeout);
228
+ }
229
+ child.on("error", () => settle({ ok: false, stdout, stderr }));
230
+ child.on("close", (code) => settle({ ok: code === 0, stdout, stderr }));
165
231
  });
166
232
  }
167
233
  function gitStatusFailText(r) {
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { gitTopLevel } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
@@ -34,7 +35,7 @@ export function registerListPresetsTool(server) {
34
35
  presetFile,
35
36
  fileExists: false,
36
37
  presets: [],
37
- error: { error: "not_a_git_repository", path: ws },
38
+ error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
38
39
  });
39
40
  continue;
40
41
  }
@@ -1,4 +1,5 @@
1
1
  import { join } from "node:path";
2
+ import { ERROR_CODES } from "./error-codes.js";
2
3
  import { gitTopLevel } from "./git.js";
3
4
  import { jsonRespond, spreadDefined } from "./json.js";
4
5
  import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
@@ -15,11 +16,11 @@ export function registerPresetsResource(server) {
15
16
  }
16
17
  const ws = pre.roots[0];
17
18
  if (!ws) {
18
- return { text: jsonRespond({ error: "no_workspace_root" }) };
19
+ return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
19
20
  }
20
21
  const top = gitTopLevel(ws);
21
22
  if (!top) {
22
- return { text: jsonRespond({ error: "not_a_git_repository", path: ws }) };
23
+ return { text: jsonRespond({ error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws }) };
23
24
  }
24
25
  const loaded = loadPresetsFromGitTop(top);
25
26
  if (!loaded.ok) {