@rethunk/mcp-multi-root-git 2.3.4 → 2.4.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.
Files changed (41) hide show
  1. package/AGENTS.md +29 -3
  2. package/CHANGELOG.md +43 -0
  3. package/HUMANS.md +54 -5
  4. package/README.md +15 -1
  5. package/dist/server/batch-commit-tool.js +244 -19
  6. package/dist/server/git-diff-tool.js +132 -0
  7. package/dist/server/git-fetch-tool.js +131 -0
  8. package/dist/server/git-push-tool.js +8 -1
  9. package/dist/server/git-show-tool.js +148 -0
  10. package/dist/server/git-stash-tool.js +131 -0
  11. package/dist/server/git-tag-tool.js +162 -0
  12. package/dist/server/git.js +18 -2
  13. package/dist/server/roots.js +8 -4
  14. package/dist/server/test-harness.js +68 -5
  15. package/dist/server/tool-parameter-schemas.js +21 -1
  16. package/dist/server/tools.js +11 -0
  17. package/docs/install.md +19 -2
  18. package/docs/mcp-tools.md +214 -5
  19. package/package.json +7 -5
  20. package/schemas/batch_commit.json +125 -0
  21. package/schemas/git_cherry_pick.json +63 -0
  22. package/schemas/git_diff.json +62 -0
  23. package/schemas/git_diff_summary.json +75 -0
  24. package/schemas/git_fetch.json +52 -0
  25. package/schemas/git_inventory.json +74 -0
  26. package/schemas/git_log.json +75 -0
  27. package/schemas/git_merge.json +79 -0
  28. package/schemas/git_parity.json +74 -0
  29. package/schemas/git_push.json +50 -0
  30. package/schemas/git_reset_soft.json +42 -0
  31. package/schemas/git_show.json +39 -0
  32. package/schemas/git_stash_apply.json +44 -0
  33. package/schemas/git_stash_list.json +30 -0
  34. package/schemas/git_status.json +49 -0
  35. package/schemas/git_tag.json +50 -0
  36. package/schemas/git_worktree_add.json +52 -0
  37. package/schemas/git_worktree_list.json +30 -0
  38. package/schemas/git_worktree_remove.json +48 -0
  39. package/schemas/index.json +108 -0
  40. package/schemas/list_presets.json +44 -0
  41. package/tool-parameters.schema.json +317 -4
package/AGENTS.md CHANGED
@@ -9,6 +9,7 @@ IDEs injecting this as context: do not re-link from rules.
9
9
  - Tools, JSON shape, resources, root resolution → [docs/mcp-tools.md](docs/mcp-tools.md)
10
10
  - Dev setup, CI, commit conventions → [CONTRIBUTING.md](CONTRIBUTING.md)
11
11
  - Presets, auth, publish → [HUMANS.md](HUMANS.md)
12
+ - Spec layout for repo planning → [specs/README.md](specs/README.md)
12
13
 
13
14
  ## Implementation map
14
15
 
