@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.
@@ -8,66 +8,39 @@ import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitInventoryTool(server) {
9
9
  server.addTool({
10
10
  name: "git_inventory",
11
- description: "Read-only push-prep inventory: status + ahead/behind per root. " +
12
- "Uses each repo's configured upstream (`@{u}`) unless both `remote` and `branch` are set. " +
13
- "Presets from `.rethunk/git-mcp-presets.json`; use `presetMerge` to combine with inline paths.",
11
+ description: "Read-only status + ahead/behind per root. See docs/mcp-tools.md.",
14
12
  parameters: WorkspacePickSchema.extend({
15
- nestedRoots: z
16
- .array(z.string())
17
- .optional()
18
- .describe("Paths relative to git toplevel. If empty/omitted, only the repo root is listed. With `presetMerge`, merged with preset paths."),
19
- preset: z
20
- .string()
21
- .optional()
22
- .describe("Named preset from .rethunk/git-mcp-presets.json (at git toplevel)."),
13
+ nestedRoots: z.array(z.string()).optional(),
14
+ preset: z.string().optional(),
23
15
  presetMerge: z
24
16
  .boolean()
25
17
  .optional()
26
18
  .default(false)
27
- .describe("When true, merge `nestedRoots` with preset nestedRoots instead of replacing."),
28
- remote: z
29
- .string()
30
- .optional()
31
- .describe("Fixed upstream remote; must be set together with `branch` to override auto upstream."),
32
- branch: z
33
- .string()
34
- .optional()
35
- .describe("Fixed upstream branch name; must be set together with `remote`."),
36
- maxRoots: z
37
- .number()
38
- .int()
39
- .min(1)
40
- .max(256)
41
- .optional()
42
- .default(MAX_INVENTORY_ROOTS_DEFAULT)
43
- .describe("Max nested roots to process (cap)."),
19
+ .describe("Merge with preset instead of replacing."),
20
+ remote: z.string().optional().describe("Pair with `branch`."),
21
+ branch: z.string().optional().describe("Pair with `remote`."),
22
+ maxRoots: z.number().int().min(1).max(256).optional().default(MAX_INVENTORY_ROOTS_DEFAULT),
44
23
  }),
45
24
  execute: async (args) => {
46
25
  const pre = requireGitAndRoots(server, args, args.preset);
47
26
  if (!pre.ok) {
48
27
  return jsonRespond(pre.error);
49
28
  }
50
- const fixedRemote = args.remote;
51
- const fixedBranch = args.branch;
52
- const hasRemote = fixedRemote !== undefined && fixedRemote.trim() !== "";
53
- const hasBranch = fixedBranch !== undefined && fixedBranch.trim() !== "";
29
+ const rawRemote = args.remote?.trim();
30
+ const rawBranch = args.branch?.trim();
31
+ const hasRemote = rawRemote !== undefined && rawRemote !== "";
32
+ const hasBranch = rawBranch !== undefined && rawBranch !== "";
54
33
  if (hasRemote !== hasBranch) {
55
- return jsonRespond({
56
- error: "remote_branch_mismatch",
57
- message: "Set both `remote` and `branch` for fixed upstream, or omit both for auto `@{u}`.",
58
- });
34
+ return jsonRespond({ error: "remote_branch_mismatch" });
59
35
  }
60
- const useFixed = hasRemote && hasBranch;
61
- if (useFixed) {
62
- const r = String(fixedRemote).trim();
63
- const b = String(fixedBranch).trim();
64
- if (!isSafeGitUpstreamToken(r) || !isSafeGitUpstreamToken(b)) {
65
- return jsonRespond({
66
- error: "invalid_remote_or_branch",
67
- message: "remote and branch must be plain tokens: no whitespace, control characters, `@`, `..`, leading `-`, or git rev metacharacters like `^ : ? * [ ] { } ~ \\`.",
68
- });
36
+ let upstream = { mode: "auto" };
37
+ if (hasRemote && hasBranch && rawRemote && rawBranch) {
38
+ if (!isSafeGitUpstreamToken(rawRemote) || !isSafeGitUpstreamToken(rawBranch)) {
39
+ return jsonRespond({ error: "invalid_remote_or_branch" });
69
40
  }
41
+ upstream = { mode: "fixed", remote: rawRemote, branch: rawBranch };
70
42
  }
43
+ const useFixed = upstream.mode === "fixed";
71
44
  const allJson = [];
72
45
  const mdChunks = [];
73
46
  for (const workspaceRoot of pre.roots) {
@@ -77,31 +50,14 @@ export function registerGitInventoryTool(server) {
77
50
  if (args.format === "json") {
78
51
  allJson.push({
79
52
  workspace_root: workspaceRoot,
80
- upstream: {
81
- mode: useFixed ? "fixed" : "auto",
82
- remote: fixedRemote,
83
- branch: fixedBranch,
84
- },
53
+ ...(upstream.mode === "fixed" ? { upstream } : {}),
85
54
  entries: [
86
- {
87
- label: workspaceRoot,
88
- path: workspaceRoot,
89
- branchStatus: "",
90
- shortStatus: "",
91
- detached: false,
92
- headAbbrev: "",
93
- upstreamMode: useFixed ? "fixed" : "auto",
94
- upstreamRef: null,
95
- ahead: null,
96
- behind: null,
97
- upstreamNote: "",
98
- skipReason: JSON.stringify(err),
99
- },
55
+ makeSkipEntry(workspaceRoot, workspaceRoot, upstream.mode, JSON.stringify(err)),
100
56
  ],
101
57
  });
102
58
  }
103
59
  else {
104
- mdChunks.push(`# Git inventory`, "", jsonRespond(err), "");
60
+ mdChunks.push(`### ${workspaceRoot}\n${jsonRespond(err)}`);
105
61
  }
106
62
  continue;
107
63
  }
@@ -124,31 +80,31 @@ export function registerGitInventoryTool(server) {
124
80
  nestedRootsTruncated = true;
125
81
  }
126
82
  const headerNote = useFixed
127
- ? `remote/branch (fixed): ${fixedRemote}/${fixedBranch}`
128
- : "upstream: per-repo @{u} (configured upstream)";
83
+ ? `upstream (fixed): ${upstream.remote}/${upstream.branch}`
84
+ : "upstream: @{u}";
129
85
  const entries = [];
130
86
  if (nestedRoots?.length) {
131
87
  const jobs = [];
132
88
  for (const rel of nestedRoots) {
133
89
  const { abs, underTop } = validateRepoPath(rel, top);
134
90
  if (!underTop) {
135
- entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(path escapes git toplevel — rejected)"));
91
+ entries.push(makeSkipEntry(rel, abs, upstream.mode, "(path escapes git toplevel — rejected)"));
136
92
  continue;
137
93
  }
138
94
  if (!gitRevParseGitDir(abs)) {
139
- entries.push(makeSkipEntry(rel, abs, useFixed ? "fixed" : "auto", "(not a git work tree — skip)"));
95
+ entries.push(makeSkipEntry(rel, abs, upstream.mode, "(not a git work tree — skip)"));
140
96
  continue;
141
97
  }
142
98
  jobs.push({ label: rel, abs });
143
99
  }
144
- const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined));
100
+ const computed = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, (j) => collectInventoryEntry(j.label, j.abs, upstream.remote, upstream.branch));
145
101
  entries.push(...computed);
146
102
  }
147
103
  else if (!gitRevParseGitDir(top)) {
148
- entries.push(makeSkipEntry(".", top, useFixed ? "fixed" : "auto", "(not a git work tree — unexpected)"));
104
+ entries.push(makeSkipEntry(".", top, upstream.mode, "(not a git work tree — unexpected)"));
149
105
  }
150
106
  else {
151
- const one = await collectInventoryEntry(".", top, useFixed ? fixedRemote : undefined, useFixed ? fixedBranch : undefined);
107
+ const one = await collectInventoryEntry(".", top, upstream.remote, upstream.branch);
152
108
  entries.push(one);
153
109
  }
154
110
  if (args.format === "json") {
@@ -159,22 +115,14 @@ export function registerGitInventoryTool(server) {
159
115
  nestedRootsTruncated: true,
160
116
  nestedRootsOmittedCount,
161
117
  }),
162
- upstream: useFixed
163
- ? { mode: "fixed", remote: fixedRemote, branch: fixedBranch }
164
- : { mode: "auto" },
118
+ ...(upstream.mode === "fixed" ? { upstream } : {}),
165
119
  entries,
166
120
  });
167
121
  }
168
122
  else {
169
- const sections = [
170
- "# Git inventory",
171
- "",
172
- `workspace_root: ${top}`,
173
- headerNote,
174
- "",
175
- ];
123
+ const sections = [`### ${top}`, headerNote];
176
124
  if (nestedRootsTruncated) {
177
- sections.push(`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`, "");
125
+ sections.push(`nested_roots_truncated: ${nestedRootsOmittedCount} path(s) not listed (maxRoots=${maxRoots})`);
178
126
  }
179
127
  for (const e of entries) {
180
128
  sections.push(...buildInventorySectionMarkdown(e));
@@ -185,7 +133,7 @@ export function registerGitInventoryTool(server) {
185
133
  if (args.format === "json") {
186
134
  return jsonRespond({ inventories: allJson });
187
135
  }
188
- return mdChunks.join("\n\n---\n\n");
136
+ return ["# Git inventory", ...mdChunks].join("\n\n");
189
137
  },
190
138
  });
191
139
  }
