@rethunk/mcp-multi-root-git 2.0.1 → 2.2.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.
@@ -0,0 +1,393 @@
1
+ import { z } from "zod";
2
+ import { gitTopLevel, spawnGitAsync } from "./git.js";
3
+ import { getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Schemas
9
+ // ---------------------------------------------------------------------------
10
+ const StrategySchema = z
11
+ .enum(["auto", "ff-only", "rebase", "merge"])
12
+ .optional()
13
+ .default("auto")
14
+ .describe("`auto` (default): cascade fast-forward → rebase → merge-commit per source. " +
15
+ "`ff-only`: only fast-forward, fail if diverged. " +
16
+ "`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
17
+ "`merge`: always create a merge commit (no fast-forward).");
18
+ // ---------------------------------------------------------------------------
19
+ // Conflict helpers
20
+ // ---------------------------------------------------------------------------
21
+ async function conflictPaths(gitTop) {
22
+ const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
23
+ if (!r.ok)
24
+ return [];
25
+ return r.stdout
26
+ .split("\n")
27
+ .map((l) => l.trim())
28
+ .filter((l) => l.length > 0);
29
+ }
30
+ async function abortMerge(gitTop) {
31
+ await spawnGitAsync(gitTop, ["merge", "--abort"]);
32
+ }
33
+ async function abortRebase(gitTop) {
34
+ await spawnGitAsync(gitTop, ["rebase", "--abort"]);
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // Per-source merge logic
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
41
+ * On conflict the repo state is cleaned (merge/rebase aborted, HEAD restored to `into`).
42
+ */
43
+ async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
44
+ // --- Classify state via merge-base ---
45
+ const intoSha = await resolveRef(gitTop, into);
46
+ const sourceSha = await resolveRef(gitTop, source);
47
+ if (!sourceSha) {
48
+ return { source, ok: false, error: "source_not_found" };
49
+ }
50
+ if (!intoSha) {
51
+ return { source, ok: false, error: "destination_not_found" };
52
+ }
53
+ const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
54
+ if (!mb.ok) {
55
+ return {
56
+ source,
57
+ ok: false,
58
+ error: "merge_base_failed",
59
+ detail: (mb.stderr || mb.stdout).trim(),
60
+ };
61
+ }
62
+ const mergeBase = mb.stdout.trim();
63
+ // source fully contained in into → noop
64
+ if (mergeBase === sourceSha) {
65
+ return { source, ok: true, outcome: "up_to_date", mergedSha: intoSha };
66
+ }
67
+ // into fully contained in source → fast-forward is the right move
68
+ const canFastForward = mergeBase === intoSha;
69
+ // --- ff-only strategy ---
70
+ if (strategy === "ff-only") {
71
+ if (!canFastForward) {
72
+ return {
73
+ source,
74
+ ok: false,
75
+ error: "cannot_fast_forward",
76
+ detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
77
+ };
78
+ }
79
+ return fastForward(gitTop, source);
80
+ }
81
+ // --- Fast-forward path for auto/rebase when applicable ---
82
+ if (canFastForward && (strategy === "auto" || strategy === "rebase")) {
83
+ return fastForward(gitTop, source);
84
+ }
85
+ // --- merge strategy: always merge commit ---
86
+ if (strategy === "merge") {
87
+ return mergeCommit(gitTop, source, mergeMessage, into);
88
+ }
89
+ // --- rebase or auto (diverged case) ---
90
+ const rebased = await rebaseSourceOntoInto(gitTop, into, source);
91
+ if (rebased.ok) {
92
+ // Rebase succeeded; FF destination up to the now-rebased source tip.
93
+ const ff = await fastForward(gitTop, source);
94
+ if (!ff.ok)
95
+ return ff;
96
+ return { ...ff, outcome: "rebase_then_ff" };
97
+ }
98
+ if (strategy === "rebase") {
99
+ return rebased; // caller opted out of merge-commit fallback
100
+ }
101
+ // auto: fall through to merge commit
102
+ return mergeCommit(gitTop, source, mergeMessage, into);
103
+ }
104
+ async function fastForward(gitTop, source) {
105
+ const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
106
+ if (!r.ok) {
107
+ await abortMerge(gitTop);
108
+ return {
109
+ source,
110
+ ok: false,
111
+ outcome: "conflicts",
112
+ conflictStage: "merge",
113
+ conflictPaths: [],
114
+ error: "merge_failed",
115
+ detail: (r.stderr || r.stdout).trim(),
116
+ };
117
+ }
118
+ const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
119
+ return {
120
+ source,
121
+ ok: true,
122
+ outcome: "fast_forward",
123
+ mergedSha: head.ok ? head.stdout.trim() : undefined,
124
+ };
125
+ }
126
+ async function mergeCommit(gitTop, source, message, into) {
127
+ const msg = message?.trim() || `Merge branch '${source}' into ${into}`;
128
+ const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
129
+ if (!r.ok) {
130
+ const paths = await conflictPaths(gitTop);
131
+ await abortMerge(gitTop);
132
+ return {
133
+ source,
134
+ ok: false,
135
+ outcome: "conflicts",
136
+ conflictStage: "merge",
137
+ conflictPaths: paths,
138
+ error: "merge_conflicts",
139
+ detail: (r.stderr || r.stdout).trim(),
140
+ };
141
+ }
142
+ const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
143
+ return {
144
+ source,
145
+ ok: true,
146
+ outcome: "merge_commit",
147
+ mergedSha: head.ok ? head.stdout.trim() : undefined,
148
+ };
149
+ }
150
+ /**
151
+ * Rebase `source` onto `into`, then return to `into`.
152
+ * On failure: abort rebase, check out `into` again, return structured conflict.
153
+ */
154
+ async function rebaseSourceOntoInto(gitTop, into, source) {
155
+ // `git rebase <upstream> <branch>` first switches to <branch>.
156
+ const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
157
+ if (!r.ok) {
158
+ const paths = await conflictPaths(gitTop);
159
+ await abortRebase(gitTop);
160
+ // Ensure we're back on `into` regardless of rebase state.
161
+ await spawnGitAsync(gitTop, ["checkout", into]);
162
+ return {
163
+ source,
164
+ ok: false,
165
+ outcome: "conflicts",
166
+ conflictStage: "rebase",
167
+ conflictPaths: paths,
168
+ error: "rebase_conflicts",
169
+ detail: (r.stderr || r.stdout).trim(),
170
+ };
171
+ }
172
+ // Rebase succeeded; switch back to destination so the caller can FF.
173
+ const co = await spawnGitAsync(gitTop, ["checkout", into]);
174
+ if (!co.ok) {
175
+ return {
176
+ source,
177
+ ok: false,
178
+ error: "checkout_failed",
179
+ detail: (co.stderr || co.stdout).trim(),
180
+ };
181
+ }
182
+ return { source, ok: true }; // caller FFs
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Cleanup helpers
186
+ // ---------------------------------------------------------------------------
187
+ async function maybeRemoveWorktree(gitTop, source, enabled) {
188
+ if (!enabled)
189
+ return undefined;
190
+ const path = await worktreeForBranch(gitTop, source);
191
+ if (!path)
192
+ return undefined;
193
+ // Re-check protected names against the worktree path's trailing segment too.
194
+ const tail = path.split("/").pop() ?? "";
195
+ if (isProtectedBranch(tail))
196
+ return undefined;
197
+ const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
198
+ return r.ok ? path : undefined;
199
+ }
200
+ async function maybeDeleteBranch(gitTop, source, enabled, into) {
201
+ if (!enabled)
202
+ return false;
203
+ if (isProtectedBranch(source))
204
+ return false;
205
+ // Safety double-check: source must be fully merged into destination.
206
+ const merged = await isFullyMergedInto(gitTop, source, into);
207
+ if (!merged)
208
+ return false;
209
+ const r = await spawnGitAsync(gitTop, ["branch", "-d", source]);
210
+ return r.ok;
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // Tool registration
214
+ // ---------------------------------------------------------------------------
215
+ export function registerGitMergeTool(server) {
216
+ server.addTool({
217
+ name: "git_merge",
218
+ description: "Merge one or more source branches into a destination. Default strategy `auto` " +
219
+ "cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
220
+ "Refuses if the working tree is dirty. Stops on the first conflict and reports " +
221
+ "the affected paths. Optional flags auto-delete merged branches and worktrees, " +
222
+ "skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
223
+ "production, release/*, hotfix/*). See docs/mcp-tools.md.",
224
+ annotations: {
225
+ readOnlyHint: false,
226
+ destructiveHint: false,
227
+ idempotentHint: false,
228
+ },
229
+ parameters: WorkspacePickSchema.extend({
230
+ sources: z
231
+ .array(z.string().min(1))
232
+ .min(1)
233
+ .max(20)
234
+ .describe("Branches to merge into the destination, in order."),
235
+ into: z
236
+ .string()
237
+ .optional()
238
+ .describe("Destination branch. Defaults to the currently checked-out branch."),
239
+ strategy: StrategySchema,
240
+ message: z
241
+ .string()
242
+ .optional()
243
+ .describe("Merge commit message (used only when a merge commit is created)."),
244
+ deleteMergedBranches: z
245
+ .boolean()
246
+ .optional()
247
+ .default(false)
248
+ .describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
249
+ "Protected names (main, master, dev, develop, stable, trunk, prod, production, " +
250
+ "release/*, hotfix/*) are always skipped. Never affects remote branches."),
251
+ deleteMergedWorktrees: z
252
+ .boolean()
253
+ .optional()
254
+ .default(false)
255
+ .describe("After all sources merge cleanly, remove any local worktree currently checked out " +
256
+ "on a source branch (`git worktree remove`). Protected tails always skipped."),
257
+ }),
258
+ execute: async (args) => {
259
+ const pre = requireGitAndRoots(server, args, undefined);
260
+ if (!pre.ok)
261
+ return jsonRespond(pre.error);
262
+ const rootInput = pre.roots[0];
263
+ if (!rootInput)
264
+ return jsonRespond({ error: "no_workspace_root" });
265
+ const gitTop = gitTopLevel(rootInput);
266
+ if (!gitTop)
267
+ return jsonRespond({ error: "not_a_git_repository", path: rootInput });
268
+ // --- Validate ref tokens early ---
269
+ for (const s of args.sources) {
270
+ if (!isSafeGitRefToken(s)) {
271
+ return jsonRespond({ error: "unsafe_ref_token", ref: s });
272
+ }
273
+ }
274
+ if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
275
+ return jsonRespond({ error: "unsafe_ref_token", ref: args.into });
276
+ }
277
+ // --- Resolve destination ---
278
+ const startBranch = await getCurrentBranch(gitTop);
279
+ const into = args.into?.trim() || startBranch;
280
+ if (!into) {
281
+ return jsonRespond({ error: "into_detached_head" });
282
+ }
283
+ // --- Refuse dirty tree ---
284
+ if (!(await isWorkingTreeClean(gitTop))) {
285
+ return jsonRespond({ error: "working_tree_dirty" });
286
+ }
287
+ // --- Ensure destination is checked out ---
288
+ if (into !== startBranch) {
289
+ const co = await spawnGitAsync(gitTop, ["checkout", into]);
290
+ if (!co.ok) {
291
+ return jsonRespond({
292
+ error: "checkout_failed",
293
+ detail: (co.stderr || co.stdout).trim(),
294
+ });
295
+ }
296
+ }
297
+ // Verify destination exists after checkout.
298
+ const intoShaProbe = await resolveRef(gitTop, into);
299
+ if (!intoShaProbe) {
300
+ return jsonRespond({ error: "destination_not_found", ref: into });
301
+ }
302
+ // --- Merge each source sequentially ---
303
+ const strategy = args.strategy ?? "auto";
304
+ const results = [];
305
+ let firstConflict = false;
306
+ for (const source of args.sources) {
307
+ const r = await mergeOneSource(gitTop, into, source, strategy, args.message);
308
+ results.push(r);
309
+ if (!r.ok) {
310
+ firstConflict = true;
311
+ break;
312
+ }
313
+ }
314
+ const allOk = !firstConflict && results.every((r) => r.ok);
315
+ // --- Cleanup (only on full success) ---
316
+ if (allOk) {
317
+ for (let i = 0; i < results.length; i++) {
318
+ const r = results[i];
319
+ if (!r || r.outcome === "up_to_date")
320
+ continue;
321
+ const worktreeRemoved = await maybeRemoveWorktree(gitTop, r.source, args.deleteMergedWorktrees ?? false);
322
+ const branchDeleted = await maybeDeleteBranch(gitTop, r.source, args.deleteMergedBranches ?? false, into);
323
+ results[i] = {
324
+ ...r,
325
+ ...spreadDefined("worktreeRemoved", worktreeRemoved),
326
+ ...spreadWhen(branchDeleted, { branchDeleted: true }),
327
+ };
328
+ }
329
+ }
330
+ const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
331
+ const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
332
+ if (args.format === "json") {
333
+ return jsonRespond({
334
+ ok: allOk,
335
+ into,
336
+ strategy,
337
+ ...spreadDefined("headSha", headSha),
338
+ applied: results.filter((r) => r.ok).length,
339
+ total: args.sources.length,
340
+ results: results.map((r) => ({
341
+ source: r.source,
342
+ ok: r.ok,
343
+ ...spreadDefined("outcome", r.outcome),
344
+ ...spreadDefined("mergedSha", r.mergedSha),
345
+ ...spreadDefined("conflictStage", r.conflictStage),
346
+ ...spreadWhen((r.conflictPaths?.length ?? 0) > 0, {
347
+ conflictPaths: r.conflictPaths,
348
+ }),
349
+ ...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
350
+ ...spreadDefined("worktreeRemoved", r.worktreeRemoved),
351
+ ...spreadDefined("skipReason", r.skipReason),
352
+ ...spreadDefined("error", r.error),
353
+ ...spreadDefined("detail", r.detail),
354
+ })),
355
+ });
356
+ }
357
+ // --- Markdown ---
358
+ const lines = [];
359
+ const applied = results.filter((r) => r.ok).length;
360
+ const header = allOk
361
+ ? `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied`
362
+ : `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied (stopped on conflict)`;
363
+ lines.push(header, "");
364
+ for (const r of results) {
365
+ const icon = r.ok ? "✓" : "✗";
366
+ const tail = [];
367
+ if (r.outcome)
368
+ tail.push(r.outcome);
369
+ if (r.mergedSha)
370
+ tail.push(`\`${r.mergedSha.slice(0, 7)}\``);
371
+ if (r.branchDeleted)
372
+ tail.push("branch deleted");
373
+ if (r.worktreeRemoved)
374
+ tail.push(`worktree removed: ${r.worktreeRemoved}`);
375
+ lines.push(`${icon} ${r.source}${tail.length ? ` — ${tail.join(", ")}` : ""}`);
376
+ if (!r.ok) {
377
+ if (r.conflictPaths?.length) {
378
+ for (const p of r.conflictPaths)
379
+ lines.push(` conflict: ${p}`);
380
+ }
381
+ if (r.error)
382
+ lines.push(` Error: ${r.error}${r.detail ? ` — ${r.detail}` : ""}`);
383
+ }
384
+ }
385
+ if (!allOk) {
386
+ const skipped = args.sources.length - results.length;
387
+ if (skipped > 0)
388
+ lines.push("", `${skipped} remaining source(s) skipped.`);
389
+ }
390
+ return lines.join("\n");
391
+ },
392
+ });
393
+ }
@@ -0,0 +1,151 @@
1
+ import { spawnGitAsync } from "./git.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Protected branch names — never auto-delete, never cascade destructive ops onto
4
+ // ---------------------------------------------------------------------------
5
+ const PROTECTED_EXACT = new Set([
6
+ "main",
7
+ "master",
8
+ "dev",
9
+ "develop",
10
+ "stable",
11
+ "trunk",
12
+ "prod",
13
+ "production",
14
+ "HEAD",
15
+ ]);
16
+ const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
17
+ /** True when a branch name is on the protected list and must not be auto-deleted. */
18
+ export function isProtectedBranch(name) {
19
+ const t = name.trim();
20
+ if (t === "")
21
+ return true;
22
+ if (PROTECTED_EXACT.has(t))
23
+ return true;
24
+ return PROTECTED_PATTERN.test(t);
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Ref/branch name validation (argv-safe subset of git's ref-format rules)
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Conservative check for branch/ref names passed to git argv.
31
+ * Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
32
+ * sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
33
+ * and pathological tokens.
34
+ */
35
+ export function isSafeGitRefToken(s) {
36
+ const t = s.trim();
37
+ if (t.length === 0 || t.length > 256)
38
+ return false;
39
+ if (t.startsWith("-"))
40
+ return false;
41
+ if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith("."))
42
+ return false;
43
+ if (t.includes(".."))
44
+ return false;
45
+ if (t.includes("@{"))
46
+ return false;
47
+ if (t.includes("//"))
48
+ return false;
49
+ return /^[A-Za-z0-9_./+-]+$/.test(t);
50
+ }
51
+ /**
52
+ * Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
53
+ * used by `git log` / `git cherry-pick`. Splits once and validates each side.
54
+ */
55
+ export function isSafeGitRangeToken(s) {
56
+ const t = s.trim();
57
+ if (t.includes("...")) {
58
+ const parts = t.split("...");
59
+ return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
60
+ }
61
+ if (t.includes("..")) {
62
+ const parts = t.split("..");
63
+ return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
64
+ }
65
+ return isSafeGitRefToken(t);
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Async helpers
69
+ // ---------------------------------------------------------------------------
70
+ /** Current branch name; `null` if detached HEAD. */
71
+ export async function getCurrentBranch(cwd) {
72
+ const r = await spawnGitAsync(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]);
73
+ if (!r.ok)
74
+ return null;
75
+ const name = r.stdout.trim();
76
+ return name === "" ? null : name;
77
+ }
78
+ /** Resolve a ref to its full SHA; `null` if unknown. */
79
+ export async function resolveRef(cwd, ref) {
80
+ if (!isSafeGitRefToken(ref))
81
+ return null;
82
+ const r = await spawnGitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
83
+ if (!r.ok)
84
+ return null;
85
+ const sha = r.stdout.trim();
86
+ return sha === "" ? null : sha;
87
+ }
88
+ /** Working tree clean (no staged, no unstaged, no untracked). */
89
+ export async function isWorkingTreeClean(cwd) {
90
+ const r = await spawnGitAsync(cwd, ["status", "--porcelain"]);
91
+ if (!r.ok)
92
+ return false;
93
+ return r.stdout.trim() === "";
94
+ }
95
+ /** True when every commit on `branch` is reachable from `target`. */
96
+ export async function isFullyMergedInto(cwd, branch, target) {
97
+ if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
98
+ return false;
99
+ const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
100
+ return r.ok;
101
+ }
102
+ /**
103
+ * SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
104
+ * Returns `null` on git failure.
105
+ */
106
+ export async function commitListBetween(cwd, excludeRef, includeRef) {
107
+ if (!isSafeGitRefToken(excludeRef) || !isSafeGitRefToken(includeRef))
108
+ return null;
109
+ const r = await spawnGitAsync(cwd, ["rev-list", "--reverse", `${excludeRef}..${includeRef}`]);
110
+ if (!r.ok)
111
+ return null;
112
+ return r.stdout
113
+ .split("\n")
114
+ .map((l) => l.trim())
115
+ .filter((l) => l.length > 0);
116
+ }
117
+ /** Parse `git worktree list --porcelain` into structured entries. */
118
+ export async function listWorktrees(cwd) {
119
+ const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
120
+ if (!r.ok)
121
+ return [];
122
+ const out = [];
123
+ let cur = {};
124
+ for (const line of r.stdout.split("\n")) {
125
+ if (line.startsWith("worktree ")) {
126
+ if (cur.path)
127
+ out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
128
+ cur = { path: line.slice("worktree ".length).trim() };
129
+ }
130
+ else if (line.startsWith("HEAD ")) {
131
+ cur.head = line.slice("HEAD ".length).trim();
132
+ }
133
+ else if (line.startsWith("branch ")) {
134
+ // e.g. `branch refs/heads/foo`
135
+ const ref = line.slice("branch ".length).trim();
136
+ cur.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
137
+ }
138
+ else if (line === "detached") {
139
+ cur.branch = null;
140
+ }
141
+ }
142
+ if (cur.path)
143
+ out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
144
+ return out;
145
+ }
146
+ /** Path of the worktree currently checked out on `branch`; `null` if none. */
147
+ export async function worktreeForBranch(cwd, branch) {
148
+ const trees = await listWorktrees(cwd);
149
+ const hit = trees.find((t) => t.branch === branch);
150
+ return hit?.path ?? null;
151
+ }
@@ -1,8 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- export const MCP_JSON_FORMAT_VERSION = "2";
5
- export function readPackageVersion() {
4
+ function readPackageVersion() {
6
5
  const here = dirname(fileURLToPath(import.meta.url));
7
6
  const pkgPath = join(here, "..", "..", "package.json");
8
7
  try {
@@ -24,7 +24,7 @@ export const PRESET_FILE_PATH = ".rethunk/git-mcp-presets.json";
24
24
  * - Wrapped: `{ "schemaVersion": "1", "presets": { "name": { ... } } }`
25
25
  * - Legacy: `{ "name": { ... }, ... }` with optional top-level `schemaVersion` / `$schema` (editor hints).
26
26
  */
27
- export function splitPresetFileRaw(raw) {
27
+ function splitPresetFileRaw(raw) {
28
28
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
29
29
  throw new Error("invalid_root");
30
30
  }
@@ -96,7 +96,7 @@ export function presetLoadErrorPayload(gitTop, fail) {
96
96
  }
97
97
  return { error: "preset_file_invalid", presetFile };
98
98
  }
99
- export function getPresetEntry(gitTop, presetName) {
99
+ function getPresetEntry(gitTop, presetName) {
100
100
  const loaded = loadPresetsFromGitTop(gitTop);
101
101
  if (!loaded.ok) {
102
102
  if (loaded.reason === "missing") {
@@ -124,7 +124,7 @@ export function getPresetEntry(gitTop, presetName) {
124
124
  }
125
125
  return { ok: true, entry, presetSchemaVersion: loaded.schemaVersion };
126
126
  }
127
- export function mergeNestedRoots(preset, inline) {
127
+ function mergeNestedRoots(preset, inline) {
128
128
  const a = preset ?? [];
129
129
  const b = inline ?? [];
130
130
  if (a.length === 0 && b.length === 0)
@@ -139,7 +139,7 @@ export function mergeNestedRoots(preset, inline) {
139
139
  }
140
140
  return out;
141
141
  }
142
- export function mergePairs(preset, inline) {
142
+ function mergePairs(preset, inline) {
143
143
  const a = preset ?? [];
144
144
  const b = inline ?? [];
145
145
  if (a.length === 0 && b.length === 0)
@@ -2,7 +2,7 @@ import { basename, resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { gateGit, gitTopLevel } from "./git.js";
4
4
  import { loadPresetsFromGitTop, presetLoadErrorPayload } from "./presets.js";
5
- export function uriToPath(uri) {
5
+ function uriToPath(uri) {
6
6
  if (!uri.startsWith("file://"))
7
7
  return null;
8
8
  try {
@@ -12,7 +12,7 @@ export function uriToPath(uri) {
12
12
  return null;
13
13
  }
14
14
  }
15
- export function listFileRoots(server) {
15
+ function listFileRoots(server) {
16
16
  const sessions = server.sessions;
17
17
  const roots = sessions[0]?.roots ?? [];
18
18
  const paths = [];
@@ -24,7 +24,7 @@ export function listFileRoots(server) {
24
24
  return paths;
25
25
  }
26
26
  /** Basename or trailing path segment; compares using normalized slashes so Windows backslashes match. */
27
- export function pathMatchesWorkspaceRootHint(rootPath, hint) {
27
+ function pathMatchesWorkspaceRootHint(rootPath, hint) {
28
28
  const h = hint.trim();
29
29
  if (!h)
30
30
  return true;
@@ -39,7 +39,7 @@ export function pathMatchesWorkspaceRootHint(rootPath, hint) {
39
39
  return true;
40
40
  return basename(rootPath) === h;
41
41
  }
42
- export function resolveWorkspaceRoots(server, args) {
42
+ function resolveWorkspaceRoots(server, args) {
43
43
  if (args.workspaceRoot?.trim()) {
44
44
  return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
45
45
  }
@@ -69,7 +69,7 @@ export function resolveWorkspaceRoots(server, args) {
69
69
  * When a preset name is requested and multiple MCP roots exist, pick the first root
70
70
  * whose git toplevel loads a preset file containing that name.
71
71
  */
72
- export function resolveRootsForPreset(server, args, presetName) {
72
+ function resolveRootsForPreset(server, args, presetName) {
73
73
  if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
74
74
  return resolveWorkspaceRoots(server, args);
75
75
  }