@rethunk/mcp-multi-root-git 2.4.0 → 2.6.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.
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { spawnGitAsync } from "./git.js";
2
3
  // ---------------------------------------------------------------------------
3
4
  // Merge conflict helpers (shared between git_merge and git_cherry_pick)
@@ -24,17 +25,24 @@ const PROTECTED_EXACT = new Set([
24
25
  "trunk",
25
26
  "prod",
26
27
  "production",
27
- "HEAD",
28
+ "head",
28
29
  ]);
29
30
  const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
30
31
  /** True when a branch name is on the protected list and must not be auto-deleted. */
31
32
  export function isProtectedBranch(name) {
32
- const t = name.trim();
33
- if (t === "")
33
+ // Normalize: trim whitespace, strip leading refs/heads/ prefix, then lowercase.
34
+ const trimmed = name.trim();
35
+ if (trimmed === "")
34
36
  return true;
35
- if (PROTECTED_EXACT.has(t))
37
+ const stripped = trimmed.startsWith("refs/heads/")
38
+ ? trimmed.slice("refs/heads/".length)
39
+ : trimmed;
40
+ const normalized = stripped.toLowerCase();
41
+ if (normalized === "")
36
42
  return true;
37
- return PROTECTED_PATTERN.test(t);
43
+ if (PROTECTED_EXACT.has(normalized))
44
+ return true;
45
+ return PROTECTED_PATTERN.test(normalized);
38
46
  }
39
47
  // ---------------------------------------------------------------------------
40
48
  // Ref/branch name validation (argv-safe subset of git's ref-format rules)
@@ -124,6 +132,77 @@ export async function isFullyMergedInto(cwd, branch, target) {
124
132
  const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
125
133
  return r.ok;
126
134
  }
