@rethunk/mcp-multi-root-git 2.1.0 → 2.3.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 +10 -8
- package/CHANGELOG.md +95 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +61 -13
- package/dist/server/git-cherry-pick-tool.js +285 -0
- package/dist/server/git-diff-summary-tool.js +5 -12
- package/dist/server/git-inventory-tool.js +4 -1
- package/dist/server/git-log-tool.js +16 -18
- package/dist/server/git-merge-tool.js +378 -0
- package/dist/server/git-parity-tool.js +4 -1
- package/dist/server/git-push-tool.js +109 -0
- package/dist/server/git-refs.js +192 -0
- package/dist/server/git-reset-soft-tool.js +82 -0
- package/dist/server/git-status-tool.js +4 -1
- package/dist/server/git-worktree-tool.js +188 -0
- package/dist/server/list-presets-tool.js +4 -1
- package/dist/server/roots.js +17 -0
- package/dist/server/schemas.js +11 -2
- package/dist/server/test-harness.js +27 -0
- package/dist/server/tools.js +17 -2
- package/docs/mcp-tools.md +261 -10
- package/package.json +5 -4
package/AGENTS.md
CHANGED
|
@@ -16,25 +16,31 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
16
16
|
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
|
|
17
17
|
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
|
|
18
18
|
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
|
|
19
|
-
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots` —
|
|
19
|
+
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots`, `requireSingleRepo` — shared tool preludes; session root resolution |
|
|
20
20
|
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
|
|
21
21
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
|
|
22
22
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
23
|
+
| [`src/server/git-refs.ts`](src/server/git-refs.ts) | `isProtectedBranch`, `isSafeGitRefToken`, `isSafeGitRangeToken`, `isSafeGitAncestorRef`; `getCurrentBranch`, `resolveRef`, `isWorkingTreeClean`, `isFullyMergedInto`, `commitListBetween`; `listWorktrees`, `worktreeForBranch`; `conflictPaths` |
|
|
23
24
|
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
24
25
|
| [`src/server/git-status-tool.ts`](src/server/git-status-tool.ts) | `git_status` |
|
|
25
26
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
26
27
|
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
27
28
|
| [`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-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` — v3 JSON shape: `sha` (full), `workspaceRoot`, no `sha7`/`ageRelative`, optional `email` |
|
|
29
30
|
| [`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/
|
|
31
|
+
| [`src/server/git-worktree-tool.ts`](src/server/git-worktree-tool.ts) | `git_worktree_list`, `git_worktree_add`, `git_worktree_remove` |
|
|
32
|
+
| [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating; exports `PushReport`, `runPushAfter` |
|
|
33
|
+
| [`src/server/git-push-tool.ts`](src/server/git-push-tool.ts) | `git_push` — standalone push with optional upstream tracking |
|
|
34
|
+
| [`src/server/git-merge-tool.ts`](src/server/git-merge-tool.ts) | `git_merge` — mutating |
|
|
35
|
+
| [`src/server/git-cherry-pick-tool.ts`](src/server/git-cherry-pick-tool.ts) | `git_cherry_pick` — mutating |
|
|
36
|
+
| [`src/server/git-reset-soft-tool.ts`](src/server/git-reset-soft-tool.ts) | `git_reset_soft` — soft-reset; mutating |
|
|
31
37
|
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
32
38
|
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
33
39
|
|
|
34
40
|
## Changing contracts
|
|
35
41
|
|
|
36
42
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
37
|
-
- **JSON format version** (currently `"
|
|
43
|
+
- **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.
|
|
38
44
|
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
39
45
|
- **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.
|
|
40
46
|
|
|
@@ -53,7 +59,3 @@ Dogfood from clone: [docs/install.md](docs/install.md) — *From source*.
|
|
|
53
59
|
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).
|
|
54
60
|
|
|
55
61
|
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).
|
|
56
|
-
|
|
57
|
-
## Commits
|
|
58
|
-
|
|
59
|
-
Conventional Commits. Small themed commits. Why-focused messages. Stage + commit in one invocation per batch.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
## [2.3.0] — 2026-04-21
|
|
6
|
+
|
|
7
|
+
Five new tools, a token-efficiency sweep, a targeted breaking change to the `git_log` JSON contract, and test coverage raised from 70% to 89%.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`git_push`** — push the current branch to its configured upstream. Accepts explicit `remote` / `branch` overrides and a `setUpstream: true` flag for first-push (`git push -u`). Refuses on detached HEAD; does not force-push.
|
|
12
|
+
- **`git_worktree_list`** — list all git worktrees (`git worktree list --porcelain`); annotated `readOnlyHint: true`.
|
|
13
|
+
- **`git_worktree_add`** — create a new linked worktree, creating the branch from `baseRef` if it does not yet exist. Refuses on protected branch names (`main`, `master`, `dev`, `release*`, `hotfix*`, …).
|
|
14
|
+
- **`git_worktree_remove`** — remove a registered worktree; refuses to remove the main worktree. Optional `force: true` for dirty trees.
|
|
15
|
+
- **`git_reset_soft`** — soft-reset the current branch to a ref (`HEAD~1`, `HEAD~N`, SHA, branch name). Preserves rewound changes in the staging index — the canonical way to re-split an already-committed chunk. Requires a clean working tree.
|
|
16
|
+
|
|
17
|
+
### Changed — breaking (`git_log` JSON, `MCP_JSON_FORMAT_VERSION` → `"3"`)
|
|
18
|
+
|
|
19
|
+
Consumers using `format: "json"` with `git_log` must update field names. All other tools are unaffected.
|
|
20
|
+
|
|
21
|
+
- `sha7` removed — was always derivable as `sha.slice(0, 7)`.
|
|
22
|
+
- `ageRelative` removed — was human-readable noise in machine output.
|
|
23
|
+
- `workspace_root` renamed to `workspaceRoot` — consistent camelCase with all other fields.
|
|
24
|
+
- `email` is now omitted when empty rather than always present.
|
|
25
|
+
- Error code `not_a_git_repo` corrected to `not_a_git_repository` — consistent with all other tools.
|
|
26
|
+
|
|
27
|
+
### Changed — non-breaking
|
|
28
|
+
|
|
29
|
+
- **Token efficiency:** `readOnlyHint: true` added to `git_status`, `git_inventory`, `git_parity`, and `list_presets`. "See docs/mcp-tools.md" suffix dropped from all 9 tool descriptions; descriptions are now self-contained.
|
|
30
|
+
- **`WorkspacePickSchema`** — `rootIndex` and `allWorkspaceRoots` parameters carry inline descriptions so LLMs can pick them without consulting external docs.
|
|
31
|
+
- **`git_merge`** — protected-branch list in the description collapsed to a single canonical reference (was duplicated inline).
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
|
|
35
|
+
- `requireSingleRepo` helper extracted to `roots.ts`; replaces copy-paste preludes across `batch_commit`, `git_diff_summary`, `git_merge`, `git_cherry_pick`, `git_push`, `git_reset_soft`, and all `git_worktree_*`.
|
|
36
|
+
- `conflictPaths` extracted from `git-merge-tool.ts` to `git-refs.ts`; shared by `git_merge` and `git_cherry_pick`.
|
|
37
|
+
- `inferRemoteFromUpstream` extracted to `git-refs.ts`; shared by `runPushAfter` (`batch_commit`) and `git_push`.
|
|
38
|
+
- `isWorkingTreeClean` used consistently everywhere (was inlined in `git_reset_soft`).
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
|
|
42
|
+
- Coverage: **88.6% lines / 92.8% functions** (up from 69.9% / 71.4%).
|
|
43
|
+
- 262 tests across 15 files (up from 134 across 7 files).
|
|
44
|
+
- New test files: `presets.test.ts`, `inventory.test.ts`, `git-utils.test.ts`, `json.test.ts`, `git-reset-soft-tool.test.ts`, `git-push-tool.test.ts`, `git-worktree-tool.test.ts`, `roots.test.ts`. `git-refs.test.ts` extended with `isSafeGitAncestorRef` cases.
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
|
|
48
|
+
- `docs/mcp-tools.md` — all new tools documented with parameter tables, JSON shapes, and error-code tables.
|
|
49
|
+
- `AGENTS.md` — implementation map updated with all new and refactored modules.
|
|
50
|
+
|
|
51
|
+
## [2.2.0] — 2026-04-17
|
|
52
|
+
|
|
53
|
+
Mutating git operations: merge, cherry-pick, and optional push-after for `batch_commit`.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **`git_merge`** tool — merge one or more source branches into a destination. Default strategy `auto` cascades fast-forward → rebase → merge-commit per source, preferring linear history. Refuses on dirty tree; stops on first conflict with structured path report. Optional `deleteMergedBranches` / `deleteMergedWorktrees` cascade cleanup, always skipping protected names (`main`, `master`, `dev`, `develop`, `stable`, `trunk`, `prod`, `production`, `release*`, `hotfix*`).
|
|
58
|
+
- **`git_cherry_pick`** tool — play commits from one or more sources onto a destination. Sources may be SHAs, `A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). Uses `--empty=drop` so patch-equivalent re-applies add nothing. Refuses on dirty tree; stops on first conflict, aborting cleanly. Same protected-name cleanup flags as `git_merge`.
|
|
59
|
+
- **`batch_commit`** `push: "after"` — push the current branch to its upstream once every commit in the batch lands. Omitted by default; no behavior change for existing callers.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- **Internal** — shared ref/branch helpers extracted to a common module; merge/cherry-pick/batch-commit implementations now reuse the same dirty-tree guards, protected-name checks, and upstream detection.
|
|
64
|
+
- **Tooling** — Biome upgraded to 2.4.11; dev dependency refresh.
|
|
65
|
+
|
|
66
|
+
### Documentation
|
|
67
|
+
|
|
68
|
+
- `docs/mcp-tools.md` — `git_merge` and `git_cherry_pick` sections with parameters, JSON shape, and error codes; `batch_commit` `push: "after"` option documented.
|
|
69
|
+
|
|
70
|
+
## [2.1.0] — prior
|
|
71
|
+
|
|
72
|
+
- Added `git_log` — path-filtered, time-windowed log across workspace roots.
|
|
73
|
+
- Added `git_diff_summary` — structured, token-efficient diff viewer with per-file truncation and default exclusions (lock files, `dist`, vendor).
|
|
74
|
+
- Added `batch_commit` — sequential multi-commit tool. Mutating, not idempotent.
|
|
75
|
+
- Test harness: fake-server duck-type lets unit tests drive the full FastMCP execute path without a live transport.
|
|
76
|
+
|
|
77
|
+
## [2.0.1] — prior
|
|
78
|
+
|
|
79
|
+
- Bug fixes and internal cleanup; no public tool-surface change.
|
|
80
|
+
|
|
81
|
+
## [2.0.0] — prior
|
|
82
|
+
|
|
83
|
+
- **Breaking:** JSON response envelope removed. `MCP_JSON_FORMAT_VERSION` now `"2"`; payloads are minified, and optional fields are omitted when empty / `null` / `false` (consumers test for presence, not equality to `null`).
|
|
84
|
+
- Initial preset file schema (`.rethunk/git-mcp-presets.json`) with wrapped and legacy-map layouts.
|
|
85
|
+
|
|
86
|
+
## [1.0.0] — prior
|
|
87
|
+
|
|
88
|
+
- Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
|
|
89
|
+
|
|
90
|
+
[2.3.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.3.0
|
|
91
|
+
[2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
|
|
92
|
+
[2.1.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.1.0
|
|
93
|
+
[2.0.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.1
|
|
94
|
+
[2.0.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.0
|
|
95
|
+
[1.0.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@rethunk/mcp-multi-root-git)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer,
|
|
7
|
+
**git** tools over MCP: read-only status, multi-root inventory, `HEAD` parity, presets, structured diff viewer, batch commit, merge, and cherry-pick. **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
|
|
|
@@ -1,19 +1,53 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { spawnGitAsync } from "./git.js";
|
|
4
|
+
import { getCurrentBranch, inferRemoteFromUpstream } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
7
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
8
|
const CommitEntrySchema = z.object({
|
|
8
9
|
message: z.string().min(1).describe("Commit message."),
|
|
9
10
|
files: z.array(z.string().min(1)).min(1).describe("Paths to stage, relative to the git root."),
|
|
10
11
|
});
|
|
12
|
+
const PushModeSchema = z
|
|
13
|
+
.enum(["never", "after"])
|
|
14
|
+
.optional()
|
|
15
|
+
.default("never")
|
|
16
|
+
.describe("`never` (default): no push. `after`: push the current branch to its upstream once all commits succeed; " +
|
|
17
|
+
"fails with `push_no_upstream` if the branch has no upstream (commits are NOT rolled back). " +
|
|
18
|
+
"Enum reserved for future modes such as `force-with-lease`.");
|
|
19
|
+
/**
|
|
20
|
+
* After all commits succeed, push the current branch to its upstream.
|
|
21
|
+
* Commits are already applied at this point — do NOT attempt rollback on push failure.
|
|
22
|
+
*/
|
|
23
|
+
export async function runPushAfter(gitTop) {
|
|
24
|
+
const branch = await getCurrentBranch(gitTop);
|
|
25
|
+
if (!branch) {
|
|
26
|
+
return { ok: false, error: "push_detached_head" };
|
|
27
|
+
}
|
|
28
|
+
const t = await inferRemoteFromUpstream(gitTop);
|
|
29
|
+
if (!t.ok) {
|
|
30
|
+
return { ok: false, branch, error: "push_no_upstream", detail: t.detail };
|
|
31
|
+
}
|
|
32
|
+
const pushResult = await spawnGitAsync(gitTop, ["push", t.remote, branch]);
|
|
33
|
+
if (!pushResult.ok) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
branch,
|
|
37
|
+
upstream: t.upstream,
|
|
38
|
+
error: "push_failed",
|
|
39
|
+
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, branch, upstream: t.upstream };
|
|
43
|
+
}
|
|
11
44
|
export function registerBatchCommitTool(server) {
|
|
12
45
|
server.addTool({
|
|
13
46
|
name: "batch_commit",
|
|
14
47
|
description: "Create multiple sequential git commits in a single call. " +
|
|
15
48
|
"Each entry stages the listed files then commits with the given message. " +
|
|
16
|
-
|
|
49
|
+
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
50
|
+
"to its upstream once all commits succeed.",
|
|
17
51
|
annotations: {
|
|
18
52
|
readOnlyHint: false,
|
|
19
53
|
destructiveHint: false,
|
|
@@ -25,19 +59,13 @@ export function registerBatchCommitTool(server) {
|
|
|
25
59
|
.min(1)
|
|
26
60
|
.max(50)
|
|
27
61
|
.describe("Commits to create, applied in order."),
|
|
62
|
+
push: PushModeSchema,
|
|
28
63
|
}),
|
|
29
64
|
execute: async (args) => {
|
|
30
|
-
const pre =
|
|
65
|
+
const pre = requireSingleRepo(server, args);
|
|
31
66
|
if (!pre.ok)
|
|
32
67
|
return jsonRespond(pre.error);
|
|
33
|
-
const
|
|
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
|
-
}
|
|
68
|
+
const gitTop = pre.gitTop;
|
|
41
69
|
const results = [];
|
|
42
70
|
for (let i = 0; i < args.commits.length; i++) {
|
|
43
71
|
const entry = args.commits[i];
|
|
@@ -99,6 +127,8 @@ export function registerBatchCommitTool(server) {
|
|
|
99
127
|
});
|
|
100
128
|
}
|
|
101
129
|
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;
|
|
102
132
|
if (args.format === "json") {
|
|
103
133
|
return jsonRespond({
|
|
104
134
|
ok: allOk,
|
|
@@ -113,6 +143,15 @@ export function registerBatchCommitTool(server) {
|
|
|
113
143
|
...spreadDefined("error", r.error),
|
|
114
144
|
...spreadDefined("detail", r.detail),
|
|
115
145
|
})),
|
|
146
|
+
...spreadWhen(push !== undefined, {
|
|
147
|
+
push: {
|
|
148
|
+
ok: push?.ok ?? false,
|
|
149
|
+
...spreadDefined("branch", push?.branch),
|
|
150
|
+
...spreadDefined("upstream", push?.upstream),
|
|
151
|
+
...spreadDefined("error", push?.error),
|
|
152
|
+
...spreadDefined("detail", push?.detail),
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
116
155
|
});
|
|
117
156
|
}
|
|
118
157
|
// --- Markdown ---
|
|
@@ -133,6 +172,15 @@ export function registerBatchCommitTool(server) {
|
|
|
133
172
|
const skipped = args.commits.length - results.length;
|
|
134
173
|
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
135
174
|
}
|
|
175
|
+
if (push) {
|
|
176
|
+
lines.push("");
|
|
177
|
+
if (push.ok) {
|
|
178
|
+
lines.push(`Push: ✓ ${push.branch} → ${push.upstream}`);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
136
184
|
return lines.join("\n");
|
|
137
185
|
},
|
|
138
186
|
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { commitListBetween, conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
async function cherryPickHead(gitTop) {
|
|
11
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "CHERRY_PICK_HEAD"]);
|
|
12
|
+
if (!r.ok)
|
|
13
|
+
return undefined;
|
|
14
|
+
const sha = r.stdout.trim();
|
|
15
|
+
return sha === "" ? undefined : sha;
|
|
16
|
+
}
|
|
17
|
+
async function abortCherryPick(gitTop) {
|
|
18
|
+
await spawnGitAsync(gitTop, ["cherry-pick", "--abort"]);
|
|
19
|
+
}
|
|
20
|
+
async function branchExists(gitTop, name) {
|
|
21
|
+
const r = await spawnGitAsync(gitTop, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
22
|
+
return r.ok;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Expand a source spec into a list of SHAs to cherry-pick.
|
|
26
|
+
* - `A..B` / `A...B` → `git rev-list --reverse` of the range
|
|
27
|
+
* - branch name (refs/heads/<name> exists) → `onto..<branch>` oldest-first
|
|
28
|
+
* - SHA or ref → single commit
|
|
29
|
+
*/
|
|
30
|
+
async function resolveSource(gitTop, onto, raw) {
|
|
31
|
+
if (raw.includes("..")) {
|
|
32
|
+
if (!isSafeGitRangeToken(raw)) {
|
|
33
|
+
return { error: "unsafe_ref_token", raw };
|
|
34
|
+
}
|
|
35
|
+
const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
|
|
36
|
+
if (!r.ok) {
|
|
37
|
+
return {
|
|
38
|
+
error: "range_resolution_failed",
|
|
39
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
40
|
+
raw,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const commits = r.stdout
|
|
44
|
+
.split("\n")
|
|
45
|
+
.map((l) => l.trim())
|
|
46
|
+
.filter((l) => l.length > 0);
|
|
47
|
+
return { raw, kind: "range", commits };
|
|
48
|
+
}
|
|
49
|
+
if (!isSafeGitRefToken(raw)) {
|
|
50
|
+
return { error: "unsafe_ref_token", raw };
|
|
51
|
+
}
|
|
52
|
+
if (await branchExists(gitTop, raw)) {
|
|
53
|
+
const commits = await commitListBetween(gitTop, onto, raw);
|
|
54
|
+
if (commits === null) {
|
|
55
|
+
return { error: "range_resolution_failed", raw };
|
|
56
|
+
}
|
|
57
|
+
return { raw, kind: "branch", commits };
|
|
58
|
+
}
|
|
59
|
+
const sha = await resolveRef(gitTop, raw);
|
|
60
|
+
if (!sha) {
|
|
61
|
+
return { error: "source_not_found", raw };
|
|
62
|
+
}
|
|
63
|
+
return { raw, kind: "sha", commits: [sha] };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Pre-filter already-in-destination commits (they would cherry-pick to empty).
|
|
67
|
+
* Also dedupe across sources while preserving first-seen order per-commit.
|
|
68
|
+
*/
|
|
69
|
+
async function filterAndDedupe(gitTop, onto, resolved) {
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const picks = [];
|
|
72
|
+
const perSourceKept = new Map();
|
|
73
|
+
for (const src of resolved) {
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const sha of src.commits) {
|
|
76
|
+
if (seen.has(sha))
|
|
77
|
+
continue;
|
|
78
|
+
seen.add(sha);
|
|
79
|
+
// Skip commits already reachable from destination (would produce empty commits).
|
|
80
|
+
const contained = await spawnGitAsync(gitTop, ["merge-base", "--is-ancestor", sha, onto]);
|
|
81
|
+
if (contained.ok)
|
|
82
|
+
continue; // already in destination
|
|
83
|
+
picks.push(sha);
|
|
84
|
+
kept.push(sha);
|
|
85
|
+
}
|
|
86
|
+
perSourceKept.set(src.raw, kept);
|
|
87
|
+
}
|
|
88
|
+
return { picks, perSourceKept };
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Tool registration
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
export function registerGitCherryPickTool(server) {
|
|
94
|
+
server.addTool({
|
|
95
|
+
name: "git_cherry_pick",
|
|
96
|
+
description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
|
|
97
|
+
"`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
|
|
98
|
+
"Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
|
|
99
|
+
"stops on the first conflict and reports paths. Optional flags auto-delete fully " +
|
|
100
|
+
"merged source branches and their worktrees, skipping protected names.",
|
|
101
|
+
annotations: {
|
|
102
|
+
readOnlyHint: false,
|
|
103
|
+
destructiveHint: false,
|
|
104
|
+
idempotentHint: false,
|
|
105
|
+
},
|
|
106
|
+
parameters: WorkspacePickSchema.extend({
|
|
107
|
+
sources: z
|
|
108
|
+
.array(z.string().min(1))
|
|
109
|
+
.min(1)
|
|
110
|
+
.max(50)
|
|
111
|
+
.describe("Sources to cherry-pick: SHA, `A..B` range, or branch name. Branch sources " +
|
|
112
|
+
"resolve to `onto..<branch>` (only commits missing from destination)."),
|
|
113
|
+
onto: z
|
|
114
|
+
.string()
|
|
115
|
+
.optional()
|
|
116
|
+
.describe("Destination branch. Defaults to the currently checked-out branch."),
|
|
117
|
+
deleteMergedBranches: z
|
|
118
|
+
.boolean()
|
|
119
|
+
.optional()
|
|
120
|
+
.default(false)
|
|
121
|
+
.describe("After all commits apply, delete each branch-kind source locally " +
|
|
122
|
+
"(`git branch -d`) when it is fully merged into the destination. " +
|
|
123
|
+
"Protected names always skipped; never touches remote refs."),
|
|
124
|
+
deleteMergedWorktrees: z
|
|
125
|
+
.boolean()
|
|
126
|
+
.optional()
|
|
127
|
+
.default(false)
|
|
128
|
+
.describe("After success, remove any local worktree attached to a branch-kind source " +
|
|
129
|
+
"(`git worktree remove`). Protected tails always skipped."),
|
|
130
|
+
}),
|
|
131
|
+
execute: async (args) => {
|
|
132
|
+
const pre = requireSingleRepo(server, args);
|
|
133
|
+
if (!pre.ok)
|
|
134
|
+
return jsonRespond(pre.error);
|
|
135
|
+
const gitTop = pre.gitTop;
|
|
136
|
+
// --- Resolve destination ---
|
|
137
|
+
const startBranch = await getCurrentBranch(gitTop);
|
|
138
|
+
const onto = args.onto?.trim() || startBranch;
|
|
139
|
+
if (!onto)
|
|
140
|
+
return jsonRespond({ error: "onto_detached_head" });
|
|
141
|
+
if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
|
|
142
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.onto });
|
|
143
|
+
}
|
|
144
|
+
// --- Refuse dirty tree ---
|
|
145
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
146
|
+
return jsonRespond({ error: "working_tree_dirty" });
|
|
147
|
+
}
|
|
148
|
+
// --- Ensure destination is checked out ---
|
|
149
|
+
if (onto !== startBranch) {
|
|
150
|
+
const co = await spawnGitAsync(gitTop, ["checkout", onto]);
|
|
151
|
+
if (!co.ok) {
|
|
152
|
+
return jsonRespond({
|
|
153
|
+
error: "checkout_failed",
|
|
154
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!(await resolveRef(gitTop, onto))) {
|
|
159
|
+
return jsonRespond({ error: "destination_not_found", ref: onto });
|
|
160
|
+
}
|
|
161
|
+
// --- Resolve each source ---
|
|
162
|
+
const resolved = [];
|
|
163
|
+
for (const raw of args.sources) {
|
|
164
|
+
const r = await resolveSource(gitTop, onto, raw);
|
|
165
|
+
if ("error" in r) {
|
|
166
|
+
return jsonRespond({
|
|
167
|
+
error: r.error,
|
|
168
|
+
source: raw,
|
|
169
|
+
...spreadDefined("detail", r.detail),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
resolved.push(r);
|
|
173
|
+
}
|
|
174
|
+
// --- Dedupe + skip already-present ---
|
|
175
|
+
const { picks, perSourceKept } = await filterAndDedupe(gitTop, onto, resolved);
|
|
176
|
+
// --- Apply cherry-pick (single atomic call) ---
|
|
177
|
+
// `--empty=drop` silently drops commits that would produce no change against the
|
|
178
|
+
// current tip — makes the tool idempotent when the same patch is re-applied.
|
|
179
|
+
let conflict;
|
|
180
|
+
let appliedCount = 0;
|
|
181
|
+
const preHeadProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
182
|
+
const preHead = preHeadProbe.ok ? preHeadProbe.stdout.trim() : "";
|
|
183
|
+
if (picks.length > 0) {
|
|
184
|
+
const r = await spawnGitAsync(gitTop, ["cherry-pick", "--empty=drop", ...picks]);
|
|
185
|
+
if (!r.ok) {
|
|
186
|
+
const failedSha = await cherryPickHead(gitTop);
|
|
187
|
+
const paths = await conflictPaths(gitTop);
|
|
188
|
+
await abortCherryPick(gitTop);
|
|
189
|
+
conflict = {
|
|
190
|
+
stage: "cherry-pick",
|
|
191
|
+
...spreadDefined("commit", failedSha),
|
|
192
|
+
paths,
|
|
193
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
// Actual commits written = HEAD advance count (empty-drop may skip some).
|
|
198
|
+
const adv = await spawnGitAsync(gitTop, ["rev-list", "--count", `${preHead}..HEAD`]);
|
|
199
|
+
appliedCount = adv.ok ? parseInt(adv.stdout.trim(), 10) || 0 : 0;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const allOk = !conflict;
|
|
203
|
+
// --- Cleanup (only on full success, only branch-kind sources) ---
|
|
204
|
+
const perSourceReport = resolved.map((s) => ({ ...s }));
|
|
205
|
+
if (allOk) {
|
|
206
|
+
for (let i = 0; i < perSourceReport.length; i++) {
|
|
207
|
+
const src = perSourceReport[i];
|
|
208
|
+
if (!src || src.kind !== "branch")
|
|
209
|
+
continue;
|
|
210
|
+
if (isProtectedBranch(src.raw))
|
|
211
|
+
continue;
|
|
212
|
+
if (args.deleteMergedWorktrees) {
|
|
213
|
+
const path = await worktreeForBranch(gitTop, src.raw);
|
|
214
|
+
if (path) {
|
|
215
|
+
const tail = path.split("/").pop() ?? "";
|
|
216
|
+
if (!isProtectedBranch(tail)) {
|
|
217
|
+
const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
|
|
218
|
+
if (r.ok)
|
|
219
|
+
src.worktreeRemoved = path;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (args.deleteMergedBranches) {
|
|
224
|
+
const merged = await isFullyMergedInto(gitTop, src.raw, onto);
|
|
225
|
+
if (merged) {
|
|
226
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
|
|
227
|
+
if (r.ok)
|
|
228
|
+
src.branchDeleted = true;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
234
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
235
|
+
if (args.format === "json") {
|
|
236
|
+
return jsonRespond({
|
|
237
|
+
ok: allOk,
|
|
238
|
+
onto,
|
|
239
|
+
...spreadDefined("headSha", headSha),
|
|
240
|
+
applied: appliedCount,
|
|
241
|
+
picked: picks.length,
|
|
242
|
+
results: perSourceReport.map((s) => ({
|
|
243
|
+
source: s.raw,
|
|
244
|
+
kind: s.kind,
|
|
245
|
+
resolvedCommits: s.commits.length,
|
|
246
|
+
keptCommits: perSourceKept.get(s.raw)?.length ?? 0,
|
|
247
|
+
...spreadWhen(s.branchDeleted === true, { branchDeleted: true }),
|
|
248
|
+
...spreadDefined("worktreeRemoved", s.worktreeRemoved),
|
|
249
|
+
})),
|
|
250
|
+
...spreadWhen(conflict !== undefined, {
|
|
251
|
+
conflict: {
|
|
252
|
+
stage: conflict?.stage ?? "cherry-pick",
|
|
253
|
+
...spreadDefined("commit", conflict?.commit),
|
|
254
|
+
paths: conflict?.paths ?? [],
|
|
255
|
+
...spreadDefined("detail", conflict?.detail),
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// --- Markdown ---
|
|
261
|
+
const lines = [];
|
|
262
|
+
const header = allOk
|
|
263
|
+
? `# Cherry-pick onto \`${onto}\`: ${appliedCount} commit(s) applied`
|
|
264
|
+
: `# Cherry-pick onto \`${onto}\`: stopped on conflict after ${appliedCount} commit(s)`;
|
|
265
|
+
lines.push(header, "");
|
|
266
|
+
for (const s of perSourceReport) {
|
|
267
|
+
const kept = perSourceKept.get(s.raw)?.length ?? 0;
|
|
268
|
+
const tail = [`${s.kind}`, `${kept}/${s.commits.length} picked`];
|
|
269
|
+
if (s.branchDeleted)
|
|
270
|
+
tail.push("branch deleted");
|
|
271
|
+
if (s.worktreeRemoved)
|
|
272
|
+
tail.push(`worktree removed: ${s.worktreeRemoved}`);
|
|
273
|
+
lines.push(`- ${s.raw} — ${tail.join(", ")}`);
|
|
274
|
+
}
|
|
275
|
+
if (conflict) {
|
|
276
|
+
lines.push("", `Conflict at commit \`${conflict.commit ?? "?"}\` (${conflict.stage}):`);
|
|
277
|
+
for (const p of conflict.paths)
|
|
278
|
+
lines.push(` conflict: ${p}`);
|
|
279
|
+
if (conflict.detail)
|
|
280
|
+
lines.push(` detail: ${conflict.detail}`);
|
|
281
|
+
}
|
|
282
|
+
return lines.join("\n");
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { matchesGlob } from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
4
4
|
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
-
import {
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
6
|
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
// Constants
|
|
@@ -156,7 +156,7 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
156
156
|
name: "git_diff_summary",
|
|
157
157
|
description: "Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions, " +
|
|
158
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.
|
|
159
|
+
"Use `range` to target staged, HEAD, or any revision range.",
|
|
160
160
|
annotations: {
|
|
161
161
|
readOnlyHint: true,
|
|
162
162
|
},
|
|
@@ -192,17 +192,10 @@ export function registerGitDiffSummaryTool(server) {
|
|
|
192
192
|
.describe("Glob patterns to exclude. Defaults to common noise: lock files, dist, vendor, etc."),
|
|
193
193
|
}),
|
|
194
194
|
execute: async (args) => {
|
|
195
|
-
|
|
196
|
-
const pre = requireGitAndRoots(server, args, undefined);
|
|
195
|
+
const pre = requireSingleRepo(server, args);
|
|
197
196
|
if (!pre.ok)
|
|
198
197
|
return jsonRespond(pre.error);
|
|
199
|
-
const
|
|
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
|
-
}
|
|
198
|
+
const gitTop = pre.gitTop;
|
|
206
199
|
// --- Build git diff args ---
|
|
207
200
|
const diffArgsResult = buildDiffArgs(args.range);
|
|
208
201
|
if (!diffArgsResult.ok) {
|
|
@@ -8,7 +8,10 @@ import { MAX_INVENTORY_ROOTS_DEFAULT, WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitInventoryTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_inventory",
|
|
11
|
-
description: "Read-only status + ahead/behind per root.
|
|
11
|
+
description: "Read-only status + ahead/behind per root.",
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
},
|
|
12
15
|
parameters: WorkspacePickSchema.extend({
|
|
13
16
|
nestedRoots: z.array(z.string()).optional(),
|
|
14
17
|
preset: z.string().optional(),
|