@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
@@ -10,39 +10,56 @@ export function registerPresetsResource(server) {
10
10
  name: "git-mcp-presets",
11
11
  mimeType: "application/json",
12
12
  async load() {
13
- const pre = requireGitAndRoots(server, {}, undefined);
13
+ const pre = requireGitAndRoots(server, { root: "*" }, undefined);
14
14
  if (!pre.ok) {
15
15
  return { text: jsonRespond(pre.error) };
16
16
  }
17
- const ws = pre.roots[0];
18
- if (!ws) {
17
+ if (pre.roots.length === 0) {
19
18
  return { text: jsonRespond({ error: ERROR_CODES.NO_WORKSPACE_ROOT }) };
20
19
  }
21
- const top = gitTopLevel(ws);
22
- if (!top) {
23
- return { text: jsonRespond({ error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws }) };
24
- }
25
- const loaded = loadPresetsFromGitTop(top);
26
- if (!loaded.ok) {
27
- if (loaded.reason === "missing") {
20
+ const roots = pre.roots.map((ws) => {
21
+ const top = gitTopLevel(ws);
22
+ const presetFile = top ? join(top, PRESET_FILE_PATH) : join(ws, PRESET_FILE_PATH);
23
+ if (!top) {
28
24
  return {
29
- text: jsonRespond({
30
- presetFile: join(top, PRESET_FILE_PATH),
25
+ workspaceRoot: ws,
26
+ gitTop: null,
27
+ presetFile,
28
+ fileExists: false,
29
+ presets: {},
30
+ error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: ws },
31
+ };
32
+ }
33
+ const loaded = loadPresetsFromGitTop(top);
34
+ if (!loaded.ok) {
35
+ if (loaded.reason === "missing") {
36
+ return {
37
+ workspaceRoot: ws,
38
+ gitTop: top,
39
+ presetFile,
31
40
  fileExists: false,
32
41
  presets: {},
33
- }),
42
+ };
43
+ }
44
+ return {
45
+ workspaceRoot: ws,
46
+ gitTop: top,
47
+ presetFile,
48
+ fileExists: true,
49
+ presets: {},
50
+ error: presetLoadErrorPayload(top, loaded),
34
51
  };
35
52
  }
36
- return { text: jsonRespond(presetLoadErrorPayload(top, loaded)) };
37
- }
38
- return {
39
- text: jsonRespond({
40
- presetFile: join(top, PRESET_FILE_PATH),
53
+ return {
54
+ workspaceRoot: ws,
55
+ gitTop: top,
56
+ presetFile,
41
57
  fileExists: true,
42
58
  ...spreadDefined("presetSchemaVersion", loaded.schemaVersion),
43
59
  presets: loaded.data,
44
- }),
45
- };
60
+ };
61
+ });
62
+ return { text: jsonRespond({ roots }) };
46
63
  },
47
64
  });
48
65
  }
@@ -5,21 +5,56 @@ import { ERROR_CODES } from "./error-codes.js";
5
5
  /**
6
6
  * Schema for `.rethunk/git-mcp-presets.json` at the workspace root.
7
7
  * Each named entry defines roots for `git_inventory` and/or pairs for `git_parity`.
8
+ * Must stay aligned with `git-mcp-presets.schema.json`.
8
9
  */
9
- const PresetEntrySchema = z.object({
10
+ const ParityPairSchema = z
11
+ .object({
12
+ left: z.string(),
13
+ right: z.string(),
14
+ label: z.string().optional(),
15
+ })
16
+ .strict();
17
+ const PresetEntrySchema = z
18
+ .object({
10
19
  nestedRoots: z.array(z.string()).optional(),
11
- parityPairs: z
12
- .array(z.object({
13
- left: z.string(),
14
- right: z.string(),
15
- label: z.string().optional(),
16
- }))
17
- .optional(),
20
+ parityPairs: z.array(ParityPairSchema).optional(),
18
21
  /** When multiple MCP file roots exist, prefer one whose path basename or suffix matches this hint. */
19
22
  workspaceRootHint: z.string().optional(),
20
- });
23
+ })
24
+ .strict();
21
25
  const PresetFileSchema = z.record(z.string(), PresetEntrySchema);