@@ -0,0 +1,276 @@
1
+ import { basename } from "node:path";
2
+ import { z } from "zod";
3
+ import { asyncPool, GIT_SUBPROCESS_PARALLELISM, gitTopLevel, spawnGitAsync } from "./git.js";
4
+ import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
5
+ import { requireGitAndRoots } from "./roots.js";
6
+ import { WorkspacePickSchema } from "./schemas.js";
7
+ // ---------------------------------------------------------------------------
8
+ // Constants
9
+ // ---------------------------------------------------------------------------
10
+ const MAX_COMMITS_HARD_CAP = 500;
11
+ const DEFAULT_MAX_COMMITS = 50;
12
+ const DEFAULT_SINCE = "7.days";
13
+ // Field separator written by git into stdout — we use git's %x01 (SOH) escape.
14
+ // The format string itself is safe ASCII; git emits the byte.
15
+ const FIELD_SEP_OUT = "\x01"; // what git outputs (SOH)
16
+ const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-START marker
17
+ // git log --pretty tformat: sha7, shaFull, subject, author, email, ISO date, relative date.
18
+ // %x02 is placed at the START of each record (tformat adds \n as terminator after each).
19
+ // Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
20
+ // each structured as: <fields>\x01\n\n <shortstat text>\n
21
+ // Fields are separated by %x01; the trailing \x01 before \n leaves one empty last field (ignored).
22
+ const PRETTY_FORMAT = "%x02%h%x01%H%x01%s%x01%aN%x01%aE%x01%aI%x01%ar%x01";
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+ /**
27
+ * Parse a shortstat line like "3 files changed, 12 insertions(+), 5 deletions(-)"
28
+ * Returns undefined when the line doesn't match (e.g. empty diff).
29
+ */
30
+ function parseShortstat(line) {
31
+ const m = /(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/.exec(line);
32
+ if (!m)
33
+ return undefined;
34
+ return {
35
+ filesChanged: parseInt(m[1] ?? "0", 10),
36
+ insertions: parseInt(m[2] ?? "0", 10),
37
+ deletions: parseInt(m[3] ?? "0", 10),
38
+ };
39
+ }
40
+ /**
41
+ * Fetch the current branch name (or detached HEAD fallback).
42
+ */
43
+ async function gitCurrentBranch(cwd, branchArg) {
44
+ if (branchArg?.trim())
45
+ return branchArg.trim();
46
+ const r = await spawnGitAsync(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
47
+ if (r.ok)
48
+ return r.stdout.trim();
49
+ return "HEAD";
50
+ }
51
+ /**
52
+ * Run git log for a single repo root and return structured data.
53
+ */
54
+ async function runGitLog(opts) {
55
+ const { top, since, paths, grep, author, maxCommits, branch } = opts;
56
+ // Resolve branch first (needed for output metadata).
57
+ const resolvedBranch = await gitCurrentBranch(top, branch);
58
+ // Fetch one extra commit to detect truncation.
59
+ const fetchLimit = maxCommits + 1;
60
+ const logArgs = [
61
+ "log",
62
+ `--pretty=tformat:${PRETTY_FORMAT}`,
63
+ "--shortstat",
64
+ `-n`,
65
+ String(fetchLimit),
66
+ `--since=${since}`,
67
+ ];
68
+ if (branch?.trim()) {
69
+ logArgs.push(branch.trim());
70
+ }
71
+ if (grep?.trim()) {
72
+ logArgs.push(`--grep=${grep.trim()}`, "-i");
73
+ }
74
+ if (author?.trim()) {
75
+ logArgs.push(`--author=${author.trim()}`);
76
+ }
77
+ if (paths.length > 0) {
78
+ logArgs.push("--", ...paths);
79
+ }
80
+ const r = await spawnGitAsync(top, logArgs);
81
+ if (!r.ok) {
82
+ return {
83
+ error: "git_log_failed",
84
+ path: top,
85
+ };
86
+ }
87
+ // Parse output.
88
+ // git log --pretty=tformat:%x02<fields>%x01 --shortstat emits, per commit:
89
+ // \x02<fields separated by SOH>\x01\n\n <shortstat line>\n
90
+ // The \x02 is a record-START marker. Splitting on \x02 gives an empty first chunk
91
+ // followed by one chunk per commit. Each chunk is:
92
+ // <fields>\x01\n\n <shortstat>\n
93
+ const raw = r.stdout;
94
+ const recordChunks = raw.split(RECORD_SEP_OUT).slice(1); // drop leading empty
95
+ const allCommits = [];
96
+ for (const chunk of recordChunks) {
97
+ if (!chunk.trim())
98
+ continue;
99
+ // Fields before the first newline; shortstat (if any) follows blank line.
100
+ const newlineIdx = chunk.indexOf("\n");
101
+ const fieldsPart = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk;
102
+ const statPart = newlineIdx >= 0 ? chunk.slice(newlineIdx + 1) : "";
103
+ const fields = fieldsPart.split(FIELD_SEP_OUT);
104
+ const [sha7, shaFull, subject, authorName, email, date, ageRelative] = fields;
105
+ if (!sha7 || !shaFull)
106
+ continue;
107
+ const stat = parseShortstat(statPart);
108
+ const commit = {
109
+ sha7: sha7.trim(),
110
+ shaFull: shaFull.trim(),
111
+ subject: subject?.trim() ?? "",
112
+ author: authorName?.trim() ?? "",
113
+ email: email?.trim() ?? "",
114
+ date: date?.trim() ?? "",
115
+ ageRelative: ageRelative?.trim() ?? "",
116
+ ...spreadDefined("filesChanged", stat?.filesChanged),
117
+ ...spreadDefined("insertions", stat?.insertions),
118
+ ...spreadDefined("deletions", stat?.deletions),
119
+ };
120
+ allCommits.push(commit);
121
+ }
122
+ const truncated = allCommits.length > maxCommits;
123
+ const commits = truncated ? allCommits.slice(0, maxCommits) : allCommits;
124
+ const omittedCount = truncated ? allCommits.length - maxCommits : 0;
125
+ return {
126
+ workspace_root: top,
127
+ repo: basename(top),
128
+ branch: resolvedBranch,
129
+ commits,
130
+ truncated,
131
+ omittedCount,
132
+ };
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Markdown rendering
136
+ // ---------------------------------------------------------------------------
137
+ function renderLogMarkdown(group, filterSummary) {
138
+ const lines = [];
139
+ lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
140
+ lines.push(`_root: ${group.workspace_root}_`);
141
+ lines.push("");
142
+ if (group.commits.length === 0) {
143
+ lines.push("_(no commits match)_");
144
+ }
145
+ else {
146
+ for (const c of group.commits) {
147
+ lines.push(`- \`${c.sha7}\` ${c.ageRelative} ${c.subject} — ${c.author}`);
148
+ }
149
+ }
150
+ if (group.truncated) {
151
+ lines.push("");
152
+ lines.push(`_(truncated — ${group.omittedCount} more commit(s) not shown; lower \`since\` or \`maxCommits\`)_`);
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Tool registration
158
+ // ---------------------------------------------------------------------------
159
+ export function registerGitLogTool(server) {
160
+ server.addTool({
161
+ name: "git_log",
162
+ description: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
163
+ "Returns structured commit history with author, date, subject, and optional diff stats. " +
164
+ "See docs/mcp-tools.md.",
165
+ annotations: {
166
+ readOnlyHint: true,
167
+ },
168
+ parameters: WorkspacePickSchema.extend({
169
+ since: z
170
+ .string()
171
+ .optional()
172
+ .describe("Passed to `git log --since=`. Accepts ISO timestamps or git relative forms like " +
173
+ "`48.hours` or `2.weeks.ago`. Default: `7.days`."),
174
+ paths: z
175
+ .array(z.string())
176
+ .optional()
177
+ .describe("Limit to commits touching these paths (passed as `-- <paths>`)."),
178
+ grep: z
179
+ .string()
180
+ .optional()
181
+ .describe("Filter commits whose message matches this regex (git `--grep`, case-insensitive)."),
182
+ author: z
183
+ .string()
184
+ .optional()
185
+ .describe("Filter by author name or email (passed as `--author=`)."),
186
+ maxCommits: z
187
+ .number()
188
+ .int()
189
+ .min(1)
190
+ .max(MAX_COMMITS_HARD_CAP)
191
+ .optional()
192
+ .default(DEFAULT_MAX_COMMITS)
193
+ .describe(`Maximum commits to return per root (hard cap ${MAX_COMMITS_HARD_CAP}). Default ${DEFAULT_MAX_COMMITS}.`),
194
+ branch: z.string().optional().describe("Ref/branch to log from. Default: HEAD."),
195
+ }),
196
+ execute: async (args) => {
197
+ const pre = requireGitAndRoots(server, args, undefined);
198
+ if (!pre.ok)
199
+ return jsonRespond(pre.error);
200
+ // Validate `since` — reject obvious injection attempts (newlines, semicolons, shell chars).
201
+ const rawSince = (args.since?.trim() ?? DEFAULT_SINCE) || DEFAULT_SINCE;
202
+ if (/[\n\r;|&`$<>]/.test(rawSince)) {
203
+ return jsonRespond({ error: "invalid_since", since: rawSince });
204
+ }
205
+ // Validate paths — reject anything with null bytes or shell meta.
206
+ // Use charCodeAt(0) === 0 for the null byte to avoid a biome lint on control chars in regex.
207
+ const rawPaths = args.paths ?? [];
208
+ for (const p of rawPaths) {
209
+ if (p.split("").some((c) => c.charCodeAt(0) === 0) || /[\n\r;|&`$<>]/.test(p)) {
210
+ return jsonRespond({ error: "invalid_paths", path: p });
211
+ }
212
+ }
213
+ const maxCommits = Math.min(args.maxCommits ?? DEFAULT_MAX_COMMITS, MAX_COMMITS_HARD_CAP);
214
+ // Fan out across roots.
215
+ const jobs = pre.roots.map((rootInput) => ({ rootInput }));
216
+ const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
217
+ const top = gitTopLevel(rootInput);
218
+ if (!top) {
219
+ return { _error: true, workspace_root: rootInput, error: "not_a_git_repo" };
220
+ }
221
+ const r = await runGitLog({
222
+ top,
223
+ since: rawSince,
224
+ paths: rawPaths,
225
+ grep: args.grep,
226
+ author: args.author,
227
+ maxCommits,
228
+ branch: args.branch,
229
+ });
230
+ if ("error" in r) {
231
+ return { _error: true, workspace_root: rootInput, error: r.error };
232
+ }
233
+ return { _error: false, ...r };
234
+ });
235
+ // Build filter summary string for markdown.
236
+ const filterParts = [`since: ${rawSince}`];
237
+ if (rawPaths.length > 0)
238
+ filterParts.push(`paths: ${rawPaths.join(", ")}`);
239
+ if (args.grep)
240
+ filterParts.push(`grep: ${args.grep}`);
241
+ if (args.author)
242
+ filterParts.push(`author: ${args.author}`);
243
+ const filterSummary = filterParts.join(" | ");
244
+ if (args.format === "json") {
245
+ const groups = results.map((r) => {
246
+ if (r._error) {
247
+ return {
248
+ workspace_root: r.workspace_root,
249
+ repo: basename(r.workspace_root),
250
+ branch: "",
251
+ commits: [],
252
+ ...spreadWhen(true, { error: r.error }),
253
+ };
254
+ }
255
+ const { _error: _e, ...rest } = r;
256
+ return {
257
+ ...rest,
258
+ ...spreadWhen(r.truncated, { truncated: true, omittedCount: r.omittedCount }),
259
+ };
260
+ });
261
+ return jsonRespond({ groups });
262
+ }
263
+ // Markdown
264
+ const mdChunks = ["# Git log"];
265
+ for (const r of results) {
266
+ if (r._error) {
267
+ mdChunks.push(`### ${r.workspace_root}\n_error: ${r.error}_`);
268
+ continue;
269
+ }
270
+ const { _error: _e, ...group } = r;
271
+ mdChunks.push(renderLogMarkdown(group, filterSummary));
272
+ }
273
+ return mdChunks.join("\n\n");
274
+ },
275
+ });
276
+ }
@@ -8,7 +8,7 @@ import { WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitParityTool(server) {
9
9
  server.addTool({
10
10
  name: "git_parity",
11
- description: "Read-only: compare `git rev-parse HEAD` for path pairs. Presets or inline `pairs`; `presetMerge` merges with inline pairs.",
11
+ description: "Read-only HEAD parity for path pairs. See docs/mcp-tools.md.",
12
12
  parameters: WorkspacePickSchema.extend({
13
13
  pairs: z
14
14
  .array(z.object({
@@ -55,10 +55,7 @@ export function registerGitParityTool(server) {
55
55
  parityPresetSchemaVersion = applied.presetSchemaVersion;
56
56
  }
57
57
  if (!pairs?.length) {
58
- return jsonRespond({
59
- error: "no_pairs",
60
- message: "Pass `pairs` directly or a `preset` with parityPairs (or presetMerge).",
61
- });
58
+ return jsonRespond({ error: "no_pairs" });
62
59
  }
63
60
  let allOk = true;
64
61
  const pairResults = [];
@@ -8,14 +8,9 @@ import { WorkspacePickSchema } from "./schemas.js";
8
8
  export function registerGitStatusTool(server) {
9
9
  server.addTool({
10
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`.",
11
+ description: "Read-only `git status --short -b` per root + submodules. See docs/mcp-tools.md.",
13
12
  parameters: WorkspacePickSchema.extend({
14
- includeSubmodules: z
15
- .boolean()
16
- .optional()
17
- .default(true)
18
- .describe("When true (default), include submodule paths listed in .gitmodules."),
13
+ includeSubmodules: z.boolean().optional().default(true),
19
14
  }),
20
15
  execute: async (args) => {
21
16
  const pre = requireGitAndRoots(server, args, undefined);
@@ -69,13 +64,19 @@ export function registerGitStatusTool(server) {
69
64
  if (args.format === "json") {
70
65
  return jsonRespond({ groups });
71
66
  }
72
- const sections = ["# Multi-root git status", ""];
67
+ const sections = [groups.length > 1 ? "# Multi-root git status" : "# Git status"];
73
68
  for (const g of groups) {
74
69
  if (groups.length > 1) {
75
- sections.push(`### MCP root: ${g.mcpRoot}`, "");
70
+ sections.push("", `### MCP root: ${g.mcpRoot}`);
76
71
  }
77
72
  for (const row of g.repos) {
78
- sections.push(`## ${row.label}`, `path: ${row.path}`, "```text", row.statusText || "(empty)", "```", ``);
73
+ const body = row.statusText || "(clean)";
74
+ if (body.includes("\n")) {
75
+ sections.push("", `## ${row.label} — ${row.path}`, "```text", body, "```");
76
+ }
77
+ else {
78
+ sections.push("", `## ${row.label} — ${row.path}`, body);
79
+ }
79
80
  }
80
81
  }
81
82
  return sections.join("\n");
@@ -6,7 +6,6 @@ export const GIT_SUBPROCESS_PARALLELISM = 4;
6
6
  let gitPathState = "unknown";
7
7
  const GIT_NOT_FOUND_BODY = {
8
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
9
  };
11
10
  export function gateGit() {
12
11
  if (gitPathState === "ok") {
@@ -81,22 +80,7 @@ export function isSafeGitUpstreamToken(s) {
81
80
  return false;
82
81
  if (t.includes(".."))
83
82
  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;
83
+ return /^(?!-)[A-Za-z0-9_./+-]+$/.test(t);
100
84
  }
101
85
  // ---------------------------------------------------------------------------
102
86
  // Async pool for parallel git (inventory)
@@ -140,16 +124,11 @@ function gitStatusFailText(r) {
140
124
  return (r.stderr || r.stdout || "git status failed").trim();
141
125
  }
142
126
  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
- };
127
+ const r = await spawnGitAsync(cwd, ["status", "--short", "-b"]);
128
+ if (!r.ok) {
129
+ return { branchOk: false, branchLine: gitStatusFailText(r) };
130
+ }
131
+ return { branchOk: true, branchLine: r.stdout.trimEnd() };
153
132
  }
154
133
  export async function gitStatusShortBranchAsync(cwd) {
155
134
  const s = await gitStatusSnapshotAsync(cwd);