@rethunk/mcp-multi-root-git 2.0.1 → 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 +9 -6
- package/README.md +1 -1
- 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-log-tool.js +276 -0
- package/dist/server/json.js +1 -2
- package/dist/server/presets.js +4 -4
- package/dist/server/roots.js +5 -5
- package/dist/server/test-harness.js +64 -0
- package/dist/server/tools.js +6 -0
- package/docs/mcp-tools.md +149 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -14,10 +14,10 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
14
14
|
| File | Symbols |
|
|
15
15
|
|------|---------|
|
|
16
16
|
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
|
|
17
|
-
| [`src/server/json.ts`](src/server/json.ts) | `
|
|
17
|
+
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
|
|
18
18
|
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
|
|
19
|
-
| [`src/server/roots.ts`](src/server/roots.ts) | `
|
|
20
|
-
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `
|
|
19
|
+
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots` — sole public export; session root resolution |
|
|
20
|
+
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
|
|
21
21
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
|
|
22
22
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
23
23
|
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
@@ -25,14 +25,17 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
25
25
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
26
26
|
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
27
27
|
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
|
|
28
|
+
| [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` |
|
|
29
|
+
| [`src/server/git-diff-summary-tool.ts`](src/server/git-diff-summary-tool.ts) | `git_diff_summary` — structured token-efficient diff viewer; read-only |
|
|
30
|
+
| [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating |
|
|
28
31
|
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
29
|
-
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop
|
|
32
|
+
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
30
33
|
|
|
31
34
|
## Changing contracts
|
|
32
35
|
|
|
33
36
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
34
|
-
-
|
|
35
|
-
- **Preset file:** keep `
|
|
37
|
+
- **JSON format version** (currently `"2"`, discoverable via MCP `initialize`): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false.
|
|
38
|
+
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
36
39
|
- **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned). Install/client wiring → [docs/install.md](docs/install.md) only. `.cursor/rules/rethunk-git-mcp.mdc` → only when *MCP-vs-shell* guidance changes.
|
|
37
40
|
|
|
38
41
|
## Validate + CI
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
**git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer, and batch commit. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
|
|
8
8
|
|
|
9
9
|
**Repository:** [github.com/Rethunk-AI/mcp-multi-root-git](https://github.com/Rethunk-AI/mcp-multi-root-git) · **npmjs (manual releases):** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) · **GitHub Packages (CI on each tag):** [`@rethunk-ai/mcp-multi-root-git`](https://github.com/Rethunk-AI/mcp-multi-root-git/packages) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
|
|
10
10
|
|
package/dist/repo-paths.js
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
|
+
import { jsonRespond, spreadDefined } from "./json.js";
|
|
5
|
+
import { requireGitAndRoots } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
const CommitEntrySchema = z.object({
|
|
8
|
+
message: z.string().min(1).describe("Commit message."),
|
|
9
|
+
files: z.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
|
|
10
|
+
});
|
|
11
|
+
export function registerBatchCommitTool(server) {
|
|
12
|
+
server.addTool({
|
|
13
|
+
name: "batch_commit",
|
|
14
|
+
description: "Create multiple sequential git commits in a single call. " +
|
|
15
|
+
"Each entry stages the listed files then commits with the given message. " +
|
|
16
|
+
"Stops on first failure. See docs/mcp-tools.md.",
|
|
17
|
+
annotations: {
|
|
18
|
+
readOnlyHint: false,
|
|
19
|
+
destructiveHint: false,
|
|
20
|
+
idempotentHint: false,
|
|
21
|
+
},
|
|
22
|
+
parameters: WorkspacePickSchema.extend({
|
|
23
|
+
commits: z
|
|
24
|
+
.array(CommitEntrySchema)
|
|
25
|
+
.min(1)
|
|
26
|
+
.max(50)
|
|
27
|
+
.describe("Commits to create, applied in order."),
|
|
28
|
+
}),
|
|
29
|
+
execute: async (args) => {
|
|
30
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
31
|
+
if (!pre.ok)
|
|
32
|
+
return jsonRespond(pre.error);
|
|
33
|
+
const rootInput = pre.roots[0];
|
|
34
|
+
if (!rootInput) {
|
|
35
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
36
|
+
}
|
|
37
|
+
const gitTop = gitTopLevel(rootInput);
|
|
38
|
+
if (!gitTop) {
|
|
39
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
40
|
+
}
|
|
41
|
+
const results = [];
|
|
42
|
+
for (let i = 0; i < args.commits.length; i++) {
|
|
43
|
+
const entry = args.commits[i];
|
|
44
|
+
if (!entry)
|
|
45
|
+
break;
|
|
46
|
+
// --- Validate all paths are under the git toplevel ---
|
|
47
|
+
const escapedPaths = [];
|
|
48
|
+
for (const rel of entry.files) {
|
|
49
|
+
const abs = resolvePathForRepo(rel, gitTop);
|
|
50
|
+
if (!isStrictlyUnderGitTop(abs, gitTop)) {
|
|
51
|
+
escapedPaths.push(rel);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (escapedPaths.length > 0) {
|
|
55
|
+
results.push({
|
|
56
|
+
index: i,
|
|
57
|
+
ok: false,
|
|
58
|
+
message: entry.message,
|
|
59
|
+
files: entry.files,
|
|
60
|
+
error: "path_escapes_repository",
|
|
61
|
+
detail: escapedPaths.join(", "),
|
|
62
|
+
});
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
// --- Stage files ---
|
|
66
|
+
const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
|
|
67
|
+
if (!addResult.ok) {
|
|
68
|
+
results.push({
|
|
69
|
+
index: i,
|
|
70
|
+
ok: false,
|
|
71
|
+
message: entry.message,
|
|
72
|
+
files: entry.files,
|
|
73
|
+
error: "stage_failed",
|
|
74
|
+
detail: (addResult.stderr || addResult.stdout).trim(),
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
// --- Commit ---
|
|
79
|
+
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
80
|
+
if (!commitResult.ok) {
|
|
81
|
+
results.push({
|
|
82
|
+
index: i,
|
|
83
|
+
ok: false,
|
|
84
|
+
message: entry.message,
|
|
85
|
+
files: entry.files,
|
|
86
|
+
error: "commit_failed",
|
|
87
|
+
detail: (commitResult.stderr || commitResult.stdout).trim(),
|
|
88
|
+
});
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
// --- Extract SHA from commit output ---
|
|
92
|
+
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
93
|
+
results.push({
|
|
94
|
+
index: i,
|
|
95
|
+
ok: true,
|
|
96
|
+
sha: shaMatch?.[1],
|
|
97
|
+
message: entry.message,
|
|
98
|
+
files: entry.files,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
102
|
+
if (args.format === "json") {
|
|
103
|
+
return jsonRespond({
|
|
104
|
+
ok: allOk,
|
|
105
|
+
committed: results.filter((r) => r.ok).length,
|
|
106
|
+
total: args.commits.length,
|
|
107
|
+
results: results.map((r) => ({
|
|
108
|
+
index: r.index,
|
|
109
|
+
ok: r.ok,
|
|
110
|
+
...spreadDefined("sha", r.sha),
|
|
111
|
+
message: r.message,
|
|
112
|
+
files: r.files,
|
|
113
|
+
...spreadDefined("error", r.error),
|
|
114
|
+
...spreadDefined("detail", r.detail),
|
|
115
|
+
})),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// --- Markdown ---
|
|
119
|
+
const lines = [];
|
|
120
|
+
const header = allOk
|
|
121
|
+
? `# Batch commit: ${results.length}/${args.commits.length} committed`
|
|
122
|
+
: `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
123
|
+
lines.push(header, "");
|
|
124
|
+
for (const r of results) {
|
|
125
|
+
const icon = r.ok ? "✓" : "✗";
|
|
126
|
+
const sha = r.sha ? ` \`${r.sha}\`` : "";
|
|
127
|
+
lines.push(`${icon}${sha} ${r.message}`);
|
|
128
|
+
if (!r.ok && r.detail) {
|
|
129
|
+
lines.push(` Error: ${r.error} — ${r.detail}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!allOk && results.length < args.commits.length) {
|
|
133
|
+
const skipped = args.commits.length - results.length;
|
|
134
|
+
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { matchesGlob } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { gitTopLevel, isSafeGitUpstreamToken, 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 DEFAULT_EXCLUDE_PATTERNS = [
|
|
11
|
+
"*.lock",
|
|
12
|
+
"*.lockb",
|
|
13
|
+
"bun.lock",
|
|
14
|
+
"package-lock.json",
|
|
15
|
+
"yarn.lock",
|
|
16
|
+
"pnpm-lock.yaml",
|
|
17
|
+
"*.min.js",
|
|
18
|
+
"*.min.css",
|
|
19
|
+
"vendor/**",
|
|
20
|
+
"node_modules/**",
|
|
21
|
+
"dist/**",
|
|
22
|
+
];
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Parse `git diff --stat` output into per-file stats.
|
|
28
|
+
* Format: " path/to/file | N ++++---"
|
|
29
|
+
* Final line: " N files changed, N insertions(+), N deletions(-)"
|
|
30
|
+
*/
|
|
31
|
+
function parseStatOutput(stat) {
|
|
32
|
+
const result = new Map();
|
|
33
|
+
for (const line of stat.split("\n")) {
|
|
34
|
+
// Skip the summary line and blank lines
|
|
35
|
+
if (!line.includes("|"))
|
|
36
|
+
continue;
|
|
37
|
+
const pipeIdx = line.indexOf("|");
|
|
38
|
+
const filePart = line.slice(0, pipeIdx).trim();
|
|
39
|
+
const statPart = line.slice(pipeIdx + 1).trim();
|
|
40
|
+
// statPart looks like "5 ++---" or "3 +++", count + and -
|
|
41
|
+
const additions = (statPart.match(/\+/g) ?? []).length;
|
|
42
|
+
const deletions = (statPart.match(/-/g) ?? []).length;
|
|
43
|
+
result.set(filePart, { additions, deletions });
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Parse `git diff` output into per-file chunks.
|
|
49
|
+
* Splits on "diff --git a/..." lines.
|
|
50
|
+
*/
|
|
51
|
+
function parseDiffOutput(diff) {
|
|
52
|
+
const chunks = [];
|
|
53
|
+
// Each file section starts with "diff --git"
|
|
54
|
+
const parts = diff.split(/(?=^diff --git )/m);
|
|
55
|
+
for (const part of parts) {
|
|
56
|
+
if (!part.startsWith("diff --git "))
|
|
57
|
+
continue;
|
|
58
|
+
const firstNewline = part.indexOf("\n");
|
|
59
|
+
const header = firstNewline >= 0 ? part.slice(0, firstNewline) : part;
|
|
60
|
+
const body = firstNewline >= 0 ? part.slice(firstNewline + 1) : "";
|
|
61
|
+
chunks.push({ header, body });
|
|
62
|
+
}
|
|
63
|
+
return chunks;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Extract file paths and status from a diff chunk header + body.
|
|
67
|
+
* Header: "diff --git a/old b/new"
|
|
68
|
+
* Body may contain "rename from", "rename to", "new file mode", "deleted file mode".
|
|
69
|
+
*/
|
|
70
|
+
function extractFileInfo(header, body) {
|
|
71
|
+
// Parse "diff --git a/X b/Y"
|
|
72
|
+
const headerMatch = /^diff --git a\/(.+) b\/(.+)$/.exec(header);
|
|
73
|
+
const aPath = headerMatch?.[1] ?? "";
|
|
74
|
+
const bPath = headerMatch?.[2] ?? aPath;
|
|
75
|
+
let status = "modified";
|
|
76
|
+
let oldPath;
|
|
77
|
+
if (/^new file mode/m.test(body)) {
|
|
78
|
+
status = "added";
|
|
79
|
+
}
|
|
80
|
+
else if (/^deleted file mode/m.test(body)) {
|
|
81
|
+
status = "deleted";
|
|
82
|
+
}
|
|
83
|
+
else if (/^rename from /m.test(body)) {
|
|
84
|
+
status = "renamed";
|
|
85
|
+
const fromMatch = /^rename from (.+)$/m.exec(body);
|
|
86
|
+
oldPath = fromMatch?.[1];
|
|
87
|
+
}
|
|
88
|
+
const path = status === "deleted" ? aPath : bPath;
|
|
89
|
+
return { path, oldPath, status };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Truncate diff body to at most `maxLines` lines (counting only hunk content lines).
|
|
93
|
+
* Returns { text, truncated }.
|
|
94
|
+
*/
|
|
95
|
+
function truncateDiffBody(body, maxLines) {
|
|
96
|
+
const lines = body.split("\n");
|
|
97
|
+
if (lines.length <= maxLines) {
|
|
98
|
+
return { text: body, truncated: false };
|
|
99
|
+
}
|
|
100
|
+
return { text: lines.slice(0, maxLines).join("\n"), truncated: true };
|
|
101
|
+
}
|
|
102
|
+
/** Check whether a file path matches any of the given glob patterns. */
|
|
103
|
+
function matchesAnyPattern(filePath, patterns) {
|
|
104
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
105
|
+
for (const pattern of patterns) {
|
|
106
|
+
if (matchesGlob(normalized, pattern))
|
|
107
|
+
return true;
|
|
108
|
+
// Also match just the basename against simple patterns (e.g. "*.lock")
|
|
109
|
+
const basename = normalized.split("/").at(-1) ?? normalized;
|
|
110
|
+
if (matchesGlob(basename, pattern))
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
/** Build the diff args array from the `range` parameter. */
|
|
116
|
+
function buildDiffArgs(range) {
|
|
117
|
+
if (range === undefined || range === "") {
|
|
118
|
+
return { ok: true, args: [] };
|
|
119
|
+
}
|
|
120
|
+
const normalized = range.trim().toLowerCase();
|
|
121
|
+
if (normalized === "staged" || normalized === "cached") {
|
|
122
|
+
return { ok: true, args: ["--cached"] };
|
|
123
|
+
}
|
|
124
|
+
if (normalized === "head") {
|
|
125
|
+
return { ok: true, args: ["HEAD"] };
|
|
126
|
+
}
|
|
127
|
+
// Range like "A..B", "A...B", or a single ref
|
|
128
|
+
// Split on ".." or "..." separators to validate each token
|
|
129
|
+
const separatorMatch = /^(.+?)(\.{2,3})(.+)$/.exec(range.trim());
|
|
130
|
+
if (separatorMatch) {
|
|
131
|
+
const [, left, sep, right] = separatorMatch;
|
|
132
|
+
if (!isSafeGitUpstreamToken(left ?? "") || !isSafeGitUpstreamToken(right ?? "")) {
|
|
133
|
+
return { ok: false, error: `unsafe_range_token: ${range}` };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, args: [`${left}${sep}${right}`] };
|
|
136
|
+
}
|
|
137
|
+
// Single ref
|
|
138
|
+
if (!isSafeGitUpstreamToken(range.trim())) {
|
|
139
|
+
return { ok: false, error: `unsafe_range_token: ${range}` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, args: [range.trim()] };
|
|
142
|
+
}
|
|
143
|
+
/** Human-readable label for the range. */
|
|
144
|
+
function rangeLabel(range, diffArgs) {
|
|
145
|
+
if (!range || range === "")
|
|
146
|
+
return "unstaged changes";
|
|
147
|
+
if (diffArgs[0] === "--cached")
|
|
148
|
+
return "staged changes";
|
|
149
|
+
return range;
|
|
150
|
+
}
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Tool registration
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
export function registerGitDiffSummaryTool(server) {
|
|
155
|
+
server.addTool({
|
|
156
|
+
name: "git_diff_summary",
|
|
157
|
+
description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
|
|
158
|
+
"truncated to configurable line limits, with noise files (lock files, dist, etc.) excluded by default. " +
|
|
159
|
+
"Use `range` to target staged, HEAD, or any revision range. See docs/mcp-tools.md.",
|
|
160
|
+
annotations: {
|
|
161
|
+
readOnlyHint: true,
|
|
162
|
+
},
|
|
163
|
+
parameters: WorkspacePickSchema.extend({
|
|
164
|
+
range: z
|
|
165
|
+
.string()
|
|
166
|
+
.optional()
|
|
167
|
+
.describe('Diff range. Examples: "staged", "HEAD~3..HEAD", "main...feature". ' +
|
|
168
|
+
"Default: unstaged changes."),
|
|
169
|
+
fileFilter: z
|
|
170
|
+
.string()
|
|
171
|
+
.optional()
|
|
172
|
+
.describe('Glob pattern to restrict output to matching files, e.g. "*.ts", "src/**".'),
|
|
173
|
+
maxLinesPerFile: z
|
|
174
|
+
.number()
|
|
175
|
+
.int()
|
|
176
|
+
.min(1)
|
|
177
|
+
.max(2000)
|
|
178
|
+
.optional()
|
|
179
|
+
.default(50)
|
|
180
|
+
.describe("Max diff lines to include per file. Default: 50."),
|
|
181
|
+
maxFiles: z
|
|
182
|
+
.number()
|
|
183
|
+
.int()
|
|
184
|
+
.min(1)
|
|
185
|
+
.max(500)
|
|
186
|
+
.optional()
|
|
187
|
+
.default(30)
|
|
188
|
+
.describe("Max files to include in output. Default: 30."),
|
|
189
|
+
excludePatterns: z
|
|
190
|
+
.array(z.string())
|
|
191
|
+
.optional()
|
|
192
|
+
.describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
|
|
193
|
+
}),
|
|
194
|
+
execute: async (args) => {
|
|
195
|
+
// --- Standard prelude ---
|
|
196
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
197
|
+
if (!pre.ok)
|
|
198
|
+
return jsonRespond(pre.error);
|
|
199
|
+
const rootInput = pre.roots[0];
|
|
200
|
+
if (!rootInput)
|
|
201
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
202
|
+
const gitTop = gitTopLevel(rootInput);
|
|
203
|
+
if (!gitTop) {
|
|
204
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
205
|
+
}
|
|
206
|
+
// --- Build git diff args ---
|
|
207
|
+
const diffArgsResult = buildDiffArgs(args.range);
|
|
208
|
+
if (!diffArgsResult.ok) {
|
|
209
|
+
return jsonRespond({ error: diffArgsResult.error });
|
|
210
|
+
}
|
|
211
|
+
const diffArgs = diffArgsResult.args;
|
|
212
|
+
// --- Run git diff --stat ---
|
|
213
|
+
const statResult = await spawnGitAsync(gitTop, ["diff", "--stat", ...diffArgs]);
|
|
214
|
+
if (!statResult.ok) {
|
|
215
|
+
return jsonRespond({
|
|
216
|
+
error: "git_diff_failed",
|
|
217
|
+
detail: (statResult.stderr || statResult.stdout).trim(),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const statMap = parseStatOutput(statResult.stdout);
|
|
221
|
+
// --- Run git diff ---
|
|
222
|
+
const diffResult = await spawnGitAsync(gitTop, ["diff", ...diffArgs]);
|
|
223
|
+
if (!diffResult.ok) {
|
|
224
|
+
return jsonRespond({
|
|
225
|
+
error: "git_diff_failed",
|
|
226
|
+
detail: (diffResult.stderr || diffResult.stdout).trim(),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// --- Parse diff chunks ---
|
|
230
|
+
const chunks = parseDiffOutput(diffResult.stdout);
|
|
231
|
+
const totalFiles = chunks.length;
|
|
232
|
+
// --- Apply excludePatterns and fileFilter ---
|
|
233
|
+
const excludePatterns = args.excludePatterns !== undefined ? args.excludePatterns : DEFAULT_EXCLUDE_PATTERNS;
|
|
234
|
+
const excludedFiles = [];
|
|
235
|
+
const includedChunks = [];
|
|
236
|
+
for (const chunk of chunks) {
|
|
237
|
+
const { path: filePath } = extractFileInfo(chunk.header, chunk.body);
|
|
238
|
+
if (matchesAnyPattern(filePath, excludePatterns)) {
|
|
239
|
+
excludedFiles.push(filePath);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (args.fileFilter && !matchesAnyPattern(filePath, [args.fileFilter])) {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
includedChunks.push(chunk);
|
|
246
|
+
}
|
|
247
|
+
// --- Truncate to maxFiles ---
|
|
248
|
+
const maxFiles = args.maxFiles ?? 30;
|
|
249
|
+
const maxLinesPerFile = args.maxLinesPerFile ?? 50;
|
|
250
|
+
const truncatedFileCount = includedChunks.length > maxFiles ? includedChunks.length - maxFiles : 0;
|
|
251
|
+
const processedChunks = includedChunks.slice(0, maxFiles);
|
|
252
|
+
// --- Build FileDiff entries ---
|
|
253
|
+
let totalAdditions = 0;
|
|
254
|
+
let totalDeletions = 0;
|
|
255
|
+
const files = [];
|
|
256
|
+
for (const chunk of processedChunks) {
|
|
257
|
+
const { path: filePath, oldPath, status } = extractFileInfo(chunk.header, chunk.body);
|
|
258
|
+
const stat = statMap.get(filePath) ?? { additions: 0, deletions: 0 };
|
|
259
|
+
totalAdditions += stat.additions;
|
|
260
|
+
totalDeletions += stat.deletions;
|
|
261
|
+
const { text: diffText, truncated } = truncateDiffBody(chunk.body, maxLinesPerFile);
|
|
262
|
+
files.push({
|
|
263
|
+
path: filePath,
|
|
264
|
+
status,
|
|
265
|
+
additions: stat.additions,
|
|
266
|
+
deletions: stat.deletions,
|
|
267
|
+
...spreadDefined("oldPath", oldPath),
|
|
268
|
+
truncated,
|
|
269
|
+
diff: diffText,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const rangeStr = rangeLabel(args.range, diffArgs);
|
|
273
|
+
const summary = {
|
|
274
|
+
range: rangeStr,
|
|
275
|
+
totalFiles,
|
|
276
|
+
totalAdditions,
|
|
277
|
+
totalDeletions,
|
|
278
|
+
files,
|
|
279
|
+
...spreadWhen(truncatedFileCount > 0, { truncatedFiles: truncatedFileCount }),
|
|
280
|
+
...spreadWhen(excludedFiles.length > 0, { excludedFiles }),
|
|
281
|
+
};
|
|
282
|
+
// --- Format output ---
|
|
283
|
+
if (args.format === "json") {
|
|
284
|
+
return jsonRespond(summary);
|
|
285
|
+
}
|
|
286
|
+
// --- Markdown output ---
|
|
287
|
+
const lines = [];
|
|
288
|
+
lines.push(`# Diff: ${rangeStr}`, "");
|
|
289
|
+
// Summary line
|
|
290
|
+
const fileWord = totalFiles === 1 ? "file" : "files";
|
|
291
|
+
let summaryLine = `**${totalFiles} ${fileWord} changed** (+${totalAdditions} \u2212${totalDeletions})`;
|
|
292
|
+
if (excludedFiles.length > 0) {
|
|
293
|
+
const excWord = excludedFiles.length === 1 ? "file" : "files";
|
|
294
|
+
summaryLine += `, ${excludedFiles.length} ${excWord} excluded (${excludedFiles.join(", ")})`;
|
|
295
|
+
}
|
|
296
|
+
if (truncatedFileCount > 0) {
|
|
297
|
+
summaryLine += `, ${truncatedFileCount} more file(s) omitted (maxFiles=${maxFiles})`;
|
|
298
|
+
}
|
|
299
|
+
lines.push(summaryLine, "");
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
// Section header
|
|
302
|
+
const statusTag = file.status !== "modified" ? `, ${file.status}` : "";
|
|
303
|
+
const renameTag = file.oldPath ? ` (from ${file.oldPath})` : "";
|
|
304
|
+
lines.push(`## ${file.path}${renameTag} (+${file.additions} \u2212${file.deletions}${statusTag})`);
|
|
305
|
+
if (file.diff) {
|
|
306
|
+
lines.push("```diff", file.diff.trimEnd(), "```");
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
lines.push("_(no diff content)_");
|
|
310
|
+
}
|
|
311
|
+
if (file.truncated) {
|
|
312
|
+
lines.push(`_(diff truncated at ${maxLinesPerFile} lines)_`);
|
|
313
|
+
}
|
|
314
|
+
lines.push("");
|
|
315
|
+
}
|
|
316
|
+
return lines.join("\n");
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/server/json.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/server/presets.js
CHANGED
|
@@ -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
|
-
|
|
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,7 +96,7 @@ export function presetLoadErrorPayload(gitTop, fail) {
|
|
|
96
96
|
}
|
|
97
97
|
return { error: "preset_file_invalid", presetFile };
|
|
98
98
|
}
|
|
99
|
-
|
|
99
|
+
function getPresetEntry(gitTop, presetName) {
|
|
100
100
|
const loaded = loadPresetsFromGitTop(gitTop);
|
|
101
101
|
if (!loaded.ok) {
|
|
102
102
|
if (loaded.reason === "missing") {
|
|
@@ -124,7 +124,7 @@ export function getPresetEntry(gitTop, presetName) {
|
|
|
124
124
|
}
|
|
125
125
|
return { ok: true, entry, presetSchemaVersion: loaded.schemaVersion };
|
|
126
126
|
}
|
|
127
|
-
|
|
127
|
+
function mergeNestedRoots(preset, inline) {
|
|
128
128
|
const a = preset ?? [];
|
|
129
129
|
const b = inline ?? [];
|
|
130
130
|
if (a.length === 0 && b.length === 0)
|
|
@@ -139,7 +139,7 @@ export function mergeNestedRoots(preset, inline) {
|
|
|
139
139
|
}
|
|
140
140
|
return out;
|
|
141
141
|
}
|
|
142
|
-
|
|
142
|
+
function mergePairs(preset, inline) {
|
|
143
143
|
const a = preset ?? [];
|
|
144
144
|
const b = inline ?? [];
|
|
145
145
|
if (a.length === 0 && b.length === 0)
|
package/dist/server/roots.js
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
-
|
|
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
|
-
|
|
15
|
+
function listFileRoots(server) {
|
|
16
16
|
const sessions = server.sessions;
|
|
17
17
|
const roots = sessions[0]?.roots ?? [];
|
|
18
18
|
const paths = [];
|
|
@@ -24,7 +24,7 @@ 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
|
-
|
|
27
|
+
function pathMatchesWorkspaceRootHint(rootPath, hint) {
|
|
28
28
|
const h = hint.trim();
|
|
29
29
|
if (!h)
|
|
30
30
|
return true;
|
|
@@ -39,7 +39,7 @@ export function pathMatchesWorkspaceRootHint(rootPath, hint) {
|
|
|
39
39
|
return true;
|
|
40
40
|
return basename(rootPath) === h;
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
function resolveWorkspaceRoots(server, args) {
|
|
43
43
|
if (args.workspaceRoot?.trim()) {
|
|
44
44
|
return { ok: true, roots: [resolve(args.workspaceRoot.trim())] };
|
|
45
45
|
}
|
|
@@ -69,7 +69,7 @@ export function resolveWorkspaceRoots(server, args) {
|
|
|
69
69
|
* When a preset name is requested and multiple MCP roots exist, pick the first root
|
|
70
70
|
* whose git toplevel loads a preset file containing that name.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
function resolveRootsForPreset(server, args, presetName) {
|
|
73
73
|
if (args.workspaceRoot?.trim() || args.allWorkspaceRoots || args.rootIndex != null) {
|
|
74
74
|
return resolveWorkspaceRoots(server, args);
|
|
75
75
|
}
|
|
@@ -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
|
+
}
|
package/dist/server/tools.js
CHANGED
|
@@ -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
|
}
|
package/docs/mcp-tools.md
CHANGED
|
@@ -15,6 +15,9 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
|
|
|
15
15
|
| `git_inventory` | `rethunk-git_git_inventory` | Status + ahead/behind per path; default upstream each repo’s `@{u}`; pass **both** `remote` and `branch` for fixed tracking. `nestedRoots`, `preset`, `presetMerge`, `maxRoots`, `format`, plus workspace pick args. |
|
|
16
16
|
| `git_parity` | `rethunk-git_git_parity` | Compare `git rev-parse HEAD` for path pairs. `pairs`, `preset`, `presetMerge`, `format`, plus workspace pick args. |
|
|
17
17
|
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only. |
|
|
18
|
+
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args + `format`. |
|
|
19
|
+
| `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args + `format`. **Read-only.** |
|
|
20
|
+
| `batch_commit` | `rethunk-git_batch_commit` | Create multiple sequential git commits in a single call. Each entry stages the listed files then commits with the given message. Stops on first failure. Args: `commits` (array of `{message, files}`), plus workspace pick args + `format`. **Mutating — not idempotent.** |
|
|
18
21
|
|
|
19
22
|
Pass **`format: "json"`** on any tool for structured JSON instead of markdown (default).
|
|
20
23
|
|
|
@@ -43,6 +46,152 @@ To keep responses compact, **optional fields are omitted when they would be empt
|
|
|
43
46
|
|
|
44
47
|
**When to bump `MCP_JSON_FORMAT_VERSION` or change payload shape:** [AGENTS.md](../AGENTS.md) — *Changing contracts*.
|
|
45
48
|
|
|
49
|
+
### `git_log` — parameters
|
|
50
|
+
|
|
51
|
+
| Parameter | Type | Default | Notes |
|
|
52
|
+
|-----------|------|---------|-------|
|
|
53
|
+
| `since` | string | `"7.days"` | Passed to `git log --since=`. Accepts ISO timestamps (`2026-04-01T00:00:00Z`) or git relative forms (`48.hours`, `2.weeks.ago`). |
|
|
54
|
+
| `paths` | string[] | (all) | Restrict to commits touching these paths (appended after `--`). |
|
|
55
|
+
| `grep` | string | — | Filter by commit message regex (git `--grep`, always case-insensitive). |
|
|
56
|
+
| `author` | string | — | Filter by author name or email (`--author=`). |
|
|
57
|
+
| `maxCommits` | int | `50` | Max commits per root. Hard cap: `500`. |
|
|
58
|
+
| `branch` | string | `HEAD` | Ref/branch to log from. |
|
|
59
|
+
| `workspaceRoot` | string | — | Explicit root; highest priority. |
|
|
60
|
+
| `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
|
|
61
|
+
| `allWorkspaceRoots` | boolean | `false` | Fan out across all MCP roots. |
|
|
62
|
+
| `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
|
|
63
|
+
|
|
64
|
+
### `git_log` — JSON shape (`format: "json"`)
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"groups": [{
|
|
69
|
+
"workspace_root": "/abs/path",
|
|
70
|
+
"repo": "my-repo",
|
|
71
|
+
"branch": "main",
|
|
72
|
+
"commits": [{
|
|
73
|
+
"sha7": "a1bf184",
|
|
74
|
+
"shaFull": "a1bf184c3d...",
|
|
75
|
+
"subject": "feat(satcom): upgrade to PROTOCOL_VERSION 4",
|
|
76
|
+
"author": "Damon Blais",
|
|
77
|
+
"email": "damon@example.com",
|
|
78
|
+
"date": "2026-04-12T18:32:01-07:00",
|
|
79
|
+
"ageRelative": "42m ago",
|
|
80
|
+
"filesChanged": 4,
|
|
81
|
+
"insertions": 16,
|
|
82
|
+
"deletions": 5
|
|
83
|
+
}],
|
|
84
|
+
"truncated": true,
|
|
85
|
+
"omittedCount": 12
|
|
86
|
+
}]
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
v2 field-omission rules: `filesChanged`, `insertions`, `deletions` are omitted when zero/absent (new file with no shortstat). `truncated` and `omittedCount` are omitted when `false`/`0`. A group emits `error` instead of `commits` when git fails for that root.
|
|
91
|
+
|
|
92
|
+
### `git_log` — error codes
|
|
93
|
+
|
|
94
|
+
| Code | Meaning |
|
|
95
|
+
|------|---------|
|
|
96
|
+
| `git_not_found` | `git` binary not on `PATH`. |
|
|
97
|
+
| `not_a_git_repo` | The resolved workspace root is not inside a git repository. |
|
|
98
|
+
| `invalid_since` | The `since` string contains shell metacharacters and was rejected. |
|
|
99
|
+
| `invalid_paths` | One of the `paths` entries contains shell metacharacters and was rejected. |
|
|
100
|
+
| `git_log_failed` | `git log` exited non-zero (e.g. unknown branch ref). |
|
|
101
|
+
| `root_index_out_of_range` | `rootIndex` exceeds the number of MCP file roots. |
|
|
102
|
+
|
|
103
|
+
### `git_diff_summary` — parameters
|
|
104
|
+
|
|
105
|
+
| Parameter | Type | Default | Notes |
|
|
106
|
+
|-----------|------|---------|-------|
|
|
107
|
+
| `range` | string | unstaged | Diff range. `"staged"` / `"cached"` for index; `"HEAD"` for last commit; `"A..B"` or `"A...B"` for revision ranges; single ref. Default: unstaged working-tree changes. |
|
|
108
|
+
| `fileFilter` | string | — | Glob pattern to restrict output to matching files (e.g. `"*.ts"`, `"src/**"`). |
|
|
109
|
+
| `maxLinesPerFile` | int | `50` | Max diff lines to include per file (1–2000). |
|
|
110
|
+
| `maxFiles` | int | `30` | Max files to include in output (1–500). |
|
|
111
|
+
| `excludePatterns` | string[] | lock files, dist, vendor | Glob patterns to exclude. Defaults to `*.lock`, `*.lockb`, `bun.lock`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `*.min.js`, `*.min.css`, `vendor/**`, `node_modules/**`, `dist/**`. Pass an empty array to disable. |
|
|
112
|
+
| `workspaceRoot` | string | — | Explicit root; highest priority. |
|
|
113
|
+
| `rootIndex` | int | — | Pick one of several MCP roots (0-based). |
|
|
114
|
+
| `format` | `"markdown"` \| `"json"` | `"markdown"` | Output format. |
|
|
115
|
+
|
|
116
|
+
### `git_diff_summary` — JSON shape (`format: "json"`)
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"range": "unstaged changes",
|
|
121
|
+
"totalFiles": 2,
|
|
122
|
+
"totalAdditions": 10,
|
|
123
|
+
"totalDeletions": 5,
|
|
124
|
+
"files": [{
|
|
125
|
+
"path": "src/foo.ts",
|
|
126
|
+
"status": "modified",
|
|
127
|
+
"additions": 8,
|
|
128
|
+
"deletions": 3,
|
|
129
|
+
"truncated": false,
|
|
130
|
+
"diff": "@@ -1,3 +1,8 @@\n-const x = 1;\n+const x = 2;"
|
|
131
|
+
}],
|
|
132
|
+
"truncatedFiles": 1,
|
|
133
|
+
"excludedFiles": ["yarn.lock"]
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`status` is one of `"modified"`, `"added"`, `"deleted"`, `"renamed"`. `oldPath` is present only for renamed files. `truncatedFiles` and `excludedFiles` are omitted when zero/empty (v2 field-omission contract).
|
|
138
|
+
|
|
139
|
+
### `git_diff_summary` — error codes
|
|
140
|
+
|
|
141
|
+
| Code | Meaning |
|
|
142
|
+
|------|---------|
|
|
143
|
+
| `git_not_found` | `git` binary not on `PATH`. |
|
|
144
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
145
|
+
| `unsafe_range_token` | The `range` string contains characters outside the safe token set. |
|
|
146
|
+
| `git_diff_failed` | `git diff` exited non-zero. |
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
### `batch_commit` — parameters
|
|
151
|
+
|
|
152
|
+
| Parameter | Type | Notes |
|
|
153
|
+
|-----------|------|-------|
|
|
154
|
+
| `commits` | `{message: string, files: string[]}[]` | Commits to create in order. 1–50 entries. Each `files` entry is a path relative to the git root; all must stay within the git toplevel. |
|
|
155
|
+
| `workspaceRoot` | string | Explicit root; highest priority. |
|
|
156
|
+
| `rootIndex` | int | Pick one of several MCP roots (0-based). |
|
|
157
|
+
| `format` | `"markdown"` \| `"json"` | Output format. Default: `"markdown"`. |
|
|
158
|
+
|
|
159
|
+
### `batch_commit` — JSON shape (`format: "json"`)
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"ok": true,
|
|
164
|
+
"committed": 2,
|
|
165
|
+
"total": 2,
|
|
166
|
+
"results": [{
|
|
167
|
+
"index": 0,
|
|
168
|
+
"ok": true,
|
|
169
|
+
"sha": "a1b2c3d",
|
|
170
|
+
"message": "feat: add foo",
|
|
171
|
+
"files": ["src/foo.ts"]
|
|
172
|
+
}, {
|
|
173
|
+
"index": 1,
|
|
174
|
+
"ok": true,
|
|
175
|
+
"sha": "b2c3d4e",
|
|
176
|
+
"message": "chore: update config",
|
|
177
|
+
"files": ["config.json"]
|
|
178
|
+
}]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
On first failure `ok` is `false`, `committed` reflects only the entries that succeeded before the error, and the failing entry includes `error` and `detail` fields. Remaining entries are skipped and not included in `results`.
|
|
183
|
+
|
|
184
|
+
### `batch_commit` — error codes (per-result `error` field)
|
|
185
|
+
|
|
186
|
+
| Code | Meaning |
|
|
187
|
+
|------|---------|
|
|
188
|
+
| `path_escapes_repository` | One of the listed file paths resolves outside the git toplevel. |
|
|
189
|
+
| `stage_failed` | `git add` failed (e.g. untracked path or permission error). |
|
|
190
|
+
| `commit_failed` | `git commit` failed (e.g. nothing staged, hooks rejected). |
|
|
191
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
46
195
|
## Resource
|
|
47
196
|
|
|
48
197
|
| URI | Purpose |
|
package/package.json
CHANGED