@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.
- package/AGENTS.md +37 -31
- package/HUMANS.md +8 -0
- package/README.md +3 -8
- package/dist/repo-paths.js +1 -1
- package/dist/server/batch-commit-tool.js +139 -0
- package/dist/server/git-diff-summary-tool.js +319 -0
- package/dist/server/git-inventory-tool.js +32 -84
- package/dist/server/git-log-tool.js +276 -0
- package/dist/server/git-parity-tool.js +2 -5
- package/dist/server/git-status-tool.js +11 -10
- package/dist/server/git.js +6 -27
- package/dist/server/inventory.js +59 -82
- package/dist/server/json.js +2 -9
- package/dist/server/list-presets-tool.js +1 -1
- package/dist/server/presets.js +10 -9
- package/dist/server/roots.js +12 -26
- package/dist/server/schemas.js +4 -16
- package/dist/server/test-harness.js +64 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +173 -12
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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("
|
|
28
|
-
remote: z
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
51
|
-
const
|
|
52
|
-
const hasRemote =
|
|
53
|
-
const hasBranch =
|
|
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
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
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(
|
|
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
|
-
? `
|
|
128
|
-
: "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,
|
|
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,
|
|
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,
|
|
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,
|
|
104
|
+
entries.push(makeSkipEntry(".", top, upstream.mode, "(not a git work tree — unexpected)"));
|
|
149
105
|
}
|
|
150
106
|
else {
|
|
151
|
-
const one = await collectInventoryEntry(".", top,
|
|
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:
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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");
|
package/dist/server/git.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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);
|