@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 CHANGED
@@ -1,53 +1,59 @@
1
- # AGENTS.md — LLM and developer onboarding
1
+ # AGENTS.md — LLM + dev onboarding
2
2
 
3
- **Scope:** [`@rethunk/mcp-multi-root-git`](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git) is an MCP **stdio** server: entry [`src/server.ts`](src/server.ts) (FastMCP + `registerRethunkGitTools`), supporting modules under [`src/server/`](src/server/), build output [`dist/server.js`](dist/server.js) (see `package.json` `bin` / `exports`; publish ships the full `dist/` tree).
3
+ IDEs injecting this as context: do not re-link from rules.
4
4
 
5
- **Operators and integrators:** **[docs/install.md](docs/install.md)** is the **only** place for prerequisites, how to launch the server, and per-client MCP configuration — do not duplicate that in README, HUMANS, rules, or here. Preset file, dev workflow, CI, and publishing: **[HUMANS.md](HUMANS.md)**.
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
- **Tool ids, client naming, `format` / JSON envelopes, resource URI, workspace root order:** **[docs/mcp-tools.md](docs/mcp-tools.md)**canonical; do not duplicate those tables in this file or HUMANS.
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 / notes |
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) | `MCP_JSON_FORMAT_VERSION`, `jsonRespond()`, `spreadWhen`, `spreadDefined` — every tool JSON body ends with `rethunkGitMcp` |
15
- | [`src/server/git.ts`](src/server/git.ts) | `gateGit()` — lazy `git --version`; `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`; `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`; `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
16
- | [`src/server/roots.ts`](src/server/roots.ts) | `uriToPath`, `listFileRoots`, `pathMatchesWorkspaceRootHint`, `resolveWorkspaceRoots`, `resolveRootsForPreset`, `requireGitAndRoots` — session roots only (client wiring: [docs/install.md](docs/install.md)) |
17
- | [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `splitPresetFileRaw`, `loadPresetsFromGitTop`, `getPresetEntry`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod `PresetEntrySchema` / `PresetFileSchema` must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
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` (uses repo-paths + git) |
20
- | [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — calls per-surface `register*` below |
21
- | [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `registerGitStatusTool` — `git_status` |
22
- | [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `registerGitInventoryTool` — `git_inventory` |
23
- | [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `registerGitParityTool` — `git_parity` |
24
- | [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `registerListPresetsTool` — `list_presets` |
25
- | [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `registerPresetsResource` — `rethunk-git://presets` resource |
26
- | [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop`, `realPathOrSelf` |
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
- - **Documentation layout:** do not add top-of-file **banner** paragraphs (bold blocks such as “Canonical doc for… / link here only”) to `docs/install.md` or other shipped docs. Use normal titles, TOC, and cross-links from README / this file / HUMANS.
31
- - **`rethunkGitMcp.jsonFormatVersion`:** bump **`MCP_JSON_FORMAT_VERSION`** and document the migration in this file and in [docs/mcp-tools.md](docs/mcp-tools.md) when JSON field names or nesting change incompatibly.
32
- - **Preset file:** keep **`splitPresetFileRaw`** + Zod parsing aligned with **`git-mcp-presets.schema.json`**; update the schema when adding keys or shapes.
33
- - **Public tool surface:** if you add/rename tools, update [docs/mcp-tools.md](docs/mcp-tools.md) and [README.md](README.md) if the landing page still mentions tools; update [docs/install.md](docs/install.md) if install or client-specific wiring changes — **never** copy install steps into other docs; update [.cursor/rules/rethunk-git-mcp.mdc](.cursor/rules/rethunk-git-mcp.mdc) only if *when-to-use MCP vs shell* wording must change (that rule links `docs/install.md`, [HUMANS.md](HUMANS.md), and `docs/mcp-tools.md` without duplicating them).
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
- ## Validation and CI
41
+ ## Validate + CI
36
42
 
37
- Local: `bun run build` (`rimraf dist && tsc`), `bun run check` (Biome), `bun run test` (`bun test` for [`src/repo-paths.test.ts`](src/repo-paths.test.ts)). GitHub Actions runs the same after `bun install --frozen-lockfile` on PRs and `main` ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)), then uploads a **prerelease `npm pack` artifact**. Pushes of tag **`v*.*.*`** matching `package.json` `version` run [`.github/workflows/release.yml`](.github/workflows/release.yml) (**GitHub Packages** npm publish under **`@rethunk-ai/mcp-multi-root-git`** + **GitHub Release** with tarball); **npmjs** is manual only — see [HUMANS.md](HUMANS.md) Publishing.
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): run **`bun run setup-hooks`** once per clone (`core.hooksPath` → `.githooks`). **pre-commit** = `check`; **pre-push** = frozen install + build + check. See [HUMANS.md](HUMANS.md) Development.
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 helpers live in [`src/repo-paths.ts`](src/repo-paths.ts); extend tests when changing that logic.
47
+ Path confinement: [`src/repo-paths.ts`](src/repo-paths.ts) extend tests when changing.
42
48
 
43
- ## Repository MCP entry (contributors)
49
+ ## Repo MCP entry (contributors)
44
50
 
45
- Dogfooding from a clone: **[docs/install.md](docs/install.md)** — *From source (this repository)* only.
51
+ Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
46
52
 
47
- This repo may ship **`.cursor/`** config (example MCP entry + **alwaysApply** rule [`.cursor/rules/rethunk-git-mcp.mdc`](.cursor/rules/rethunk-git-mcp.mdc)). The rule covers *when* to call these tools vs shell git and links **`docs/install.md`**, **[HUMANS.md](HUMANS.md)**, and **`docs/mcp-tools.md`** without duplicating their bodies; it does not re-link this file when it is already injected as project context.
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 still mention the GitHub **README** for discovery. Canonical references: **tools / JSON** **[docs/mcp-tools.md](docs/mcp-tools.md)**; **install / client wiring** — **[docs/install.md](docs/install.md)** (link it; do not paste per-client JSON into skills); **preset file** — **[HUMANS.md](HUMANS.md)**.
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
- Use the team’s **conventional commits + batching** skill (or equivalent): small themed commits, why-focused messages, `git add` + `git commit` in one shell invocation per batch.
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
+ [![Release](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
8
+ [![GitHub release](https://img.shields.io/github/v/release/Rethunk-AI/mcp-multi-root-git?logo=github&label=release)](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
9
+ [![npm downloads](https://img.shields.io/npm/dm/%40rethunk%2Fmcp-multi-root-git.svg?label=npm%20downloads)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
10
+ [![GitHub Packages](https://img.shields.io/badge/github%20packages-%40rethunk--ai%2Fmcp--multi--root--git-24292f?logo=github)](https://github.com/Rethunk-AI/mcp-multi-root-git/packages)
11
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933.svg)](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
  [![CI](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/ci.yml)
4
- [![Release](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml/badge.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/actions/workflows/release.yml)
5
- [![GitHub release](https://img.shields.io/github/v/release/Rethunk-AI/mcp-multi-root-git?logo=github&label=release)](https://github.com/Rethunk-AI/mcp-multi-root-git/releases/latest)
6
4
  [![npm version](https://img.shields.io/npm/v/%40rethunk%2Fmcp-multi-root-git.svg)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
7
- [![npm downloads](https://img.shields.io/npm/dm/%40rethunk%2Fmcp-multi-root-git.svg?label=npm%20downloads)](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
8
- [![GitHub Packages](https://img.shields.io/badge/github%20packages-%40rethunk--ai%2Fmcp--multi--root--git-24292f?logo=github)](https://github.com/Rethunk-AI/mcp-multi-root-git/pkgs/npm/mcp-multi-root-git)
9
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
- [![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933.svg)](https://github.com/Rethunk-AI/mcp-multi-root-git/blob/main/package.json)
11
6
 
12
- Read-only **git** tools over MCP (status, multi-root inventory, `HEAD` parity, presets). **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
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/pkgs/npm/mcp-multi-root-git) — see [docs/install.md](docs/install.md) and [HUMANS.md](HUMANS.md) Publishing.
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 (IDEs that inject this file as project context should not re-link it from rules) |
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)**.
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
- export function realPathOrSelf(p) {
3
+ function realPathOrSelf(p) {
4
4
  try {
5
5
  return realpathSync(p);
6
6
  }
@@ -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
+ }