@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
package/AGENTS.md
CHANGED
|
@@ -1,53 +1,59 @@
|
|
|
1
|
-
# AGENTS.md — LLM
|
|
1
|
+
# AGENTS.md — LLM + dev onboarding
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
IDEs injecting this as context: do not re-link from rules.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Package:** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git). MCP **stdio** server. Entry [`src/server.ts`](src/server.ts) → FastMCP + `registerRethunkGitTools`. Build output [`dist/server.js`](dist/server.js) (publish ships full `dist/`).
|
|
6
6
|
|
|
7
|
-
**
|
|
7
|
+
**Canonical docs — do not duplicate:**
|
|
8
|
+
- Install + per-client wiring → [docs/install.md](docs/install.md)
|
|
9
|
+
- Tools, JSON shape, resources, root resolution → [docs/mcp-tools.md](docs/mcp-tools.md)
|
|
10
|
+
- Presets, dev, CI, publish → [HUMANS.md](HUMANS.md)
|
|
8
11
|
|
|
9
12
|
## Implementation map
|
|
10
13
|
|
|
11
|
-
| File | Symbols
|
|
12
|
-
|
|
14
|
+
| File | Symbols |
|
|
15
|
+
|------|---------|
|
|
13
16
|
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
|
|
14
|
-
| [`src/server/json.ts`](src/server/json.ts) | `
|
|
15
|
-
| [`src/server/git.ts`](src/server/git.ts) | `gateGit
|
|
16
|
-
| [`src/server/roots.ts`](src/server/roots.ts) | `
|
|
17
|
-
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `
|
|
17
|
+
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
|
|
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) | `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) |
|
|
18
21
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
|
|
19
|
-
| [`src/server/inventory.ts`](src/server/inventory.ts) | `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry`
|
|
20
|
-
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` —
|
|
21
|
-
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `
|
|
22
|
-
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `
|
|
23
|
-
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `
|
|
24
|
-
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `
|
|
25
|
-
| [`src/server/
|
|
26
|
-
| [`src/
|
|
22
|
+
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
23
|
+
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
24
|
+
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
|
|
25
|
+
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
26
|
+
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
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 |
|
|
31
|
+
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
32
|
+
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
27
33
|
|
|
28
34
|
## Changing contracts
|
|
29
35
|
|
|
30
|
-
- **
|
|
31
|
-
-
|
|
32
|
-
- **Preset file:** keep
|
|
33
|
-
- **Public tool surface:**
|
|
36
|
+
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
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).
|
|
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.
|
|
34
40
|
|
|
35
|
-
##
|
|
41
|
+
## Validate + CI
|
|
36
42
|
|
|
37
|
-
Local: `bun run build`
|
|
43
|
+
Local: `bun run build` | `bun run check` | `bun run test`. CI ([`ci.yml`](.github/workflows/ci.yml)) runs same on PRs + `main` after `bun install --frozen-lockfile`, uploads prerelease `npm pack` artifact. Tag `v*.*.*` matching `package.json` version → [`release.yml`](.github/workflows/release.yml) publishes to GitHub Packages as `@rethunk-ai/mcp-multi-root-git` + cuts GitHub Release. npmjs publish is manual (see [HUMANS.md](HUMANS.md)).
|
|
38
44
|
|
|
39
|
-
Optional [`.githooks/`](.githooks):
|
|
45
|
+
Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-commit=`check`; pre-push=frozen install + build + check.
|
|
40
46
|
|
|
41
|
-
Path confinement
|
|
47
|
+
Path confinement: [`src/repo-paths.ts`](src/repo-paths.ts) — extend tests when changing.
|
|
42
48
|
|
|
43
|
-
##
|
|
49
|
+
## Repo MCP entry (contributors)
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
Repo ships `.cursor/` with alwaysApply rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc) covering MCP-vs-shell usage. Rule does not re-link this file (already injected).
|
|
48
54
|
|
|
49
|
-
User-level skills may
|
|
55
|
+
User-level skills may mention README for discovery. Canonical refs: tools/JSON → [docs/mcp-tools.md](docs/mcp-tools.md); install → [docs/install.md](docs/install.md); presets → [HUMANS.md](HUMANS.md).
|
|
50
56
|
|
|
51
57
|
## Commits
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
Conventional Commits. Small themed commits. Why-focused messages. Stage + commit in one invocation per batch.
|
package/HUMANS.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Read-only MCP git tools for any workspace. **How the server is installed and wired to clients:** **[docs/install.md](docs/install.md)** only (do not restate that material here).
|
|
4
4
|
|
|
5
|
+
## Badges
|
|
6
|
+
|
|
7
|
+
[](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
|
|
8
|
+
[](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
|
|
9
|
+
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
10
|
+
[](https://github.com/Rethunk-AI/mcp-multi-root-git/packages)
|
|
11
|
+
[](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
|
|
12
|
+
|
|
5
13
|
**Implementation map (modules under `src/server/`, entry `src/server.ts`), symbols, and contract bumps** live in **`AGENTS.md`** at the repository root. This guide does not repeat those sections.
|
|
6
14
|
|
|
7
15
|
**Registered tool ids, client naming (`rethunk-git_*`), `format` / JSON envelopes, resource URI, workspace root resolution:** **[docs/mcp-tools.md](docs/mcp-tools.md)** — canonical; not duplicated here.
|
package/README.md
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
# @rethunk/mcp-multi-root-git
|
|
2
2
|
|
|
3
3
|
[](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml)
|
|
4
|
-
[](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
|
|
5
|
-
[](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
|
|
6
4
|
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
7
|
-
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
8
|
-
[](https://github.com/Rethunk-AI/mcp-multi-root-git/pkgs/npm/mcp-multi-root-git)
|
|
9
5
|
[](LICENSE)
|
|
10
|
-
[](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
|
|
11
6
|
|
|
12
|
-
|
|
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.
|
|
13
8
|
|
|
14
|
-
**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/
|
|
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.
|
|
15
10
|
|
|
16
11
|
## Documentation
|
|
17
12
|
|
|
@@ -20,6 +15,6 @@ Read-only **git** tools over MCP (status, multi-root inventory, `HEAD` parity, p
|
|
|
20
15
|
| **[docs/install.md](docs/install.md)** | Single source for prerequisites, running the package, and every supported MCP client (plus from-source and troubleshooting) |
|
|
21
16
|
| **[docs/mcp-tools.md](docs/mcp-tools.md)** | Tool ids, client naming, `format` / JSON, resource URI, workspace root resolution (canonical reference) |
|
|
22
17
|
| **[HUMANS.md](HUMANS.md)** | Preset file, dev commands, CI, publishing |
|
|
23
|
-
| **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI
|
|
18
|
+
| **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI |
|
|
24
19
|
|
|
25
20
|
**Tools at a glance:** see the table in **[docs/mcp-tools.md](docs/mcp-tools.md)**.
|
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
|
+
}
|