135
+ /**
136
+ * Returns the git patch-id for a single commit, or undefined on failure.
137
+ * Uses execFileSync to pipe `git diff-tree --patch -r <sha>` into `git patch-id --stable`.
138
+ */
139
+ function commitPatchId(cwd, sha) {
140
+ try {
141
+ const diff = execFileSync("git", ["diff-tree", "--patch", "-r", sha], {
142
+ cwd,
143
+ encoding: "utf8",
144
+ });
145
+ const out = execFileSync("git", ["patch-id", "--stable"], {
146
+ cwd,
147
+ encoding: "utf8",
148
+ input: diff,
149
+ });
150
+ return out.trim().split(" ")[0] || undefined;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ /**
157
+ * Returns true when every commit reachable from `branch` but not from `target` has a
158
+ * patch-equivalent commit already in `target` (content-equivalent merge check for
159
+ * cherry-pick workflows where SHA differs but diff is identical).
160
+ *
161
+ * Falls back to ref-equality (`isFullyMergedInto`) when patch-id comparison fails.
162
+ */
163
+ export async function isContentEquivalentlyMergedInto(cwd, branch, target) {
164
+ if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
165
+ return false;
166
+ // Fast path: ref equality (normal merge).
167
+ const refMerged = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
168
+ if (refMerged.ok)
169
+ return true;
170
+ // Collect commits in branch not reachable from target.
171
+ const srcList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${branch}`, `^${target}`]);
172
+ if (!srcList.ok)
173
+ return false;
174
+ const srcShas = srcList.stdout
175
+ .split("\n")
176
+ .map((l) => l.trim())
177
+ .filter((l) => l.length > 0);
178
+ if (srcShas.length === 0)
179
+ return true; // nothing to check
180
+ // Build patch-id set for target commits since merge-base.
181
+ const base = await spawnGitAsync(cwd, ["merge-base", branch, target]);
182
+ if (!base.ok)
183
+ return false;
184
+ const baseSha = base.stdout.trim();
185
+ const destList = await spawnGitAsync(cwd, ["rev-list", "--no-merges", `${baseSha}..${target}`]);
186
+ if (!destList.ok)
187
+ return false;
188
+ const destShas = destList.stdout
189
+ .split("\n")
190
+ .map((l) => l.trim())
191
+ .filter((l) => l.length > 0);
192
+ const destPatchIds = new Set();
193
+ for (const sha of destShas) {
194
+ const patchId = commitPatchId(cwd, sha);
195
+ if (patchId)
196
+ destPatchIds.add(patchId);
197
+ }
198
+ // Every source commit must have its patch-id present in destination.
199
+ for (const sha of srcShas) {
200
+ const patchId = commitPatchId(cwd, sha);
201
+ if (!patchId || !destPatchIds.has(patchId))
202
+ return false;
203
+ }
204
+ return true;
205
+ }
127
206
  /**
128
207
  * SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
129
208
  * Returns `null` on git failure.
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
+ import { assertRelativePathUnderTop, resolvePathForRepo } from "../repo-paths.js";
2
3
  import { spawnGitAsync } from "./git.js";
4
+ import { isSafeGitAncestorRef } from "./git-refs.js";
3
5
  import { jsonRespond } from "./json.js";
4
6
  import { requireSingleRepo } from "./roots.js";
5
7
  import { WorkspacePickSchema } from "./schemas.js";
@@ -12,7 +14,7 @@ import { WorkspacePickSchema } from "./schemas.js";
12
14
  */
13
15
  async function runGitShow(opts) {
14
16
  const { top, ref, path } = opts;
15
- // Build git show args. Start with --no-patch to get just the commit message.
17
+ // Build git show args. Shows commit message + full diff (or file content when path given).
16
18
  const showArgs = ["show", ref];
17
19
  if (path) {
18
20
  // When path is specified, show that path at the ref without --no-patch
@@ -119,7 +121,10 @@ export function registerGitShowTool(server) {
119
121
  format: true,
120
122
  })
121
123
  .extend({
122
- ref: z.string().describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
124
+ ref: z
125
+ .string()
126
+ .min(1)
127
+ .describe("Commit reference (SHA, branch, tag, or any git rev-spec)."),
123
128
  path: z
124
129
  .string()
125
130
  .optional()
@@ -130,6 +135,15 @@ export function registerGitShowTool(server) {
130
135
  if (!pre.ok)
131
136
  return jsonRespond(pre.error);
132
137
  const top = pre.gitTop;
138
+ if (!isSafeGitAncestorRef(args.ref)) {
139
+ return jsonRespond({ error: "unsafe_ref_token", ref: args.ref });
140
+ }
141
+ if (args.path !== undefined) {
142
+ const resolved = resolvePathForRepo(args.path, top);
143
+ if (!assertRelativePathUnderTop(args.path, resolved, top)) {
144
+ return jsonRespond({ error: "path_escapes_repo", path: args.path });
145
+ }
146
+ }
133
147
  const result = await runGitShow({
134
148
  top,
135
149
  ref: args.ref,
@@ -24,11 +24,8 @@ export function registerGitStashListTool(server) {
24
24
  return jsonRespond(pre.error);
25
25
  const { gitTop } = pre;
26
26
  // List all stashes: git stash list --format='%(refname:short)|%(subject)|%(objectname:short)'
27
- const r = await spawnGitAsync(gitTop, [
28
- "stash",
29
- "list",
30
- "--format=%(refname:short)|%(subject)|%(objectname:short)",
31
- ]);
27
+ // git stash list uses git-log format (%s, %h) not for-each-ref format %(subject).
28
+ const r = await spawnGitAsync(gitTop, ["stash", "list", "--format=%gd|%s|%h"]);
32
29
  if (!r.ok) {
33
30
  // If there are no stashes, git still returns ok=true with empty output
34
31
  // Only treat as error if git itself failed
@@ -42,18 +39,22 @@ export function registerGitStashListTool(server) {
42
39
  .split("\n")
43
40
  .map((l) => l.trim())
44
41
  .filter((l) => l.length > 0);
45
- for (let i = 0; i < lines.length; i++) {
46
- const line = lines[i];
47
- if (line) {
48
- const parts = line.split("|");
49
- if (parts.length >= 3 && parts[1] && parts[2]) {
50
- stashes.push({
51
- index: i,
52
- message: parts[1],
53
- sha: parts[2],
54
- });
55
- }
42
+ for (const line of lines) {
43
+ const parts = line.split("|");
44
+ // parts[0] = stash@{N}, last part = short SHA, middle = message (may contain "|")
45
+ const sha = parts[parts.length - 1];
46
+ const message = parts.slice(1, -1).join("|");
47
+ // Parse the real stash index from the canonical stash@{N} ref in parts[0].
48
+ const indexMatch = parts[0] ? /stash@\{(\d+)\}/.exec(parts[0]) : null;
49
+ if (!indexMatch || parts.length < 3 || !message || !sha) {
50
+ // Malformed line — skip without affecting index tracking.
51
+ continue;
56
52
  }
53
+ stashes.push({
54
+ index: Number(indexMatch[1]),
55
+ message,
56
+ sha,
57
+ });
57
58
  }
58
59
  if (args.format === "json") {
59
60
  return jsonRespond({ stashes });
@@ -72,11 +72,11 @@ export function registerGitTagTool(server) {
72
72
  const gitTop = pre.gitTop;
73
73
  const tag = args.tag.trim();
74
74
  if (!tag) {
75
- return jsonRespond({ error: "tag_empty" });
75
+ return jsonRespond({ error: "empty_tag_name" });
76
76
  }
77
77
  // Validate tag name: no shell metacharacters
78
78
  if (!isSafeGitUpstreamToken(tag)) {
79
- return jsonRespond({ error: "tag_unsafe", tag });
79
+ return jsonRespond({ error: "unsafe_tag_token", tag });
80
80
  }
81
81
  // Handle deletion
82
82
  if (args.delete === true) {
@@ -99,7 +99,7 @@ export function registerGitTagTool(server) {
99
99
  // Determine the ref to tag (default HEAD)
100
100
  const ref = (args.ref ?? "HEAD").trim();
101
101
  if (!isSafeGitUpstreamToken(ref)) {
102
- return jsonRespond({ error: "ref_unsafe", ref });
102
+ return jsonRespond({ error: "unsafe_ref_token", ref });
103
103
  }
104
104
  // Get the SHA of the ref to tag
105
105
  const sha = await getRefSha(gitTop, ref);
@@ -1,5 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
3
3
  import { cpus } from "node:os";
4
4
  import { join } from "node:path";
5
5
  /**
@@ -77,12 +77,40 @@ export function parseGitSubmodulePaths(gitRoot) {
77
77
  const f = join(gitRoot, ".gitmodules");
78
78
  if (!existsSync(f))
79
79
  return [];
80
- const text = readFileSync(f, "utf8");
80
+ // Skip non-regular files (character devices, sockets, etc.) — common in
81
+ // Claude Code sandbox environments where stub device files shadow paths.
82
+ try {
83
+ if (!lstatSync(f).isFile())
84
+ return [];
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ let text;
90
+ try {
91
+ text = readFileSync(f, "utf8");
92
+ }
93
+ catch {
94
+ return [];
95
+ }
81
96
  const paths = [];
82
- for (const line of text.split("\n")) {
83
- const m = /^\s*path\s*=\s*(.+)\s*$/.exec(line);
97
+ let inSubmoduleSection = false;
98
+ for (const rawLine of text.split("\n")) {
99
+ // Strip inline and whole-line comments (; and #)
100
+ const commentIdx = rawLine.search(/\s*[;#]/);
101
+ const line = commentIdx >= 0 ? rawLine.slice(0, commentIdx) : rawLine;
102
+ // Track INI section header
103
+ const sectionMatch = /^\s*\[(.+)\]\s*$/.exec(line);
104
+ if (sectionMatch) {
105
+ inSubmoduleSection = /^submodule\s+"/.test(sectionMatch[1] ?? "");
106
+ continue;
107
+ }
108
+ // Only collect path = lines inside a [submodule "..."] section
109
+ if (!inSubmoduleSection)
110
+ continue;
111
+ const m = /^\s*path\s*=\s*(.+?)\s*$/.exec(line);
84
112
  if (m?.[1])
85
- paths.push(m[1].trim());
113
+ paths.push(m[1]);
86
114
  }
87
115
  return paths;
88
116
  }
package/dist/server.js CHANGED
@@ -2,9 +2,16 @@
2
2
  import { FastMCP } from "fastmcp";
3
3
  import { readMcpServerVersion } from "./server/json.js";
4
4
  import { registerRethunkGitTools } from "./server/tools.js";
5
+ /**
6
+ * JSON payload contract version. Bump on incompatible JSON output changes
7
+ * (renamed/nested/omitted fields). Surfaced via the FastMCP `instructions`
8
+ * field below, so it is discoverable in the MCP `initialize` response.
9
+ */
10
+ export const MCP_JSON_FORMAT_VERSION = "3";
5
11
  const server = new FastMCP({
6
12
  name: "rethunk-git",
7
13
  version: readMcpServerVersion(),
14
+ instructions: `rethunk-git MCP server. JSON payload contract: format version ${MCP_JSON_FORMAT_VERSION} (minified, no envelope; optional fields omitted when empty/null/false).`,
8
15
  roots: { enabled: true },
9
16
  });
10
17
  registerRethunkGitTools(server);