@rethunk/mcp-multi-root-git 1.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.
@@ -0,0 +1,84 @@
1
+ import { join, resolve } from "node:path";
2
+ import { z } from "zod";
3
+ import { isStrictlyUnderGitTop } from "../repo-paths.js";
4
+ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitStatusShortBranchAsync, gitTopLevel, hasGitMetadata, parseGitSubmodulePaths, } from "./git.js";
5
+ import { jsonRespond } from "./json.js";
6
+ import { requireGitAndRoots } from "./roots.js";
7
+ import { WorkspacePickSchema } from "./schemas.js";
8
+ export function registerGitStatusTool(server) {
9
+ server.addTool({
10
+ name: "git_status",
11
+ description: "Run `git status --short -b` in the workspace root and (optionally) each path from `.gitmodules`. " +
12
+ "Read-only. Supports multi-root MCP workspaces via `allWorkspaceRoots` or `rootIndex`.",
13
+ parameters: WorkspacePickSchema.extend({
14
+ includeSubmodules: z
15
+ .boolean()
16
+ .optional()
17
+ .default(true)
18
+ .describe("When true (default), include submodule paths listed in .gitmodules."),
19
+ }),
20
+ execute: async (args) => {
21
+ const pre = requireGitAndRoots(server, args, undefined);
22
+ if (!pre.ok) {
23
+ return jsonRespond(pre.error);
24
+ }
25
+ const groups = [];
26
+ for (const rootInput of pre.roots) {
27
+ const repos = [];
28
+ const top = gitTopLevel(rootInput);
29
+ if (!top) {
30
+ repos.push({
31
+ label: rootInput,
32
+ path: rootInput,
33
+ statusText: "not a git repository",
34
+ ok: false,
35
+ });
36
+ groups.push({ mcpRoot: rootInput, repos });
37
+ continue;
38
+ }
39
+ const includeSubmodules = args.includeSubmodules !== false;
40
+ const meta = await gitStatusShortBranchAsync(top);
41
+ repos.push({ label: ".", path: top, statusText: meta.text, ok: meta.ok });
42
+ if (includeSubmodules) {
43
+ const rels = parseGitSubmodulePaths(top);
44
+ const subRows = await asyncPool(rels, GIT_SUBPROCESS_PARALLELISM, async (rel) => {
45
+ const subPath = resolve(join(top, rel));
46
+ if (!isStrictlyUnderGitTop(subPath, top)) {
47
+ return {
48
+ label: rel,
49
+ path: subPath,
50
+ statusText: "(submodule path escapes repository — rejected)",
51
+ ok: false,
52
+ };
53
+ }
54
+ if (!hasGitMetadata(subPath)) {
55
+ return {
56
+ label: rel,
57
+ path: subPath,
58
+ statusText: "(no .git — submodule not checked out?)",
59
+ ok: false,
60
+ };
61
+ }
62
+ const st = await gitStatusShortBranchAsync(subPath);
63
+ return { label: rel, path: subPath, statusText: st.text, ok: st.ok };
64
+ });
65
+ repos.push(...subRows);
66
+ }
67
+ groups.push({ mcpRoot: rootInput, repos });
68
+ }
69
+ if (args.format === "json") {
70
+ return jsonRespond({ groups });
71
+ }
72
+ const sections = ["# Multi-root git status", ""];
73
+ for (const g of groups) {
74
+ if (groups.length > 1) {
75
+ sections.push(`### MCP root: ${g.mcpRoot}`, "");
76
+ }
77
+ for (const row of g.repos) {
78
+ sections.push(`## ${row.label}`, `path: ${row.path}`, "```text", row.statusText || "(empty)", "```", ``);
79
+ }
80
+ }
81
+ return sections.join("\n");
82
+ },
83
+ });
84
+ }
@@ -0,0 +1,165 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ /** Parallel git subprocesses for inventory rows and git_status submodule rows. */
5
+ export const GIT_SUBPROCESS_PARALLELISM = 4;
6
+ let gitPathState = "unknown";
7
+ const GIT_NOT_FOUND_BODY = {
8
+ error: "git_not_found",
9
+ message: "The `git` binary was not found on PATH or failed `git --version`. Install Git and ensure it is available to the MCP server process.",
10
+ };
11
+ export function gateGit() {
12
+ if (gitPathState === "ok") {
13
+ return { ok: true };
14
+ }
15
+ if (gitPathState === "missing") {
16
+ return {
17
+ ok: false,
18
+ body: GIT_NOT_FOUND_BODY,
19
+ };
20
+ }
21
+ const r = spawnSync("git", ["--version"], { encoding: "utf8" });
22
+ if (r.status !== 0) {
23
+ gitPathState = "missing";
24
+ return {
25
+ ok: false,
26
+ body: GIT_NOT_FOUND_BODY,
27
+ };
28
+ }
29
+ gitPathState = "ok";
30
+ return { ok: true };
31
+ }
32
+ // ---------------------------------------------------------------------------
33
+ // Git helpers (sync — used where async batching not needed)
34
+ // ---------------------------------------------------------------------------
35
+ export function gitTopLevel(cwd) {
36
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
37
+ cwd,
38
+ encoding: "utf8",
39
+ });
40
+ if (r.status !== 0)
41
+ return null;
42
+ return r.stdout.trim();
43
+ }
44
+ export function gitRevParseGitDir(cwd) {
45
+ const r = spawnSync("git", ["rev-parse", "--git-dir"], {
46
+ cwd,
47
+ encoding: "utf8",
48
+ });
49
+ return r.status === 0;
50
+ }
51
+ export function gitRevParseHead(cwd) {
52
+ const r = spawnSync("git", ["rev-parse", "HEAD"], {
53
+ cwd,
54
+ encoding: "utf8",
55
+ });
56
+ if (r.status !== 0) {
57
+ return { ok: false, text: (r.stderr || r.stdout || "git rev-parse HEAD failed").trim() };
58
+ }
59
+ return { ok: true, sha: r.stdout.trim(), text: r.stdout.trim() };
60
+ }
61
+ export function parseGitSubmodulePaths(gitRoot) {
62
+ const f = join(gitRoot, ".gitmodules");
63
+ if (!existsSync(f))
64
+ return [];
65
+ const text = readFileSync(f, "utf8");
66
+ const paths = [];
67
+ for (const line of text.split("\n")) {
68
+ const m = /^\s*path\s*=\s*(.+)\s*$/.exec(line);
69
+ if (m?.[1])
70
+ paths.push(m[1].trim());
71
+ }
72
+ return paths;
73
+ }
74
+ export function hasGitMetadata(dir) {
75
+ return existsSync(join(dir, ".git"));
76
+ }
77
+ /** Conservative checks for remote/branch strings passed into git rev-parse / rev-list argv. */
78
+ export function isSafeGitUpstreamToken(s) {
79
+ const t = s.trim();
80
+ if (t.length === 0 || t.length > 256)
81
+ return false;
82
+ if (t.includes(".."))
83
+ return false;
84
+ if (t.startsWith("-"))
85
+ return false;
86
+ if (t.includes("@"))
87
+ return false;
88
+ const forbidden = new Set("{}[]~^:?*\\".split(""));
89
+ for (let i = 0; i < t.length; i++) {
90
+ const ch = t.charAt(i);
91
+ const c = ch.charCodeAt(0);
92
+ if (c < 32 || c === 127)
93
+ return false;
94
+ if (/\s/.test(ch))
95
+ return false;
96
+ if (forbidden.has(ch))
97
+ return false;
98
+ }
99
+ return true;
100
+ }
101
+ // ---------------------------------------------------------------------------
102
+ // Async pool for parallel git (inventory)
103
+ // ---------------------------------------------------------------------------
104
+ export async function asyncPool(items, concurrency, fn) {
105
+ const results = new Array(items.length);
106
+ let next = 0;
107
+ async function worker() {
108
+ for (;;) {
109
+ const i = next++;
110
+ if (i >= items.length)
111
+ break;
112
+ const item = items[i];
113
+ if (item === undefined)
114
+ break;
115
+ results[i] = await fn(item);
116
+ }
117
+ }
118
+ const n = Math.min(Math.max(1, concurrency), Math.max(1, items.length));
119
+ await Promise.all(Array.from({ length: n }, () => worker()));
120
+ return results;
121
+ }
122
+ export function spawnGitAsync(cwd, args) {
123
+ return new Promise((resolveP) => {
124
+ const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
125
+ let stdout = "";
126
+ let stderr = "";
127
+ child.stdout?.setEncoding("utf8");
128
+ child.stderr?.setEncoding("utf8");
129
+ child.stdout?.on("data", (c) => {
130
+ stdout += c;
131
+ });
132
+ child.stderr?.on("data", (c) => {
133
+ stderr += c;
134
+ });
135
+ child.on("error", () => resolveP({ ok: false, stdout, stderr }));
136
+ child.on("close", (code) => resolveP({ ok: code === 0, stdout, stderr }));
137
+ });
138
+ }
139
+ function gitStatusFailText(r) {
140
+ return (r.stderr || r.stdout || "git status failed").trim();
141
+ }
142
+ export async function gitStatusSnapshotAsync(cwd) {
143
+ const [br, sh] = await Promise.all([
144
+ spawnGitAsync(cwd, ["status", "--short", "-b"]),
145
+ spawnGitAsync(cwd, ["status", "--short"]),
146
+ ]);
147
+ return {
148
+ branchOk: br.ok,
149
+ shortOk: sh.ok,
150
+ branchLine: br.ok ? br.stdout.trimEnd() : gitStatusFailText(br),
151
+ shortLine: sh.ok ? sh.stdout.trimEnd() : gitStatusFailText(sh),
152
+ };
153
+ }
154
+ export async function gitStatusShortBranchAsync(cwd) {
155
+ const s = await gitStatusSnapshotAsync(cwd);
156
+ return { ok: s.branchOk, text: s.branchLine };
157
+ }
158
+ export async function fetchAheadBehind(absPath, upstreamSpec) {
159
+ const aheadR = await spawnGitAsync(absPath, ["rev-list", "--count", `${upstreamSpec}..HEAD`]);
160
+ const behindR = await spawnGitAsync(absPath, ["rev-list", "--count", `HEAD..${upstreamSpec}`]);
161
+ return {
162
+ ahead: aheadR.ok ? aheadR.stdout.trim() : null,
163
+ behind: behindR.ok ? behindR.stdout.trim() : null,
164
+ };
165
+ }
@@ -0,0 +1,133 @@
1
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
2
+ import { fetchAheadBehind, gitStatusSnapshotAsync, spawnGitAsync } from "./git.js";
3
+ export const MAX_INVENTORY_ROOTS_DEFAULT = 64;
4
+ export function validateRepoPath(rel, gitTop) {
5
+ const abs = resolvePathForRepo(rel, gitTop);
6
+ return { abs, underTop: assertRelativePathUnderTop(rel, abs, gitTop) };
7
+ }
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
+ };
23
+ }
24
+ export function buildInventorySectionMarkdown(e) {
25
+ 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("");
37
+ }
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}`);
41
+ }
42
+ else {
43
+ lines.push(`upstream: ${e.upstreamNote}`);
44
+ }
45
+ return [`## ${e.label}`, `path: ${e.path}`, "```text", lines.join("\n"), "```", ``];
46
+ }
47
+ function upstreamNoteFor(ref, ahead, behind) {
48
+ return ahead != null && behind != null
49
+ ? `tracking ${ref}`
50
+ : `upstream ${ref} (counts unreadable)`;
51
+ }
52
+ export async function collectInventoryEntry(label, absPath, fixedRemote, fixedBranch) {
53
+ const [snap, headR] = await Promise.all([
54
+ gitStatusSnapshotAsync(absPath),
55
+ spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "HEAD"]),
56
+ ]);
57
+ const branchStatus = snap.branchLine;
58
+ const shortStatus = snap.shortLine;
59
+ const headAbbrev = headR.ok ? headR.stdout.trim() : "";
60
+ 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}`]);
66
+ if (!verify.ok) {
67
+ return {
68
+ label,
69
+ path: absPath,
70
+ branchStatus,
71
+ shortStatus,
72
+ detached,
73
+ headAbbrev: headAbbrev || "(unknown)",
74
+ upstreamMode: "fixed",
75
+ upstreamRef: `${remote}/${branch}`,
76
+ ahead: null,
77
+ behind: null,
78
+ upstreamNote: `(no local ref ${remote}/${branch} or unreadable)`,
79
+ };
80
+ }
81
+ const ref = `${remote}/${branch}`;
82
+ const { ahead, behind } = await fetchAheadBehind(absPath, ref);
83
+ return {
84
+ label,
85
+ path: absPath,
86
+ branchStatus,
87
+ shortStatus,
88
+ detached,
89
+ headAbbrev: headAbbrev || "(unknown)",
90
+ upstreamMode: "fixed",
91
+ upstreamRef: ref,
92
+ ahead,
93
+ behind,
94
+ upstreamNote: upstreamNoteFor(ref, ahead, behind),
95
+ };
96
+ }
97
+ const upVerify = await spawnGitAsync(absPath, ["rev-parse", "--verify", "@{u}"]);
98
+ 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)",
110
+ upstreamMode: "auto",
111
+ upstreamRef: null,
112
+ ahead: null,
113
+ behind: null,
114
+ upstreamNote: note,
115
+ };
116
+ }
117
+ const abbrevR = await spawnGitAsync(absPath, ["rev-parse", "--abbrev-ref", "@{u}"]);
118
+ const upstreamRef = abbrevR.ok ? abbrevR.stdout.trim() : "@{u}";
119
+ const { ahead, behind } = await fetchAheadBehind(absPath, "@{u}");
120
+ return {
121
+ label,
122
+ path: absPath,
123
+ branchStatus,
124
+ shortStatus,
125
+ detached,
126
+ headAbbrev: headAbbrev || "(unknown)",
127
+ upstreamMode: "auto",
128
+ upstreamRef,
129
+ ahead,
130
+ behind,
131
+ upstreamNote: upstreamNoteFor(upstreamRef, ahead, behind),
132
+ };
133
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export const MCP_JSON_FORMAT_VERSION = "1";
5
+ export function readPackageVersion() {
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ const pkgPath = join(here, "..", "..", "package.json");
8
+ try {
9
+ const j = JSON.parse(readFileSync(pkgPath, "utf8"));
10
+ return j.version ?? "0.0.0";
11
+ }
12
+ catch {
13
+ return "0.0.0";
14
+ }
15
+ }
16
+ /** FastMCP types require major.minor.patch; strip prerelease suffixes from package.json. */
17
+ export function readMcpServerVersion() {
18
+ const raw = readPackageVersion().trim();
19
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw);
20
+ if (m?.[1] !== undefined && m[2] !== undefined && m[3] !== undefined) {
21
+ return `${m[1]}.${m[2]}.${m[3]}`;
22
+ }
23
+ return "0.0.0";
24
+ }
25
+ 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);
33
+ }
34
+ /** Spread into an object literal only when `cond` is true; otherwise `{}`. */
35
+ export function spreadWhen(cond, fields) {
36
+ return cond ? fields : {};
37
+ }
38
+ /** Spread `{ [key]: value }` only when `value` is not `undefined`. */
39
+ export function spreadDefined(key, value) {
40
+ return spreadWhen(value !== undefined, { [key]: value });
41
+ }
@@ -0,0 +1,105 @@
1
+ import { join } from "node:path";
2
+ import { gitTopLevel } from "./git.js";
3
+ import { jsonRespond, spreadDefined } from "./json.js";
4
+ import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ export function registerListPresetsTool(server) {
8
+ server.addTool({
9
+ name: "list_presets",
10
+ description: "List named entries from `.rethunk/git-mcp-presets.json` at the git toplevel for the resolved workspace root.",
11
+ parameters: WorkspacePickSchema.pick({
12
+ workspaceRoot: true,
13
+ rootIndex: true,
14
+ allWorkspaceRoots: true,
15
+ format: true,
16
+ }),
17
+ execute: async (args) => {
18
+ const pre = requireGitAndRoots(server, args, undefined);
19
+ if (!pre.ok) {
20
+ return jsonRespond(pre.error);
21
+ }
22
+ const out = [];
23
+ for (const ws of pre.roots) {
24
+ const top = gitTopLevel(ws);
25
+ const presetFile = top ? join(top, PRESET_FILE_PATH) : join(ws, PRESET_FILE_PATH);
26
+ if (!top) {
27
+ out.push({
28
+ workspaceRoot: ws,
29
+ gitTop: null,
30
+ presetFile,
31
+ fileExists: false,
32
+ presets: [],
33
+ error: { error: "not_a_git_repository", path: ws },
34
+ });
35
+ continue;
36
+ }
37
+ const loaded = loadPresetsFromGitTop(top);
38
+ if (!loaded.ok) {
39
+ if (loaded.reason === "missing") {
40
+ out.push({
41
+ workspaceRoot: ws,
42
+ gitTop: top,
43
+ presetFile,
44
+ fileExists: false,
45
+ presets: [],
46
+ });
47
+ }
48
+ else {
49
+ out.push({
50
+ workspaceRoot: ws,
51
+ gitTop: top,
52
+ presetFile,
53
+ fileExists: true,
54
+ presets: [],
55
+ error: presetLoadErrorPayload(top, loaded),
56
+ });
57
+ }
58
+ continue;
59
+ }
60
+ const presets = Object.entries(loaded.data).map(([name, e]) => ({
61
+ name,
62
+ nestedRootsCount: e.nestedRoots?.length ?? 0,
63
+ parityPairsCount: e.parityPairs?.length ?? 0,
64
+ ...spreadDefined("workspaceRootHint", e.workspaceRootHint ? e.workspaceRootHint : undefined),
65
+ }));
66
+ out.push({
67
+ workspaceRoot: ws,
68
+ gitTop: top,
69
+ presetFile,
70
+ fileExists: true,
71
+ ...spreadDefined("presetSchemaVersion", loaded.schemaVersion),
72
+ presets,
73
+ });
74
+ }
75
+ if (args.format === "json") {
76
+ return jsonRespond({ roots: out });
77
+ }
78
+ const lines = ["# Git MCP presets", ""];
79
+ for (const row of out) {
80
+ lines.push(`## ${row.workspaceRoot}`, `git_top: ${row.gitTop ?? "(none)"}`, `preset_file: ${row.presetFile}`, "");
81
+ if (row.error) {
82
+ lines.push("```json", JSON.stringify(row.error, null, 2), "```", "");
83
+ continue;
84
+ }
85
+ if (!row.fileExists) {
86
+ lines.push("(no preset file)", "");
87
+ continue;
88
+ }
89
+ if (row.presets.length === 0) {
90
+ lines.push("(empty preset file)", "");
91
+ continue;
92
+ }
93
+ if (row.presetSchemaVersion !== undefined) {
94
+ lines.push(`preset_schema_version: ${row.presetSchemaVersion}`, "");
95
+ }
96
+ for (const p of row.presets) {
97
+ lines.push(`- **${p.name}**: nestedRoots=${p.nestedRootsCount}, parityPairs=${p.parityPairsCount}` +
98
+ (p.workspaceRootHint ? `, hint=${p.workspaceRootHint}` : ""));
99
+ }
100
+ lines.push("");
101
+ }
102
+ return lines.join("\n");
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,47 @@
1
+ import { join } from "node:path";
2
+ import { gitTopLevel } from "./git.js";
3
+ import { jsonRespond, spreadDefined } from "./json.js";
4
+ import { loadPresetsFromGitTop, PRESET_FILE_PATH, presetLoadErrorPayload } from "./presets.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ export function registerPresetsResource(server) {
7
+ server.addResource({
8
+ uri: "rethunk-git://presets",
9
+ name: "git-mcp-presets",
10
+ mimeType: "application/json",
11
+ async load() {
12
+ const pre = requireGitAndRoots(server, {}, undefined);
13
+ if (!pre.ok) {
14
+ return { text: jsonRespond(pre.error) };
15
+ }
16
+ const ws = pre.roots[0];
17
+ if (!ws) {
18
+ return { text: jsonRespond({ error: "no_workspace_root" }) };
19
+ }
20
+ const top = gitTopLevel(ws);
21
+ if (!top) {
22
+ return { text: jsonRespond({ error: "not_a_git_repository", path: ws }) };
23
+ }
24
+ const loaded = loadPresetsFromGitTop(top);
25
+ if (!loaded.ok) {
26
+ if (loaded.reason === "missing") {
27
+ return {
28
+ text: jsonRespond({
29
+ presetFile: join(top, PRESET_FILE_PATH),
30
+ fileExists: false,
31
+ presets: {},
32
+ }),
33
+ };
34
+ }
35
+ return { text: jsonRespond(presetLoadErrorPayload(top, loaded)) };
36
+ }
37
+ return {
38
+ text: jsonRespond({
39
+ presetFile: join(top, PRESET_FILE_PATH),
40
+ fileExists: true,
41
+ ...spreadDefined("presetSchemaVersion", loaded.schemaVersion),
42
+ presets: loaded.data,
43
+ }),
44
+ };
45
+ },
46
+ });
47
+ }