@rethunk/mcp-multi-root-git 1.0.0 → 2.1.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.
@@ -6,48 +6,52 @@ export function validateRepoPath(rel, gitTop) {
6
6
  return { abs, underTop: assertRelativePathUnderTop(rel, abs, gitTop) };
7
7
  }
8
8
  export function makeSkipEntry(label, abs, upstreamMode, skipReason) {
9
- return {
10
- label,
11
- path: abs,
12
- branchStatus: "",
13
- shortStatus: "",
14
- detached: false,
15
- headAbbrev: "",
16
- upstreamMode,
17
- upstreamRef: null,
18
- ahead: null,
19
- behind: null,
20
- upstreamNote: "",
21
- skipReason,
22
- };
9
+ return { label, path: abs, upstreamMode, skipReason };
23
10
  }
24
11
  export function buildInventorySectionMarkdown(e) {
12
+ const header = `## ${e.label} — ${e.path}`;
25
13
  if (e.skipReason) {
26
- return [`## ${e.label}`, `path: ${e.path}`, "```text", e.skipReason, "```", ``];
27
- }
28
- const lines = [];
29
- lines.push(e.branchStatus);
30
- lines.push("");
31
- lines.push("short:");
32
- lines.push(e.shortStatus || "(clean)");
33
- lines.push("");
34
- if (e.detached) {
35
- lines.push("branch: (detached HEAD)");
36
- lines.push("");
14
+ return ["", header, e.skipReason];
37
15
  }
38
- if (e.ahead != null && e.behind != null && e.upstreamRef) {
39
- lines.push(`ahead_of_${e.upstreamRef.replace(/\//g, "_")}: ${e.ahead}`);
40
- lines.push(`behind_${e.upstreamRef.replace(/\//g, "_")}: ${e.behind}`);
16
+ const lines = [e.branchStatus || "(clean)"];
17
+ if (e.detached)
18
+ lines.push("detached HEAD");
19
+ if (e.ahead !== undefined && e.behind !== undefined && e.upstreamRef) {
20
+ lines.push(`${e.upstreamRef}: ahead ${e.ahead}, behind ${e.behind}`);
41
21
  }
42
- else {
22
+ else if (e.upstreamNote) {
43
23
  lines.push(`upstream: ${e.upstreamNote}`);
44
24
  }
45
- return [`## ${e.label}`, `path: ${e.path}`, "```text", lines.join("\n"), "```", ``];
25
+ const single = lines.length === 1 ? lines[0] : undefined;
26
+ if (single !== undefined && !single.includes("\n")) {
27
+ return ["", header, single];
28
+ }
29
+ return ["", header, "```text", lines.join("\n"), "```"];
46
30
  }
47
- function upstreamNoteFor(ref, ahead, behind) {
48
- return ahead != null && behind != null
49
- ? `tracking ${ref}`
50
- : `upstream ${ref} (counts unreadable)`;
31
+ function upstreamNoteFor(ref, hasCounts) {
32
+ return hasCounts ? `tracking ${ref}` : `upstream ${ref} (counts unreadable)`;
33
+ }
34
+ function buildEntry(params) {
35
+ const out = {
36
+ label: params.label,
37
+ path: params.absPath,
38
+ upstreamMode: params.upstreamMode,
39
+ };
40
+ if (params.branchStatus)
41
+ out.branchStatus = params.branchStatus;
42
+ if (params.detached)
43
+ out.detached = true;
44
+ if (params.headAbbrev)
45
+ out.headAbbrev = params.headAbbrev;
46
+ if (params.upstreamRef !== null)
47
+ out.upstreamRef = params.upstreamRef;
48
+ if (params.ahead !== null)
49
+ out.ahead = params.ahead;
50
+ if (params.behind !== null)
51
+ out.behind = params.behind;
52
+ if (params.upstreamNote)
53
+ out.upstreamNote = params.upstreamNote;
54
+ return out;
51
55
  }
52
56
  export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch) {
53
57
  const [snap, headR] = await Promise.all([
@@ -55,79 +59,52 @@ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBr
55
59
  spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
56
60
  ]);
57
61
  const branchStatus = snap.branchLine;
58
- const shortStatus = snap.shortLine;
59
62
  const headAbbrev = headR.ok ? headR.stdout.trim() : "";
60
63
  const detached = !headR.ok || headAbbrev === "HEAD" || headAbbrev.endsWith("/HEAD");
61
- const useFixed = fixedRemote !== undefined && fixedBranch !== undefined;
62
- if (useFixed) {
63
- const remote = fixedRemote;
64
- const branch = fixedBranch;
65
- const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", `${remote}/${branch}`]);
64
+ const base = { label, absPath, branchStatus, detached, headAbbrev };
65
+ if (fixedRemote !== undefined && fixedBranch !== undefined) {
66
+ const ref = `${fixedRemote}/${fixedBranch}`;
67
+ const verify = await spawnGitAsync(absPath, ["rev-parse", "--verify", ref]);
66
68
  if (!verify.ok) {
67
- return {
68
- label,
69
- path: absPath,
70
- branchStatus,
71
- shortStatus,
72
- detached,
73
- headAbbrev: headAbbrev || "(unknown)",
69
+ return buildEntry({
70
+ ...base,
74
71
  upstreamMode: "fixed",
75
- upstreamRef: `${remote}/${branch}`,
72
+ upstreamRef: ref,
76
73
  ahead: null,
77
74
  behind: null,
78
- upstreamNote: `(no local ref ${remote}/${branch} or unreadable)`,
79
- };
75
+ upstreamNote: `(no local ref ${ref} or unreadable)`,
76
+ });
80
77
  }
81
- const ref = `${remote}/${branch}`;
82
78
  const { ahead, behind } = await fetchAheadBehind(absPath, ref);
83
- return {
84
- label,
85
- path: absPath,
86
- branchStatus,
87
- shortStatus,
88
- detached,
89
- headAbbrev: headAbbrev || "(unknown)",
79
+ return buildEntry({
80
+ ...base,
90
81
  upstreamMode: "fixed",
91
82
  upstreamRef: ref,
92
83
  ahead,
93
84
  behind,
94
- upstreamNote: upstreamNoteFor(ref, ahead, behind),
95
- };
85
+ upstreamNote: upstreamNoteFor(ref, ahead != null && behind != null),
86
+ });
96
87
  }
97
88
  const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
98
89
  if (!upVerify.ok) {
99
- let note = "no upstream configured";
100
- if (detached) {
101
- note = "detached HEAD — no upstream";
102
- }
103
- return {
104
- label,
105
- path: absPath,
106
- branchStatus,
107
- shortStatus,
108
- detached,
109
- headAbbrev: headAbbrev || "(unknown)",
90
+ return buildEntry({
91
+ ...base,
110
92
  upstreamMode: "auto",
111
93
  upstreamRef: null,
112
94
  ahead: null,
113
95
  behind: null,
114
- upstreamNote: note,
115
- };
96
+ upstreamNote: detached ? "detached HEAD — no upstream" : "no upstream configured",
97
+ });
116
98
  }
117
99
  const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
118
100
  const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
119
101
  const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
120
- return {
121
- label,
122
- path: absPath,
123
- branchStatus,
124
- shortStatus,
125
- detached,
126
- headAbbrev: headAbbrev || "(unknown)",
102
+ return buildEntry({
103
+ ...base,
127
104
  upstreamMode: "auto",
128
105
  upstreamRef,
129
106
  ahead,
130
107
  behind,
131
- upstreamNote: upstreamNoteFor(upstreamRef, ahead, behind),
132
- };
108
+ upstreamNote: upstreamNoteFor(upstreamRef, ahead != null && behind != null),
109
+ });
133
110
  }
@@ -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 = "1";
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 {
@@ -23,13 +22,7 @@ export function readMcpServerVersion() {
23
22
  return "0.0.0";
24
23
  }
25
24
  export function jsonRespond(body) {
26
- return JSON.stringify({
27
- ...body,
28
- rethunkGitMcp: {
29
- jsonFormatVersion: MCP_JSON_FORMAT_VERSION,
30
- packageVersion: readPackageVersion(),
31
- },
32
- }, null, 2);
25
+ return JSON.stringify(body);
33
26
  }
34
27
  /** Spread into an object literal only when `cond` is true; otherwise `{}`. */
35
28
  export function spreadWhen(cond, fields) {
@@ -7,7 +7,7 @@ import { WorkspacePickSchema } from "./schemas.js";
7
7
  export function registerListPresetsTool(server) {
8
8
  server.addTool({
9
9
  name: "list_presets",
10
- description: "List named entries from `.rethunk/git-mcp-presets.json` at the git toplevel for the resolved workspace root.",
10
+ description: "List presets from .rethunk/git-mcp-presets.json. See docs/mcp-tools.md.",
11
11
  parameters: WorkspacePickSchema.pick({
12
12
  workspaceRoot: true,
13
13
  rootIndex: true,
@@ -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,24 +96,25 @@ 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") {
103
103
  return {
104
+ ok: false,
104
105
  error: {
105
106
  error: "preset_not_found",
106
107
  preset: presetName,
107
108
  presetFile: join(gitTop, PRESET_FILE_PATH),
108
- message: "Preset file missing",
109
109
  },
110
110
  };
111
111
  }
112
- return { error: presetLoadErrorPayload(gitTop, loaded) };
112
+ return { ok: false, error: presetLoadErrorPayload(gitTop, loaded) };
113
113
  }
114
114
  const entry = loaded.data[presetName];
115
115
  if (!entry) {
116
116
  return {
117
+ ok: false,
117
118
  error: {
118
119
  error: "preset_not_found",
119
120
  preset: presetName,
@@ -121,9 +122,9 @@ export function getPresetEntry(gitTop, presetName) {
121
122
  },
122
123
  };
123
124
  }
124
- return { entry, presetSchemaVersion: loaded.schemaVersion };
125
+ return { ok: true, entry, presetSchemaVersion: loaded.schemaVersion };
125
126
  }
126
- export function mergeNestedRoots(preset, inline) {
127
+ function mergeNestedRoots(preset, inline) {
127
128
  const a = preset ?? [];
128
129
  const b = inline ?? [];
129
130
  if (a.length === 0 && b.length === 0)
@@ -138,7 +139,7 @@ export function mergeNestedRoots(preset, inline) {
138
139
  }
139
140
  return out;
140
141
  }
141
- export function mergePairs(preset, inline) {
142
+ function mergePairs(preset, inline) {
142
143
  const a = preset ?? [];
143
144
  const b = inline ?? [];
144
145
  if (a.length === 0 && b.length === 0)
@@ -147,7 +148,7 @@ export function mergePairs(preset, inline) {
147
148
  }
148
149
  export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNestedRoots) {
149
150
  const got = getPresetEntry(gitTop, presetName);
150
- if ("error" in got)
151
+ if (!got.ok)
151
152
  return { ok: false, error: got.error };
152
153
  const fromPreset = got.entry.nestedRoots;
153
154
  let nestedRoots = inlineNestedRoots;
@@ -161,7 +162,7 @@ export function applyPresetNestedRoots(gitTop, presetName, presetMerge, inlineNe
161
162
  }
162
163
  export function applyPresetParityPairs(gitTop, presetName, presetMerge, inlinePairs) {
163
164
  const got = getPresetEntry(gitTop, presetName);
164
- if ("error" in got)
165
+ if (!got.ok)
165
166
  return { ok: false, error: got.error };
166
167
  const fromPreset = got.entry.parityPairs;
167
168
  let pairs = inlinePairs;
@@ -1,8 +1,8 @@
1
- import { basename, isAbsolute, resolve } from "node:path";
1
+ 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,40 +24,29 @@ 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;
31
- if (basename(rootPath) === h)
32
- return true;
33
31
  const absRoot = resolve(rootPath);
34
- if (absRoot === h)
35
- return true;
36
- try {
37
- if (isAbsolute(h) && resolve(h) === absRoot)
38
- return true;
39
- }
40
- catch {
41
- /* invalid absolute hint */
42
- }
43
32
  const normRoot = absRoot.replace(/\\/g, "/").replace(/\/+$/, "");
44
33
  const normHint = h.replace(/\\/g, "/").replace(/\/+$/, "").replace(/^\/+/, "");
45
34
  if (!normHint)
46
35
  return true;
47
36
  if (normRoot === normHint)
48
37
  return true;
49
- return normRoot.endsWith(`/${normHint}`);
38
+ if (normRoot.endsWith(`/${normHint}`))
39
+ return true;
40
+ return basename(rootPath) === h;
50
41
  }
51
- export function resolveWorkspaceRoots(server, args) {
42
+ function resolveWorkspaceRoots(server, args) {
52
43
  if (args.workspaceRoot?.trim()) {
53
44
  return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
54
45
  }
55
46
  const fileRoots = listFileRoots(server);
47
+ const fallback = { ok: true, roots: [process.cwd()] };
56
48
  if (args.allWorkspaceRoots) {
57
- if (fileRoots.length === 0) {
58
- return { ok: true, roots: [process.cwd()] };
59
- }
60
- return { ok: true, roots: fileRoots };
49
+ return fileRoots.length === 0 ? fallback : { ok: true, roots: fileRoots };
61
50
  }
62
51
  if (args.rootIndex != null) {
63
52
  const r = fileRoots[args.rootIndex];
@@ -74,16 +63,13 @@ export function resolveWorkspaceRoots(server, args) {
74
63
  return { ok: true, roots: [r] };
75
64
  }
76
65
  const primary = fileRoots[0];
77
- if (primary !== undefined) {
78
- return { ok: true, roots: [primary] };
79
- }
80
- return { ok: true, roots: [process.cwd()] };
66
+ return primary !== undefined ? { ok: true, roots: [primary] } : fallback;
81
67
  }
82
68
  /**
83
69
  * When a preset name is requested and multiple MCP roots exist, pick the first root
84
70
  * whose git toplevel loads a preset file containing that name.
85
71
  */
86
- export function resolveRootsForPreset(server, args, presetName) {
72
+ function resolveRootsForPreset(server, args, presetName) {
87
73
  if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
88
74
  return resolveWorkspaceRoots(server, args);
89
75
  }
@@ -2,21 +2,9 @@ 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
4
  export const WorkspacePickSchema = z.object({
5
- workspaceRoot: z
6
- .string()
7
- .optional()
8
- .describe("Override workspace path. Wins over MCP roots, rootIndex, and allWorkspaceRoots."),
9
- rootIndex: z
10
- .number()
11
- .int()
12
- .min(0)
13
- .optional()
14
- .describe("Use the Nth file:// MCP root (0-based) when multiple workspace roots exist."),
15
- allWorkspaceRoots: z
16
- .boolean()
17
- .optional()
18
- .default(false)
19
- .describe("Run against every file:// MCP root and aggregate results."),
20
- format: FormatSchema.describe('Return "markdown" (default) or structured "json".'),
5
+ workspaceRoot: z.string().optional().describe("Highest-priority override."),
6
+ rootIndex: z.number().int().min(0).optional(),
7
+ allWorkspaceRoots: z.boolean().optional().default(false),
8
+ format: FormatSchema,
21
9
  });
22
10
  export { MAX_INVENTORY_ROOTS_DEFAULT };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Lightweight test harness for MCP tool execute handlers.
3
+ *
4
+ * FastMCP does not expose a way to inject a custom transport, so the full
5
+ * MCP client/server stack cannot be wired up in tests without stdio or HTTP.
6
+ * Instead, we use a duck-typed fake server that satisfies the FastMCP interface
7
+ * just enough for tool registration: it has `sessions` (empty — tools use
8
+ * `workspaceRoot` arg which bypasses session root detection) and `addTool`
9
+ * which captures the tool definition so we can call `execute` directly.
10
+ *
11
+ * Context passed to execute is a no-op stub — none of the current tools
12
+ * use the context object (logging, progress, etc.).
13
+ *
14
+ * Usage:
15
+ * const tool = captureTool(registerBatchCommitTool);
16
+ * const result = await tool({ workspaceRoot: dir, commits: [...] });
17
+ * // result is string (markdown) or JSON-parseable string
18
+ */
19
+ // Stub context — no tool currently uses context
20
+ const STUB_CONTEXT = {
21
+ log: {
22
+ debug: () => undefined,
23
+ error: () => undefined,
24
+ info: () => undefined,
25
+ warn: () => undefined,
26
+ },
27
+ reportProgress: async () => undefined,
28
+ session: undefined,
29
+ };
30
+ // ---------------------------------------------------------------------------
31
+ // Fake server
32
+ // ---------------------------------------------------------------------------
33
+ function makeFakeServer() {
34
+ const tools = [];
35
+ const server = {
36
+ sessions: [],
37
+ addTool(tool) {
38
+ tools.push({ name: tool.name, execute: tool.execute });
39
+ },
40
+ };
41
+ return { server, tools };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Public API
45
+ // ---------------------------------------------------------------------------
46
+ /**
47
+ * Register one tool and return a caller that invokes its execute handler.
48
+ * The returned function accepts tool args (always include `workspaceRoot`)
49
+ * and returns the raw result as a string.
50
+ */
51
+ export function captureTool(register, toolName) {
52
+ const { server, tools } = makeFakeServer();
53
+ register(server);
54
+ const pick = toolName ? tools.find((t) => t.name === toolName) : tools[0];
55
+ if (!pick) {
56
+ throw new Error(`captureTool: no tool captured${toolName ? ` named "${toolName}"` : ""}. Did you forget to call register?`);
57
+ }
58
+ return async (args) => {
59
+ const result = await pick.execute(args, STUB_CONTEXT);
60
+ if (typeof result === "string")
61
+ return result;
62
+ return JSON.stringify(result);
63
+ };
64
+ }
@@ -1,4 +1,7 @@
1
+ import { registerBatchCommitTool } from "./batch-commit-tool.js";
2
+ import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
1
3
  import { registerGitInventoryTool } from "./git-inventory-tool.js";
4
+ import { registerGitLogTool } from "./git-log-tool.js";
2
5
  import { registerGitParityTool } from "./git-parity-tool.js";
3
6
  import { registerGitStatusTool } from "./git-status-tool.js";
4
7
  import { registerListPresetsTool } from "./list-presets-tool.js";
@@ -8,5 +11,8 @@ export function registerRethunkGitTools(server) {
8
11
  registerGitInventoryTool(server);
9
12
  registerGitParityTool(server);
10
13
  registerListPresetsTool(server);
14
+ registerBatchCommitTool(server);
15
+ registerGitDiffSummaryTool(server);
16
+ registerGitLogTool(server);
11
17
  registerPresetsResource(server);
12
18
  }