22
26
  export const PRESET_FILE_PATH = ".rethunk/git-mcp-presets.json";
27
+ const PRESET_ENTRY_FIELD_NAMES = new Set(["nestedRoots", "parityPairs", "workspaceRootHint"]);
28
+ const WRAPPED_META_KEYS = new Set(["$schema", "schemaVersion", "presets"]);
29
+ const PRESET_SCHEMA_VERSION = "1";
30
+ function looksLikePresetEntryObject(obj) {
31
+ const keys = Object.keys(obj);
32
+ if (keys.length === 0)
33
+ return true;
34
+ return keys.every((k) => PRESET_ENTRY_FIELD_NAMES.has(k));
35
+ }
36
+ function isWrappedLayout(o) {
37
+ if (!("presets" in o))
38
+ return false;
39
+ const inner = o.presets;
40
+ if (inner === null || typeof inner !== "object" || Array.isArray(inner)) {
41
+ return false;
42
+ }
43
+ const innerObj = inner;
44
+ // Non-empty inner with only PresetEntry field names → legacy preset named "presets".
45
+ if (looksLikePresetEntryObject(innerObj) && Object.keys(innerObj).length > 0) {
46
+ return false;
47
+ }
48
+ // Empty inner: wrapped when top level has only metadata + presets.
49
+ if (Object.keys(innerObj).length === 0) {
50
+ const topKeys = Object.keys(o);
51
+ return topKeys.every((k) => WRAPPED_META_KEYS.has(k));
52
+ }
53
+ return true;
54
+ }
55
+ function schemaIssue(message, path = []) {
56
+ return { code: "custom", path, message };
57
+ }
23
58
  /**
24
59
  * Supports:
25
60
  * - Wrapped: `{ "schemaVersion": "1", "presets": { "name": { ... } } }`
@@ -30,10 +65,12 @@ function splitPresetFileRaw(raw) {
30
65
  throw new Error("invalid_root");
31
66
  }
32
67
  const o = raw;
33
- if ("presets" in o &&
34
- o.presets !== null &&
35
- typeof o.presets === "object" &&
36
- !Array.isArray(o.presets)) {
68
+ if (isWrappedLayout(o)) {
69
+ for (const key of Object.keys(o)) {
70
+ if (!WRAPPED_META_KEYS.has(key)) {
71
+ throw new Error("wrapped_extra_keys");
72
+ }
73
+ }
37
74
  const sv = o.schemaVersion;
38
75
  return {
39
76
  mapRaw: o.presets,
@@ -69,13 +106,39 @@ export function loadPresetsFromGitTop(gitTop) {
69
106
  mapRaw = s.mapRaw;
70
107
  schemaVersion = s.schemaVersion;
71
108
  }
72
- catch {
109
+ catch (e) {
110
+ if (e instanceof Error && e.message === "wrapped_extra_keys") {
111
+ return {
112
+ ok: false,
113
+ reason: "schema",
114
+ issues: [
115
+ schemaIssue("Wrapped preset files allow only $schema, schemaVersion, and presets"),
116
+ ],
117
+ };
118
+ }
73
119
  return {
74
120
  ok: false,
75
121
  reason: "invalid_json",
76
122
  message: "Preset file root must be a JSON object",
77
123
  };
78
124
  }
125
+ if (schemaVersion !== undefined && schemaVersion !== PRESET_SCHEMA_VERSION) {
126
+ return {
127
+ ok: false,
128
+ reason: "schema",
129
+ issues: [schemaIssue(`schemaVersion must be "${PRESET_SCHEMA_VERSION}"`, ["schemaVersion"])],
130
+ };
131
+ }
132
+ if (mapRaw === null ||
133
+ typeof mapRaw !== "object" ||
134
+ Array.isArray(mapRaw) ||
135
+ Object.keys(mapRaw).length === 0) {
136
+ return {
137
+ ok: false,
138
+ reason: "schema",
139
+ issues: [schemaIssue("Preset file must contain at least one preset")],
140
+ };
141
+ }
79
142
  const parsed = PresetFileSchema.safeParse(mapRaw);
80
143
  if (!parsed.success) {
81
144
  return { ok: false, reason: "schema", issues: parsed.error.issues };
@@ -150,7 +213,16 @@ function mergePairs(preset, inline) {
150
213
  const b = inline ?? [];
151
214
  if (a.length === 0 && b.length === 0)
152
215
  return undefined;
153
- return [...a, ...b];
216
+ const seen = new Set();
217
+ const out = [];
218
+ for (const pair of [...a, ...b]) {
219
+ const key = `${pair.left}\0${pair.right}`;
220
+ if (!seen.has(key)) {
221
+ seen.add(key);
222
+ out.push(pair);
223
+ }
224
+ }
225
+ return out;
154
226
  }
155
227
  export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNestedRoots) {
156
228
  const got = getPresetEntry(gitTop, presetName);
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { ERROR_CODES } from "./error-codes.js";
4
4
  import { gateGit, gitTopLevel } from "./git.js";
5
5
  import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
6
- import { MAX_ABSOLUTE_GIT_ROOTS } from "./schemas.js";
6
+ import { MAX_ROOT_PATHS } from "./schemas.js";
7
7
  function uriToPath(uri) {
8
8
  if (!uri.startsWith("file://"))
9
9
  return null;
@@ -45,25 +45,17 @@ function pathMatchesWorkspaceRootHint(rootPath, hint) {
45
45
  return true;
46
46
  return basename(rootPath) === h;
47
47
  }
48
- function hasExclusiveWorkspacePick(args) {
49
- if (args.workspaceRoot?.trim())
50
- return true;
51
- if (args.rootIndex != null)
52
- return true;
53
- if (args.allWorkspaceRoots === true)
54
- return true;
55
- return false;
56
- }
57
48
  /**
58
- * Resolve `absoluteGitRoots` to unique git toplevels (stable order, first occurrence wins).
49
+ * Resolve an explicit `root` path array to unique git toplevels
50
+ * (stable order, first occurrence wins).
59
51
  */