@@ -29,6 +30,11 @@ IDEs injecting this as context: do not re-link from rules.
29
30
  | [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
30
31
  | [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` — v3 JSON shape: `sha` (full), `workspaceRoot`, no `sha7`/`ageRelative`, optional `email` |
31
32
  | [`src/server/git-diff-summary-tool.ts`](src/server/git-diff-summary-tool.ts) | `git_diff_summary` — structured token-efficient diff viewer; read-only |
33
+ | [`src/server/git-diff-tool.ts`](src/server/git-diff-tool.ts) | `git_diff` — raw scoped diff text; read-only |
34
+ | [`src/server/git-show-tool.ts`](src/server/git-show-tool.ts) | `git_show` — inspect commit message + diff or file content at a ref; read-only |
35
+ | [`src/server/git-stash-tool.ts`](src/server/git-stash-tool.ts) | `git_stash_list`, `git_stash_apply` |
36
+ | [`src/server/git-fetch-tool.ts`](src/server/git-fetch-tool.ts) | `git_fetch` — fetch remote refs without touching the working tree |
37
+ | [`src/server/git-tag-tool.ts`](src/server/git-tag-tool.ts) | `git_tag` — create/delete annotated or lightweight tags |
32
38
  | [`src/server/git-worktree-tool.ts`](src/server/git-worktree-tool.ts) | `git_worktree_list`, `git_worktree_add`, `git_worktree_remove` |
33
39
  | [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating; exports `PushReport`, `runPushAfter` |
34
40
  | [`src/server/git-push-tool.ts`](src/server/git-push-tool.ts) | `git_push` — standalone push with optional upstream tracking |
@@ -36,7 +42,7 @@ IDEs injecting this as context: do not re-link from rules.
36
42
  | [`src/server/git-cherry-pick-tool.ts`](src/server/git-cherry-pick-tool.ts) | `git_cherry_pick` — mutating |
37
43
  | [`src/server/git-reset-soft-tool.ts`](src/server/git-reset-soft-tool.ts) | `git_reset_soft` — soft-reset; mutating |
38
44
  | [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
39
- | [`src/server/tool-parameter-schemas.ts`](src/server/tool-parameter-schemas.ts) | `buildToolParameterSchemaDocument`, `captureToolParameterSchemas`; backs `tool-parameters.schema.json` |
45
+ | [`src/server/tool-parameter-schemas.ts`](src/server/tool-parameter-schemas.ts) | `buildToolParameterSchemaDocument`, `captureToolParameterSchemas`; backs `tool-parameters.schema.json` and published `schemas/*.json` snapshots |
40
46
  | [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
41
47
 
42
48
  ## Changing contracts
@@ -44,7 +50,7 @@ IDEs injecting this as context: do not re-link from rules.
44
50
  - **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
45
51
  - **JSON format version** (currently `"3"`, 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. v3 changes in `git_log`: `sha7` → `sha` (full SHA), `workspace_root` → `workspaceRoot`, `ageRelative` removed, `email` omitted when empty.
46
52
  - **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
47
- - **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.
53
+ - **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned), then regenerate the shipped schema artifacts (`tool-parameters.schema.json`, `schemas/index.json`, `schemas/*.json`). Install/client wiring → [docs/install.md](docs/install.md) only.
48
54
 
49
55
  ## Validate + CI
50
56
 
@@ -54,10 +60,30 @@ Optional [`.githooks/`](.githooks): `bun run setup-hooks` once per clone. pre-co
54
60
 
55
61
  Path confinement: [`src/repo-paths.ts`](src/repo-paths.ts) — extend tests when changing.
56
62
 
63
+ ## AI constraints
64
+
65
+ Rules for LLMs operating in or against this repository.
66
+
67
+ **End-user Git/MCP preference** (status/log/diff/commits): **`~/.claude/CLAUDE.md`** § **Git & GitHub** — same policy when dogfooding this server from a harness.
68
+
69
+ **Mutating tools require workspace-root confirmation** — `git_fetch`, `batch_commit`, `git_push`, `git_merge`, `git_cherry_pick`, `git_reset_soft`, `git_tag`, and `git_stash_apply` operate only on roots confirmed by `requireGitAndRoots` / `requireSingleRepo`. Never pass caller-supplied absolute paths to mutating tools; use `workspaceRoot` or MCP roots.
70
+
71
+ **`batch_commit` atomic staging — single call per logical change** — Do NOT attempt incremental staging across multiple `batch_commit` calls. Each call is self-contained: it stages all files in all entries, commits them sequentially, and the moment the call completes, all commits have landed. Include all related files (for all related commit entries) in a single `batch_commit` call. A call cannot be resumed or extended by a later call — each is an independent transaction. If entry N fails, entries before N remain committed; entries after N are skipped (not rolled back).
72
+
73
+ **`absoluteGitRoots` is read-only** — pass it only on read tools (`git_status`, `git_inventory`, `git_parity`, `git_log`, `git_diff_summary`, `list_presets`). Mutating tools reject this parameter.
74
+
75
+ **Protected branches are enforced by the server** — do not attempt `git_worktree_add` with a branch name matching `main`, `master`, `dev`, `develop`, `stable`, `trunk`, `prod`, `production`, `release*`, or `hotfix*`. The server rejects such calls.
76
+
77
+ **Never force-push** — `git_push` has no force-push mode by design. `git_merge` with `strategy: "ff-only"` will fail cleanly rather than force.
78
+
79
+ **Contract bumps need documentation** — if a JSON output shape changes incompatibly, bump `MCP_JSON_FORMAT_VERSION` in `src/server.ts` and document the migration in both this file and [docs/mcp-tools.md](docs/mcp-tools.md).
80
+
81
+ **Path confinement** — any tool accepting file paths must use `resolvePathForRepo` / `assertRelativePathUnderTop` from [`src/repo-paths.ts`](src/repo-paths.ts) and include escaping-attempt tests.
82
+
57
83
  ## Repo MCP entry (contributors)
58
84
 
59
85
  Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
60
86
 
61
- 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).
87
+ Client Git policy is still **`~/.claude/CLAUDE.md`** § Git & GitHub (see **End-user Git/MCP preference** above).
62
88
 
63
89
  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).
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to `@rethunk/mcp-multi-root-git` are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com); the project uses [Semantic Versioning](https://semver.org).
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [2.4.0] — 2026-05-07
8
+
9
+ New git MCP tools, better `batch_commit` ergonomics, published schema coverage for the full tool surface, and a broad docs/test refresh since `v2.3.4`.
10
+
11
+ ### Added
12
+
13
+ - **New tools:** `git_fetch`, `git_diff`, `git_show`, `git_tag`, `git_stash_list`, and `git_stash_apply`.
14
+ - **`batch_commit` enhancements:** `dryRun: true` preview mode plus hunk-level staging via `{ path, lines: { from, to } }`.
15
+ - **Published per-tool schema artifacts:** `schemas/index.json` plus one JSON Schema file per tool alongside `tool-parameters.schema.json`.
16
+
17
+ ### Changed
18
+
19
+ - **`GIT_SUBPROCESS_PARALLELISM`** is now configurable via environment and clamps to a safe `2×CPU` maximum.
20
+ - **`git_show`**, **`git_fetch`**, and **`git_tag`** now use the standard single-repo workspace pick (`workspaceRoot` / `rootIndex`) and omit multi-root-only parameters.
21
+ - **Build / release tooling** now aligns on Bun `1.3.13`, updated dev dependencies, and the current prerelease tarball flow.
22
+
23
+ ### Fixed
24
+
25
+ - **MCP roots** — workspace root collection now scans active MCP sessions and dedupes `file://` roots instead of relying on a fixed server `cwd`.
26
+ - **`git_push`** and **`batch_commit`** now surface raw git stdout/stderr on failure for easier recovery.
27
+ - **Published schema snapshots** are now complete and in sync with the registered tool surface, including `schema:tools:check`.
28
+ - **`publish:preflight`** now writes temporary coverage output under the platform temp directory instead of a hard-coded `/tmp` path.
29
+
30
+ ### Documentation
31
+
32
+ - **README / HUMANS / AGENTS / CONTRIBUTING / install docs** refreshed for the current tool surface, shipped schema artifacts, and contributor workflow.
33
+ - **`SECURITY.md`** added with repository access, git-operation risk, and disclosure guidance.
34
+ - **`docs/mcp-tools.md`** now documents the full tool surface, `batch_commit` atomic staging semantics, and the shipped schema artifacts.
35
+ - **`TODO.md`** backlog entries now reflect genuine remaining gaps instead of listing already-implemented tools as missing.
36
+ - **`specs/` scaffold** added with standard `active`, `done`, and `parked` layout for repo planning.
37
+ - **CHANGELOG references** for `v2.3.2`–`v2.3.4` were restored.
38
+
39
+ ### Tests
40
+
41
+ - Coverage expanded across `list_presets`, `git_parity`, `git_cherry_pick`, `git_merge`, `git_show`, schema generation, and roots handling.
42
+ - The shared git test harness now reuses repo-init / commit helpers and speeds up fixture setup.
43
+
5
44
  ## [2.3.4] — 2026-04-26
6
45
 
7
46
  Publication-prep patch for the `absoluteGitRoots` line.
@@ -138,6 +177,10 @@ Mutating git operations: merge, cherry-pick, and optional push-after for `batch_
138
177
 
139
178
  - Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
140
179
 
180
+ [2.4.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.4.0
181
+ [2.3.4]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.4
182
+ [2.3.3]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.3
183
+ [2.3.2]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.2
141
184
  [2.3.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.1
142
185
  [2.3.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.0
143
186
  [2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
package/HUMANS.md CHANGED
@@ -55,19 +55,21 @@ If you installed from **GitHub Packages**, use **`./node_modules/@rethunk-ai/mcp
55
55
 
56
56
  ## Tool parameter schema artifact
57
57
 
58
- The package ships **`tool-parameters.schema.json`**, a generated JSON Schema snapshot of every registered tool's parameter schema. Maintainers regenerate it with:
58
+ The package ships **`tool-parameters.schema.json`**, a generated JSON Schema snapshot of every registered tool's parameter schema, plus the published **`schemas/`** directory with **`schemas/index.json`** and one per-tool JSON Schema file. Maintainers regenerate them with:
59
59
 
60
60
  ```bash
61
61
  bun run schema:tools
62
+ bun run schema:individual
62
63
  ```
63
64
 
64
65
  CI and `prepublishOnly` use:
65
66
 
66
67
  ```bash
67
68
  bun run schema:tools:check
69
+ bun run schema:individual
68
70
  ```
69
71
 
70
- This artifact is for inspection, drift checks, and clients that want an offline schema snapshot. Runtime MCP schema discovery remains the source of truth for connected clients.
72
+ These artifacts are for inspection, drift checks, code generation, and clients that want offline schema snapshots. Runtime MCP schema discovery remains the source of truth for connected clients.
71
73
 
72
74
  **Layouts:**
73
75
 
@@ -121,13 +123,60 @@ Example `git_parity` batch using the same pair in each sibling clone:
121
123
 
122
124
  `absoluteGitRoots` is read-only by design. Mutating tools such as **`batch_commit`**, **`git_push`**, **`git_merge`**, and **`git_cherry_pick`** omit it from their schema; use `workspaceRoot` or MCP roots for writes. Full parameter rules and error codes live in **[docs/mcp-tools.md](docs/mcp-tools.md#absoluteGitRoots-sibling-clones)**.
123
125
 
126
+ ## Prerequisites
127
+
128
+ - **Git** on `PATH` (`git --version`). Missing git → all tools return `git_not_found`.
129
+ - **Node.js ≥ 22** (for `npx`) or **Bun** (for `bunx`). See `engines` in `package.json`.
130
+ - No other runtime dependencies; the server is a self-contained MCP stdio process.
131
+
132
+ ## Running the server
133
+
134
+ The server is a **stdio** MCP process — your MCP client starts it. You do not run it directly. Quick start:
135
+
136
+ ```bash
137
+ # npmjs (public, may lag tags)
138
+ npx -y @rethunk/mcp-multi-root-git
139
+
140
+ # GitHub Packages (CI-aligned, every tag)
141
+ npx -y @rethunk-ai/mcp-multi-root-git # requires ~/.npmrc with @rethunk-ai registry token
142
+ ```
143
+
144
+ Add the server to your MCP client config under a stable name (e.g. `rethunk-git`). Full per-client config (Cursor, VS Code, Claude Desktop, Zed): **[docs/install.md](docs/install.md)**.
145
+
146
+ ## Common operations
147
+
148
+ Call tools by their registered id (prefix depends on client config name):
149
+
150
+ | Operation | Tool |
151
+ |-----------|------|
152
+ | Check status across workspace roots | `git_status` |
153
+ | Status + ahead/behind for submodules | `git_inventory` |
154
+ | Compare HEAD between path pairs | `git_parity` |
155
+ | List preset names | `list_presets` |
156
+ | View commit log | `git_log` |
157
+ | View structured diff | `git_diff_summary` |
158
+ | View raw diff text | `git_diff` |
159
+ | Inspect commit content | `git_show` |
160
+ | List stashes | `git_stash_list` |
161
+ | Apply or pop a stash | `git_stash_apply` |
162
+ | Fetch remote refs | `git_fetch` |
163
+ | Create commits | `batch_commit` |
164
+ | Push a branch | `git_push` |
165
+ | Create or delete tags | `git_tag` |
166
+ | Merge branches | `git_merge` |
167
+ | Cherry-pick commits | `git_cherry_pick` |
168
+ | Soft-reset HEAD | `git_reset_soft` |
169
+ | Manage worktrees | `git_worktree_list` / `git_worktree_add` / `git_worktree_remove` |
170
+
171
+ Full parameter tables and JSON shapes: **[docs/mcp-tools.md](docs/mcp-tools.md)**.
172
+
124
173
  ## `git_not_found`
125
174
 
126
- If **`git`** is missing or not runnable, tools and the presets resource respond with **`git_not_found`**. Prerequisites and `PATH`: **[docs/install.md](docs/install.md)**.
175
+ If **`git`** is missing or not runnable, tools and the presets resource respond with **`git_not_found`**. Ensure `git` is on `PATH` in the environment that launches the MCP server.
127
176
 
128
177
  ## Installation
129
178
 
130
- **Package install and MCP clients:** **[docs/install.md](docs/install.md)**.
179
+ **Full install instructions and MCP client wiring:** **[docs/install.md](docs/install.md)**.
131
180
 
132
181
  ## Development
133
182
 
@@ -150,7 +199,7 @@ Before tagging, run the clean-tree preflight from the release commit:
150
199
  bun run publish:preflight
151
200
  ```
152
201
 
153
- It verifies the `package.json` version has a matching `CHANGELOG.md` section, generated tool schemas are current, build/check/coverage pass, and `npm pack --dry-run` includes the expected package files.
202
+ It verifies the `package.json` version has a matching `CHANGELOG.md` section, both schema artifacts are current, build/check/coverage pass, and `npm pack --dry-run` includes the expected package files.
154
203
 
155
204
  ### npmjs (manual) — maintainers only
156
205
 
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
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)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
- **git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer, log, push, worktrees, soft-reset, batch commit, merge, and cherry-pick. **Install and MCP client wiring:** **[docs/install.md](docs/install.md)** only — do not duplicate those steps elsewhere.
7
+ **git** tools over MCP: status, multi-root inventory, `HEAD` parity, presets, logs, structured and raw diff views, commit inspection, fetch, stash, tag, batch commit, push, merge, cherry-pick, worktrees, and soft-reset. **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
 
@@ -14,7 +14,21 @@
14
14
  |-----|----------|
15
15
  | **[docs/install.md](docs/install.md)** | Single source for prerequisites, running the package, and every supported MCP client (plus from-source and troubleshooting) |
16
16
  | **[docs/mcp-tools.md](docs/mcp-tools.md)** | Tool ids, client naming, `format` / JSON, resource URI, workspace root resolution (canonical reference) |
17
+ | **[schemas/index.json](schemas/index.json)** | Published JSON schemas for all MCP tool parameters (JSON Schema draft 2020-12 format) |
17
18
  | **[HUMANS.md](HUMANS.md)** | Preset file, dev commands, CI, publishing |
18
19
  | **[AGENTS.md](AGENTS.md)** | Contributors: implementation map ([`src/server/`](src/server/) + entry [`src/server.ts`](src/server.ts)), contract bumps, CI |
20
+ | **[CONTRIBUTING.md](CONTRIBUTING.md)** | Dev setup, hooks, commit conventions, release checks, and how to add tools |
21
+ | **[SECURITY.md](SECURITY.md)** | Disclosure policy, threat model, and repository safety guidance |
22
+ | **[specs/README.md](specs/README.md)** | Active / done / parked specification layout used for repo planning |
19
23
 
20
24
  **Tools at a glance:** see the table in **[docs/mcp-tools.md](docs/mcp-tools.md)**.
25
+
26
+ ## JSON Schemas
27
+
28
+ All MCP tool parameters are published as JSON Schema (draft 2020-12) documents in the **[`schemas/`](schemas/)** directory. Tools and validators can use these schemas to validate tool input arguments without reading source code.
29
+
30
+ **Schema files:**
31
+ - `schemas/index.json` — Index of all 20 tool schemas with file paths
32
+ - `schemas/{tool_name}.json` — Individual schema for each tool (e.g., `schemas/batch_commit.json`, `schemas/git_status.json`)
33
+
34
+ For programmatic use, read `schemas/index.json` to discover available tools and their schema files.
@@ -5,9 +5,24 @@ import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
5
5
  import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
6
6
  import { requireSingleRepo } from "./roots.js";
7
7
  import { WorkspacePickSchema } from "./schemas.js";
8
+ const FileEntrySchema = z.union([
9
+ z.string().min(1),
10
+ z.object({
11
+ path: z.string().min(1).describe("File path relative to git root."),
12
+ lines: z
13
+ .object({
14
+ from: z.number().int().min(1).describe("Start line number (1-indexed)."),
15
+ to: z.number().int().min(1).describe("End line number (1-indexed, inclusive)."),
16
+ })
17
+ .describe("Line range to stage. Only hunks overlapping [from, to] are staged."),
18
+ }),
19
+ ]);
8
20
  const CommitEntrySchema = z.object({
9
21
  message: z.string().min(1).describe("Commit message."),
10
- files: z.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
22
+ files: z
23
+ .array(FileEntrySchema)
24
+ .min(1)
25
+ .describe("Paths to stage, relative to the git root. Each can be a string path or { path, lines } for hunk-level staging."),
11
26
  });
12
27
  const PushModeSchema = z
13
28
  .enum(["never", "after"])
@@ -16,6 +31,132 @@ const PushModeSchema = z
16
31
  .describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
17
32
  "fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
18
33
  "Enum reserved for future modes such as `force-with-lease`.");
34
+ const DryRunSchema = z
35
+ .boolean()
36
+ .optional()
37
+ .default(false)
38
+ .describe("When true, stage files, collect preview (files staged, commit messages), return preview response without writing commits. " +
39
+ "Unstages any files that were staged for the preview. Response indicates DRY RUN mode.");
40
+ /**
41
+ * Parses a unified diff to extract hunks that overlap with a given line range.
42
+ * Returns a partial patch containing only the overlapping hunks, including header lines.
43
+ * Uses new file line numbers (after @@) to determine overlap.
44
+ */
45
+ function extractOverlappingHunks(diffContent, fromLine, toLine) {
46
+ const lines = diffContent.split("\n");
47
+ // Find file header lines (index, ---, +++)
48
+ const fileHeaderLines = [];
49
+ let firstHunkIdx = -1;
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const line = lines[i];
52
+ if (line === undefined)
53
+ continue;
54
+ if (line.startsWith("@@")) {
55
+ firstHunkIdx = i;
56
+ break;
57
+ }
58
+ fileHeaderLines.push(line);
59
+ }
60
+ if (firstHunkIdx === -1) {
61
+ // No hunks found
62
+ return null;
63
+ }
64
+ const result = [...fileHeaderLines];
65
+ let i = firstHunkIdx;
66
+ while (i < lines.length) {
67
+ const line = lines[i];
68
+ if (line === undefined) {
69
+ i++;
70
+ continue;
71
+ }
72
+ // Match hunk header: @@ -oldStart,oldCount +newStart,newCount @@
73
+ const hunkMatch = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,(\d+))?\s+@@/.exec(line);
74
+ if (hunkMatch) {
75
+ const newStart = Number.parseInt(hunkMatch[1] || "0", 10);
76
+ const newCount = Number.parseInt(hunkMatch[2] || "1", 10);
77
+ const hunkEnd = newStart + newCount - 1;
78
+ // Check if hunk overlaps with requested line range
79
+ const hasOverlap = !(hunkEnd < fromLine || newStart > toLine);
80
+ if (hasOverlap) {
81
+ // Add hunk header
82
+ result.push(line);
83
+ i++;
84
+ // Add hunk content until next hunk or EOF
85
+ while (i < lines.length) {
86
+ const contentLine = lines[i];
87
+ if (contentLine === undefined) {
88
+ i++;
89
+ continue;
90
+ }
91
+ // Stop at next hunk header
92
+ if (contentLine.startsWith("@@")) {
93
+ break;
94
+ }
95
+ result.push(contentLine);
96
+ i++;
97
+ }
98
+ }
99
+ else {
100
+ // Skip hunk
101
+ i++;
102
+ while (i < lines.length) {
103
+ const contentLine = lines[i];
104
+ if (contentLine === undefined) {
105
+ i++;
106
+ continue;
107
+ }
108
+ if (contentLine.startsWith("@@")) {
109
+ break;
110
+ }
111
+ i++;
112
+ }
113
+ }
114
+ }
115
+ else {
116
+ i++;
117
+ }
118
+ }
119
+ return result.length > fileHeaderLines.length ? result.join("\n") : null;
120
+ }
121
+ /**
122
+ * Stages a file with optional line range. If lines are provided, only hunks
123
+ * overlapping the range are staged via a partial patch. Otherwise, stages the whole file.
124
+ */
125
+ async function stageFile(gitTop, filePath, lines) {
126
+ if (!lines) {
127
+ // Simple case: stage the whole file
128
+ const addResult = await spawnGitAsync(gitTop, ["add", "--", filePath]);
129
+ return {
130
+ ok: addResult.ok,
131
+ error: addResult.ok ? undefined : (addResult.stderr || addResult.stdout).trim(),
132
+ };
133
+ }
134
+ // Line range case: extract overlapping hunks and apply patch
135
+ const diffResult = await spawnGitAsync(gitTop, ["diff", filePath]);
136
+ if (!diffResult.ok) {
137
+ return { ok: false, error: (diffResult.stderr || diffResult.stdout).trim() };
138
+ }
139
+ const partialPatch = extractOverlappingHunks(diffResult.stdout, lines.from, lines.to);
140
+ if (!partialPatch) {
141
+ return { ok: false, error: "No hunks found in line range" };
142
+ }
143
+ // Write partial patch to temp file in the git repo and apply it to the index
144
+ const tempPatchFile = `${gitTop}/.git/.mcp-patch-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`;
145
+ const { writeFileSync, unlinkSync } = await import("node:fs");
146
+ writeFileSync(tempPatchFile, partialPatch, "utf8");
147
+ const applyResult = await spawnGitAsync(gitTop, ["apply", "--cached", tempPatchFile]);
148
+ // Clean up temp file
149
+ try {
150
+ unlinkSync(tempPatchFile);
151
+ }
152
+ catch {
153
+ // Ignore cleanup errors
154
+ }
155
+ return {
156
+ ok: applyResult.ok,
157
+ error: applyResult.ok ? undefined : (applyResult.stderr || applyResult.stdout).trim(),
158
+ };
159
+ }
19
160
  /**
20
161
  * After all commits succeed, push the current branch to its upstream.
21
162
  * Commits are already applied at this point — do NOT attempt rollback on push failure.
@@ -39,7 +180,13 @@ export async function runPushAfter(gitTop) {
39
180
  detail: (pushResult.stderr || pushResult.stdout).trim(),
40
181
  };
41
182
  }
42
- return { ok: true, branch, upstream: t.upstream };
183
+ const gitOutput = (pushResult.stdout || pushResult.stderr).trim();
184
+ return {
185
+ ok: true,
186
+ branch,
187
+ upstream: t.upstream,
188
+ ...spreadDefined("output", gitOutput || undefined),
189
+ };
43
190
  }
44
191
  export function registerBatchCommitTool(server) {
45
192
  server.addTool({
@@ -47,7 +194,8 @@ export function registerBatchCommitTool(server) {
47
194
  description: "Create multiple sequential git commits in a single call. " +
48
195
  "Each entry stages the listed files then commits with the given message. " +
49
196
  'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
50
- "to its upstream once all commits succeed.",
197
+ "to its upstream once all commits succeed. " +
198
+ "Optional `dryRun: true` previews what would be staged/committed without writing commits.",
51
199
  annotations: {
52
200
  readOnlyHint: false,
53
201
  destructiveHint: false,
@@ -60,6 +208,7 @@ export function registerBatchCommitTool(server) {
60
208
  .max(50)
61
209
  .describe("Commits to create, applied in order."),
62
210
  push: PushModeSchema,
211
+ dryRun: DryRunSchema,
63
212
  }),
64
213
  execute: async (args) => {
65
214
  const pre = requireSingleRepo(server, args);
@@ -67,16 +216,30 @@ export function registerBatchCommitTool(server) {
67
216
  return jsonRespond(pre.error);
68
217
  const gitTop = pre.gitTop;
69
218
  const results = [];
219
+ const stagedFilesForCleanup = new Set();
70
220
  for (let i = 0; i < args.commits.length; i++) {
71
221
  const entry = args.commits[i];
72
222
  if (!entry)
73
223
  break;
224
+ // Normalize file entries to { path, lines? } format
225
+ const fileEntries = [];
226
+ const filePaths = [];
227
+ for (const fileEntry of entry.files) {
228
+ if (typeof fileEntry === "string") {
229
+ fileEntries.push({ path: fileEntry });
230
+ filePaths.push(fileEntry);
231
+ }
232
+ else {
233
+ fileEntries.push(fileEntry);
234
+ filePaths.push(fileEntry.path);
235
+ }
236
+ }
74
237
  // --- Validate all paths are under the git toplevel ---
75
238
  const escapedPaths = [];
76
- for (const rel of entry.files) {
77
- const abs = resolvePathForRepo(rel, gitTop);
239
+ for (const path of filePaths) {
240
+ const abs = resolvePathForRepo(path, gitTop);
78
241
  if (!isStrictlyUnderGitTop(abs, gitTop)) {
79
- escapedPaths.push(rel);
242
+ escapedPaths.push(path);
80
243
  }
81
244
  }
82
245
  if (escapedPaths.length > 0) {
@@ -84,53 +247,94 @@ export function registerBatchCommitTool(server) {
84
247
  index: i,
85
248
  ok: false,
86
249
  message: entry.message,
87
- files: entry.files,
250
+ files: filePaths,
88
251
  error: "path_escapes_repository",
89
252
  detail: escapedPaths.join(", "),
90
253
  });
91
254
  break;
92
255
  }
93
- // --- Stage files ---
94
- const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
95
- if (!addResult.ok) {
256
+ // --- Stage files (with optional line ranges) ---
257
+ let stagingFailed = false;
258
+ let stagingError = "";
259
+ for (const fileEntry of fileEntries) {
260
+ const stageResult = await stageFile(gitTop, fileEntry.path, fileEntry.lines);
261
+ if (!stageResult.ok) {
262
+ stagingFailed = true;
263
+ stagingError = stageResult.error || "Unknown error";
264
+ break;
265
+ }
266
+ }
267
+ if (stagingFailed) {
96
268
  results.push({
97
269
  index: i,
98
270
  ok: false,
99
271
  message: entry.message,
100
- files: entry.files,
272
+ files: filePaths,
101
273
  error: "stage_failed",
102
- detail: (addResult.stderr || addResult.stdout).trim(),
274
+ detail: stagingError,
275
+ ...spreadDefined("output", stagingError || undefined),
103
276
  });
104
277
  break;
105
278
  }
279
+ // Track staged files for cleanup in dry-run
280
+ if (args.dryRun) {
281
+ for (const path of filePaths) {
282
+ stagedFilesForCleanup.add(path);
283
+ }
284
+ }
285
+ // --- Dry-run mode: collect preview and unstage ---
286
+ if (args.dryRun) {
287
+ // Get diff stat for this staged entry
288
+ const diffStatResult = await spawnGitAsync(gitTop, ["diff", "--staged", "--stat"]);
289
+ const diffStat = diffStatResult.ok ? (diffStatResult.stdout || "").trim() : undefined;
290
+ results.push({
291
+ index: i,
292
+ ok: true,
293
+ message: entry.message,
294
+ files: filePaths,
295
+ staged: filePaths,
296
+ ...spreadDefined("diffStat", diffStat || undefined),
297
+ });
298
+ continue; // Skip actual commit in dry-run mode
299
+ }
106
300
  // --- Commit ---
107
301
  const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
108
302
  if (!commitResult.ok) {
303
+ const gitOutput = (commitResult.stderr || commitResult.stdout).trim();
109
304
  results.push({
110
305
  index: i,
111
306
  ok: false,
112
307
  message: entry.message,
113
- files: entry.files,
308
+ files: filePaths,
114
309
  error: "commit_failed",
115
- detail: (commitResult.stderr || commitResult.stdout).trim(),
310
+ detail: gitOutput,
311
+ ...spreadDefined("output", gitOutput || undefined),
116
312
  });
117
313
  break;
118
314
  }
119
315
  // --- Extract SHA from commit output ---
120
316
  const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
317
+ const gitOutput = (commitResult.stdout || commitResult.stderr).trim();
121
318
  results.push({
122
319
  index: i,
123
320
  ok: true,
124
321
  sha: shaMatch?.[1],
125
322
  message: entry.message,
126
- files: entry.files,
323
+ files: filePaths,
324
+ ...spreadDefined("output", gitOutput || undefined),
127
325
  });
128
326
  }
327
+ // --- In dry-run mode, unstage all files ---
328
+ if (args.dryRun && stagedFilesForCleanup.size > 0) {
329
+ const filesToReset = Array.from(stagedFilesForCleanup);
330
+ await spawnGitAsync(gitTop, ["reset", "HEAD", "--", ...filesToReset]);
331
+ }
129
332
  const allOk = results.length === args.commits.length && results.every((r) => r.ok);
130
- // --- Optional push after all commits succeed ---
131
- const push = allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
333
+ // --- Optional push after all commits succeed (not in dry-run mode) ---
334
+ const push = !args.dryRun && allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
132
335
  if (args.format === "json") {
133
336
  return jsonRespond({
337
+ ...spreadWhen(args.dryRun, { dryRun: true }),
134
338
  ok: allOk,
135
339
  committed: results.filter((r) => r.ok).length,
136
340
  total: args.commits.length,
@@ -140,8 +344,11 @@ export function registerBatchCommitTool(server) {
140
344
  ...spreadDefined("sha", r.sha),
141
345
  message: r.message,
142
346
  files: r.files,
347
+ ...spreadDefined("staged", r.staged),
348
+ ...spreadDefined("diffStat", r.diffStat),
143
349
  ...spreadDefined("error", r.error),
144
350
  ...spreadDefined("detail", r.detail),
351
+ ...spreadDefined("output", r.output),
145
352
  })),
146
353
  ...spreadWhen(push !== undefined, {
147
354
  push: {
@@ -150,15 +357,17 @@ export function registerBatchCommitTool(server) {
150
357
  ...spreadDefined("upstream", push?.upstream),
151
358
  ...spreadDefined("error", push?.error),
152
359
  ...spreadDefined("detail", push?.detail),
360
+ ...spreadDefined("output", push?.output),
153
361
  },
154
362
  }),
155
363
  });
156
364
  }
157
365
  // --- Markdown ---
158
366
  const lines = [];
367
+ const dryRunPrefix = args.dryRun ? "DRY RUN — " : "";
159
368
  const header = allOk
160
- ? `# Batch commit: ${results.length}/${args.commits.length} committed`
161
- : `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
369
+ ? `# Batch commit: ${dryRunPrefix}${results.length}/${args.commits.length} committed`
370
+ : `# Batch commit: ${dryRunPrefix}${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
162
371
  lines.push(header, "");
163
372
  for (const r of results) {
164
373
  const icon = r.ok ? "✓" : "✗";
@@ -167,11 +376,24 @@ export function registerBatchCommitTool(server) {
167
376
  if (!r.ok && r.detail) {
168
377
  lines.push(` Error: ${r.error} — ${r.detail}`);
169
378
  }
379
+ if (args.dryRun && r.staged) {
380
+ lines.push(` Staged: ${r.staged.join(", ")}`);
381
+ }
382
+ if (args.dryRun && r.diffStat) {
383
+ lines.push(` Diff stat:`);
384
+ lines.push(` ${r.diffStat.replace(/\n/g, "\n ")}`);
385
+ }
386
+ if (r.output) {
387
+ lines.push(` Output: ${r.output.replace(/\n/g, "\n ")}`);
388
+ }
170
389
  }
171
390
  if (!allOk && results.length < args.commits.length) {
172
391
  const skipped = args.commits.length - results.length;
173
392
  lines.push("", `${skipped} remaining commit(s) skipped.`);
174
393
  }
394
+ if (args.dryRun) {
395
+ lines.push("", "**DRY RUN — no commits written. All staged files have been unstaged.**");
396
+ }
175
397
  if (push) {
176
398
  lines.push("");
177
399
  if (push.ok) {
@@ -180,6 +402,9 @@ export function registerBatchCommitTool(server) {
180
402
  else {
181
403
  lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
182
404
  }
405
+ if (push.output) {
406
+ lines.push(` Output: ${push.output.replace(/\n/g, "\n ")}`);
407
+ }
183
408
  }
184
409
  return lines.join("\n");
185
410
  },