60
- export function resolveAbsoluteGitRootsList(raw) {
61
- if (raw.length > MAX_ABSOLUTE_GIT_ROOTS) {
52
+ export function resolveRootPathList(raw) {
53
+ if (raw.length > MAX_ROOT_PATHS) {
62
54
  return {
63
55
  ok: false,
64
56
  error: {
65
- error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_TOO_MANY,
66
- max: MAX_ABSOLUTE_GIT_ROOTS,
57
+ error: ERROR_CODES.ROOT_LIST_TOO_MANY,
58
+ max: MAX_ROOT_PATHS,
67
59
  count: raw.length,
68
60
  },
69
61
  };
@@ -73,12 +65,12 @@ export function resolveAbsoluteGitRootsList(raw) {
73
65
  for (const item of raw) {
74
66
  const trimmed = item.trim();
75
67
  if (trimmed.length === 0) {
76
- return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: item } };
68
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ROOT_PATH, path: item } };
77
69
  }
78
70
  const abs = resolve(trimmed);
79
71
  const top = gitTopLevel(abs);
80
72
  if (!top) {
81
- return { ok: false, error: { error: ERROR_CODES.INVALID_ABSOLUTE_GIT_ROOT, path: abs } };
73
+ return { ok: false, error: { error: ERROR_CODES.INVALID_ROOT_PATH, path: abs } };
82
74
  }
83
75
  if (seen.has(top))
84
76
  continue;
@@ -86,47 +78,23 @@ export function resolveAbsoluteGitRootsList(raw) {
86
78
  tops.push(top);
87
79
  }
88
80
  if (tops.length === 0) {
89
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EMPTY } };
81
+ return { ok: false, error: { error: ERROR_CODES.ROOT_LIST_EMPTY } };
90
82
  }
91
83
  return { ok: true, roots: tops };
92
84
  }
93
- function resolveWorkspaceRoots(server, args) {
94
- if (args.workspaceRoot?.trim()) {
95
- return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
96
- }
97
- const fileRoots = listFileRoots(server);
98
- const fallback = { ok: true, roots: [process.cwd()] };
99
- if (args.allWorkspaceRoots) {
100
- return fileRoots.length === 0 ? fallback : { ok: true, roots: fileRoots };
101
- }
102
- if (args.rootIndex != null) {
103
- const r = fileRoots[args.rootIndex];
104
- if (!r) {
105
- return {
106
- ok: false,
107
- error: {
108
- error: ERROR_CODES.ROOT_INDEX_OUT_OF_RANGE,
109
- rootIndex: args.rootIndex,
110
- rootCount: fileRoots.length,
111
- },
112
- };
113
- }
114
- return { ok: true, roots: [r] };
115
- }
85
+ /** Default when `root` is omitted: first MCP file root, else cwd. */
86
+ function defaultRoots(fileRoots) {
116
87
  const primary = fileRoots[0];
117
- return primary !== undefined ? { ok: true, roots: [primary] } : fallback;
88
+ return { ok: true, roots: [primary ?? process.cwd()] };
118
89
  }
119
90
  /**
120
91
  * When a preset name is requested and multiple MCP roots exist, pick the first root
121
92
  * whose git toplevel loads a preset file containing that name.
122
93
  */
123
- function resolveRootsForPreset(server, args, presetName) {
124
- if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
125
- return resolveWorkspaceRoots(server, args);
126
- }
94
+ function resolveRootsForPreset(server, presetName) {
127
95
  const fileRoots = listFileRoots(server);
128
96
  if (fileRoots.length <= 1) {
129
- return resolveWorkspaceRoots(server, args);
97
+ return defaultRoots(fileRoots);
130
98
  }
131
99
  const matches = [];
132
100
  for (const r of fileRoots) {
@@ -153,53 +121,58 @@ function resolveRootsForPreset(server, args, presetName) {
153
121
  if (pick !== undefined) {
154
122
  return { ok: true, roots: [pick] };
155
123
  }
156
- return resolveWorkspaceRoots(server, args);
124
+ return defaultRoots(fileRoots);
157
125
  }
158
- /** `gateGit` plus workspace / preset root resolution; shared tool and resource prelude. */
126
+ /** `gateGit` plus `root` resolution; shared fan-out tool and resource prelude. */
159
127
  export function requireGitAndRoots(server, args, presetName) {
160
128
  const gg = gateGit();
161
129
  if (!gg.ok) {
162
130
  return { ok: false, error: gg.body };
163
131
  }
164
- const abs = args.absoluteGitRoots;
165
- if (abs != null && abs.length > 0) {
132
+ const root = args.root;
133
+ if (Array.isArray(root)) {
166
134
  if (presetName) {
167
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_PRESET_CONFLICT } };
135
+ return { ok: false, error: { error: ERROR_CODES.ROOT_LIST_PRESET_CONFLICT } };
168
136
  }
169
- if (hasExclusiveWorkspacePick(args)) {
170
- return { ok: false, error: { error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_EXCLUSIVE } };
137
+ return resolveRootPathList(root);
138
+ }
139
+ const trimmed = root?.trim();
140
+ if (trimmed === "*") {
141
+ const fileRoots = listFileRoots(server);
142
+ if (fileRoots.length === 0)
143
+ return defaultRoots(fileRoots);
144
+ if (fileRoots.length > MAX_ROOT_PATHS) {
145
+ return {
146
+ ok: false,
147
+ error: {
148
+ error: ERROR_CODES.ROOT_LIST_TOO_MANY,
149
+ max: MAX_ROOT_PATHS,
150
+ count: fileRoots.length,
151
+ },
152
+ };
171
153
  }
172
- return resolveAbsoluteGitRootsList(abs);
154
+ return { ok: true, roots: fileRoots };
155
+ }
156
+ if (trimmed) {
157
+ return { ok: true, roots: [resolve(trimmed)] };
173
158
  }
174
- const rootsRes = presetName
175
- ? resolveRootsForPreset(server, args, presetName)
176
- : resolveWorkspaceRoots(server, args);
177
- if (!rootsRes.ok) {
178
- return { ok: false, error: rootsRes.error };
159
+ if (presetName) {
160
+ return resolveRootsForPreset(server, presetName);
179
161
  }
180
- return { ok: true, roots: rootsRes.roots };
162
+ return defaultRoots(listFileRoots(server));
181
163
  }
182
164
  /**
183
- * Convenience wrapper for single-repo tools: gate git, resolve roots, pick the first
184
- * root, and resolve its git toplevel. Returns `{ ok: true, gitTop }` or a structured
185
- * error payload ready for `jsonRespond`.
165
+ * Prelude for single-repo tools: gate git, resolve `workspaceRoot` (or the first
166
+ * MCP root / cwd), and resolve its git toplevel. Returns `{ ok: true, gitTop }`
167
+ * or a structured error payload ready for `jsonRespond`.
186
168
  */
187
- export function requireSingleRepo(server, args, presetName = undefined) {
188
- const pre = requireGitAndRoots(server, args, presetName);
189
- if (!pre.ok)
190
- return pre;
191
- if (args.absoluteGitRoots != null && args.absoluteGitRoots.length > 0 && pre.roots.length !== 1) {
192
- return {
193
- ok: false,
194
- error: {
195
- error: ERROR_CODES.ABSOLUTE_GIT_ROOTS_SINGLE_REPO_ONLY,
196
- rootCount: pre.roots.length,
197
- },
198
- };
169
+ export function requireSingleRepo(server, args) {
170
+ const gg = gateGit();
171
+ if (!gg.ok) {
172
+ return { ok: false, error: gg.body };
199
173
  }
200
- const rootInput = pre.roots[0];
201
- if (!rootInput)
202
- return { ok: false, error: { error: ERROR_CODES.NO_WORKSPACE_ROOT } };
174
+ const ws = args.workspaceRoot?.trim();
175
+ const rootInput = ws ? resolve(ws) : (listFileRoots(server)[0] ?? process.cwd());
203
176
  const top = gitTopLevel(rootInput);
204
177
  if (!top)
205
178
  return { ok: false, error: { error: ERROR_CODES.NOT_A_GIT_REPOSITORY, path: rootInput } };
@@ -1,27 +1,26 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_INVENTORY_ROOTS_DEFAULT } from "./inventory.js";
3
3
  const FormatSchema = z.enum(["markdown", "json"]).optional().default("markdown");
4
- /** Max paths in `absoluteGitRoots` (matches `git_inventory` `maxRoots` hard cap). */
5
- export const MAX_ABSOLUTE_GIT_ROOTS = 256;
4
+ /** Max entries when `root` is an array (matches `git_inventory` `maxRoots` hard cap). */
5
+ export const MAX_ROOT_PATHS = 256;
6
+ /** Single-repo tools: one optional repo-path override plus output format. */
6
7
  export const WorkspacePickSchema = z.object({
7
- workspaceRoot: z.string().optional().describe("Highest-priority root override."),
8
- rootIndex: z
9
- .number()
10
- .int()
11
- .min(0)
12
- .optional()
13
- .describe("0-based index into MCP roots; ignored if workspaceRoot set."),
14
- allWorkspaceRoots: z
15
- .boolean()
16
- .optional()
17
- .default(false)
18
- .describe("Fan out across all MCP roots."),
19
- /** Independent git worktrees (sibling clones). Mutually exclusive with workspaceRoot, rootIndex, allWorkspaceRoots, and (git_inventory) preset/nestedRoots. */
20
- absoluteGitRoots: z
21
- .array(z.string())
22
- .max(MAX_ABSOLUTE_GIT_ROOTS)
8
+ workspaceRoot: z.string().optional().describe("Repo path. Default: first MCP root / cwd."),
9
+ format: FormatSchema,
10
+ });
11
+ /**
12
+ * Fan-out tools: one polymorphic routing param plus output format.
13
+ *
14
+ * Array length is intentionally uncapped here so `resolveRootPathList` can
15
+ * return the structured `{ error: root_list_too_many, max, count }` JSON
16
+ * payload. Zod `.max(MAX_ROOT_PATHS)` would reject with `too_big` before execute.
17
+ * The `"*"` sentinel is a plain string (no redundant `z.literal("*")`).
18
+ */
19
+ export const RootPickSchema = z.object({
20
+ root: z
21
+ .union([z.string(), z.array(z.string())])
23
22
  .optional()
24
- .describe("Absolute paths to git repo roots; use for sibling clones under a non-git parent."),
23
+ .describe('Repo path, array of paths, or "*" for all MCP roots.'),
25
24
  format: FormatSchema,
26
25
  });
27
26
  export { MAX_INVENTORY_ROOTS_DEFAULT };
@@ -16,6 +16,7 @@
16
16
  * const result = await tool({ workspaceRoot: dir, commits: [...] });
17
17
  * // result is string (markdown) or JSON-parseable string
18
18
  */
19
+ import { afterEach } from "bun:test";
19
20
  import { execFileSync } from "node:child_process";
20
21
  import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
22
  import { tmpdir } from "node:os";
@@ -33,12 +34,18 @@ const STUB_CONTEXT = {
33
34
  };
34
35
  // ---------------------------------------------------------------------------
35
36
  // Tmp-dir lifecycle — prevents accumulating thousands of leaked test dirs.
36
- // Each test file must register the hook itself:
37
- // afterEach(cleanupTmpPaths);
38
- // Module-scope afterEach(...) would only register once (first-importer wins)
39
- // because the module is cached across test files in the same bun test run.
37
+ // Each test file that calls mkTmpDir / makeRepo / trackTmpPath must register
38
+ // cleanup once at module scope:
39
+ // registerTmpCleanup();
40
+ // (equivalent to afterEach(cleanupTmpPaths) but documents the contract.)
41
+ // Module-scope afterEach(...) in this file would only register once
42
+ // (first-importer wins) because the module is cached across test files.
40
43
  // ---------------------------------------------------------------------------
41
44
  const tmpPaths = [];
45
+ /** Register afterEach cleanup for dirs created via mkTmpDir / makeRepo. Call once per test file. */
46
+ export function registerTmpCleanup() {
47
+ afterEach(cleanupTmpPaths);
48
+ }
42
49
  export function mkTmpDir(prefix) {
43
50
  const dir = mkdtempSync(join(tmpdir(), prefix));
44
51
  tmpPaths.push(dir);
@@ -1,95 +1,84 @@
1
1
  import { z } from "zod";
2
- import { registerBatchCommitTool } from "./batch-commit-tool.js";
3
- import { registerGitBlameTool } from "./git-blame-tool.js";
4
- import { registerGitBranchListTool } from "./git-branch-list-tool.js";
5
- import { registerGitCherryPickTool } from "./git-cherry-pick-tool.js";
6
- import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
7
- import { registerGitDiffTool } from "./git-diff-tool.js";
8
- import { registerGitFetchTool } from "./git-fetch-tool.js";
9
- import { registerGitInventoryTool } from "./git-inventory-tool.js";
10
- import { registerGitLogTool } from "./git-log-tool.js";
11
- import { registerGitMergeTool } from "./git-merge-tool.js";
12
- import { registerGitParityTool } from "./git-parity-tool.js";
13
- import { registerGitPushTool } from "./git-push-tool.js";
14
- import { registerGitReflogTool } from "./git-reflog-tool.js";
15
- import { registerGitResetSoftTool } from "./git-reset-soft-tool.js";
16
- import { registerGitShowTool } from "./git-show-tool.js";
17
- import { registerGitStashApplyTool, registerGitStashListTool } from "./git-stash-tool.js";
18
- import { registerGitStatusTool } from "./git-status-tool.js";
19
- import { registerGitTagTool } from "./git-tag-tool.js";
20
- import { registerGitWorktreeAddTool, registerGitWorktreeListTool, registerGitWorktreeRemoveTool, } from "./git-worktree-tool.js";
21
- import { registerListPresetsTool } from "./list-presets-tool.js";
22
- export const READ_ONLY_ABSOLUTE_ROOT_TOOLS = [
2
+ import { registerRethunkGitTools } from "./tools.js";
3
+ /**
4
+ * Fan-out read tools: polymorphic `root` routing (string | string[] | "*").
5
+ * Must stay in sync with the tools that use RootPickSchema — asserted against
6
+ * live `registerRethunkGitTools` capture in tests.
7
+ */
8
+ export const FAN_OUT_ROOT_TOOLS = [
23
9
  "git_status",
24
10
  "git_inventory",
25
11
  "git_parity",
26
12
  "list_presets",
27
13
  "git_log",
28
- "git_diff_summary",
14
+ "git_grep",
29
15
  ];
16
+ /**
17
+ * Read-only single-repo tools: `workspaceRoot` routing only.
18
+ */
30
19
  export const READ_ONLY_SINGLE_REPO_TOOLS = [
20
+ "git_diff_summary",
31
21
  "git_diff",
32
22
  "git_show",
33
- "git_worktree_list",
34
- "git_stash_list",
23
+ "git_conflicts",
35
24
  "git_blame",
36
- "git_branch_list",
37
- "git_reflog",
38
25
  ];
26
+ /**
27
+ * Mutating tools: `workspaceRoot` routing only.
28
+ */
39
29
  export const MUTATING_TOOLS = [
40
- "git_fetch",
41
30
  "batch_commit",
42
31
  "git_push",
43
32
  "git_merge",
44
33
  "git_cherry_pick",
34
+ "git_cherry_pick_continue",
45
35
  "git_reset_soft",
36
+ "git_revert",
46
37
  "git_tag",
38
+ "git_branch",
47
39
  "git_worktree_add",
48
40
  "git_worktree_remove",
49
41
  "git_stash_apply",
42
+ "git_stash_push",
50
43
  ];
44
+ /** Category union used for routing assertions; must equal live registrar names. */
51
45
  export const ALL_PARAMETER_SCHEMA_TOOLS = [
52
- ...READ_ONLY_ABSOLUTE_ROOT_TOOLS,
46
+ ...FAN_OUT_ROOT_TOOLS,
53
47
  ...READ_ONLY_SINGLE_REPO_TOOLS,
54
48
  ...MUTATING_TOOLS,
55
49
  ];
56
- function captureParameterTools(register) {
50
+ /**
51
+ * Capture parameter Zod schemas by driving the same registrar path as the live
52
+ * server (`registerRethunkGitTools`), so adding a tool only in `tools.ts` is
53
+ * enough for schema capture — no parallel register* list here.
54
+ *
55
+ * Clears `RETHUNK_GIT_TOOLS` for the duration so allowlist filtering cannot
56
+ * silently omit tools from published artifacts.
57
+ */
58
+ export function captureToolParameterSchemas() {
57
59
  const tools = [];
58
60
  const server = {
59
61
  sessions: [],
60
62
  addTool(tool) {
61
63
  tools.push({ name: tool.name, parameters: tool.parameters });
62
64
  },
65
+ addResource() {
66
+ // Presets resource is always registered; capture only needs tools.
67
+ },
63
68
  };
64
- register(server);
65
- return tools;
66
- }
67
- export function captureToolParameterSchemas() {
68
- const tools = captureParameterTools((server) => {
69
- registerGitStatusTool(server);
70
- registerGitInventoryTool(server);
71
- registerGitParityTool(server);
72
- registerListPresetsTool(server);
73
- registerGitLogTool(server);
74
- registerGitDiffSummaryTool(server);
75
- registerGitDiffTool(server);
76
- registerGitShowTool(server);
77
- registerGitWorktreeListTool(server);
78
- registerGitStashListTool(server);
79
- registerGitFetchTool(server);
80
- registerGitBlameTool(server);
81
- registerGitBranchListTool(server);
82
- registerGitReflogTool(server);
83
- registerBatchCommitTool(server);
84
- registerGitPushTool(server);
85
- registerGitMergeTool(server);
86
- registerGitCherryPickTool(server);
87
- registerGitResetSoftTool(server);
88
- registerGitTagTool(server);
89
- registerGitWorktreeAddTool(server);
90
- registerGitWorktreeRemoveTool(server);
91
- registerGitStashApplyTool(server);
92
- });
69
+ const prev = process.env.RETHUNK_GIT_TOOLS;
70
+ delete process.env.RETHUNK_GIT_TOOLS;
71
+ try {
72
+ registerRethunkGitTools(server);
73
+ }
74
+ finally {
75
+ if (prev === undefined) {
76
+ delete process.env.RETHUNK_GIT_TOOLS;
77
+ }
78
+ else {
79
+ process.env.RETHUNK_GIT_TOOLS = prev;
80
+ }
81
+ }
93
82
  return Object.fromEntries(tools.map((tool) => [tool.name, z.toJSONSchema(tool.parameters)]));
94
83
  }
95
84
  export function buildToolParameterSchemaDocument() {