@rethunk/mcp-multi-root-git 2.0.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +9 -6
- package/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/dist/repo-paths.js +1 -1
- package/dist/server/batch-commit-tool.js +209 -0
- package/dist/server/git-cherry-pick-tool.js +299 -0
- package/dist/server/git-diff-summary-tool.js +319 -0
- package/dist/server/git-log-tool.js +276 -0
- package/dist/server/git-merge-tool.js +393 -0
- package/dist/server/git-refs.js +151 -0
- package/dist/server/json.js +1 -2
- package/dist/server/presets.js +4 -4
- package/dist/server/roots.js +5 -5
- package/dist/server/test-harness.js +64 -0
- package/dist/server/tools.js +10 -0
- package/docs/mcp-tools.md +296 -0
- package/package.json +5 -4
package/AGENTS.md
CHANGED
|
@@ -14,10 +14,10 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
14
14
|
| File | Symbols |
|
|
15
15
|
|------|---------|
|
|
16
16
|
| [`src/server.ts`](src/server.ts) | `FastMCP` + `roots: { enabled: true }`; `readMcpServerVersion()`; `registerRethunkGitTools` |
|
|
17
|
-
| [`src/server/json.ts`](src/server/json.ts) | `
|
|
17
|
+
| [`src/server/json.ts`](src/server/json.ts) | `jsonRespond()` (minified, no envelope), `spreadWhen`, `spreadDefined` |
|
|
18
18
|
| [`src/server/git.ts`](src/server/git.ts) | `gateGit`, `spawnGitAsync`, `asyncPool`, `GIT_SUBPROCESS_PARALLELISM`, `gitTopLevel`, `gitRevParseGitDir`, `gitRevParseHead`, `parseGitSubmodulePaths`, `hasGitMetadata`, `gitStatusSnapshotAsync`, `gitStatusShortBranchAsync`, `fetchAheadBehind`, `isSafeGitUpstreamToken` |
|
|
19
|
-
| [`src/server/roots.ts`](src/server/roots.ts) | `
|
|
20
|
-
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `
|
|
19
|
+
| [`src/server/roots.ts`](src/server/roots.ts) | `requireGitAndRoots` — sole public export; session root resolution |
|
|
20
|
+
| [`src/server/presets.ts`](src/server/presets.ts) | `PRESET_FILE_PATH`, `loadPresetsFromGitTop`, `presetLoadErrorPayload`, `applyPresetNestedRoots`, `applyPresetParityPairs`; Zod schemas must match [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json) |
|
|
21
21
|
| [`src/server/schemas.ts`](src/server/schemas.ts) | `WorkspacePickSchema`, `MAX_INVENTORY_ROOTS_DEFAULT` |
|
|
22
22
|
| [`src/server/inventory.ts`](src/server/inventory.ts) | `InventoryEntryJson`, `validateRepoPath`, `makeSkipEntry`, `buildInventorySectionMarkdown`, `collectInventoryEntry` |
|
|
23
23
|
| [`src/server/tools.ts`](src/server/tools.ts) | `registerRethunkGitTools` — dispatches to `register*` below |
|
|
@@ -25,14 +25,17 @@ IDEs injecting this as context: do not re-link from rules.
|
|
|
25
25
|
| [`src/server/git-inventory-tool.ts`](src/server/git-inventory-tool.ts) | `git_inventory` |
|
|
26
26
|
| [`src/server/git-parity-tool.ts`](src/server/git-parity-tool.ts) | `git_parity` |
|
|
27
27
|
| [`src/server/list-presets-tool.ts`](src/server/list-presets-tool.ts) | `list_presets` |
|
|
28
|
+
| [`src/server/git-log-tool.ts`](src/server/git-log-tool.ts) | `git_log` |
|
|
29
|
+
| [`src/server/git-diff-summary-tool.ts`](src/server/git-diff-summary-tool.ts) | `git_diff_summary` — structured token-efficient diff viewer; read-only |
|
|
30
|
+
| [`src/server/batch-commit-tool.ts`](src/server/batch-commit-tool.ts) | `batch_commit` — sequential multi-commit; mutating |
|
|
28
31
|
| [`src/server/presets-resource.ts`](src/server/presets-resource.ts) | `rethunk-git://presets` resource |
|
|
29
|
-
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop
|
|
32
|
+
| [`src/repo-paths.ts`](src/repo-paths.ts) | `resolvePathForRepo`, `assertRelativePathUnderTop`, `isStrictlyUnderGitTop` |
|
|
30
33
|
|
|
31
34
|
## Changing contracts
|
|
32
35
|
|
|
33
36
|
- **No banner paragraphs** in shipped docs. Use normal titles + cross-links.
|
|
34
|
-
-
|
|
35
|
-
- **Preset file:** keep `
|
|
37
|
+
- **JSON format version** (currently `"2"`, discoverable via MCP `initialize`): bump on incompatible JSON changes (renamed/nested/omitted fields). Document migration here + [docs/mcp-tools.md](docs/mcp-tools.md). v2 removed the `rethunkGitMcp` envelope; payloads are minified; optional fields omitted when empty/null/false.
|
|
38
|
+
- **Preset file:** keep `presets.ts` Zod schemas aligned with [`git-mcp-presets.schema.json`](git-mcp-presets.schema.json).
|
|
36
39
|
- **Public tool surface:** rename/add → update [docs/mcp-tools.md](docs/mcp-tools.md) + [README.md](README.md) (if mentioned). Install/client wiring → [docs/install.md](docs/install.md) only. `.cursor/rules/rethunk-git-mcp.mdc` → only when *MCP-vs-shell* guidance changes.
|
|
37
40
|
|
|
38
41
|
## Validate + CI
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
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.2.0] — 2026-04-17
|
|
6
|
+
|
|
7
|
+
Mutating git operations: merge, cherry-pick, and optional push-after for `batch_commit`.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`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*`).
|
|
12
|
+
- **`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`.
|
|
13
|
+
- **`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.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **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.
|
|
18
|
+
- **Tooling** — Biome upgraded to 2.4.11; dev dependency refresh.
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
- `docs/mcp-tools.md` — `git_merge` and `git_cherry_pick` sections with parameters, JSON shape, and error codes; `batch_commit` `push: "after"` option documented.
|
|
23
|
+
|
|
24
|
+
## [2.1.0] — prior
|
|
25
|
+
|
|
26
|
+
- Added `git_log` — path-filtered, time-windowed log across workspace roots.
|
|
27
|
+
- Added `git_diff_summary` — structured, token-efficient diff viewer with per-file truncation and default exclusions (lock files, `dist`, vendor).
|
|
28
|
+
- Added `batch_commit` — sequential multi-commit tool. Mutating, not idempotent.
|
|
29
|
+
- Test harness: fake-server duck-type lets unit tests drive the full FastMCP execute path without a live transport.
|
|
30
|
+
|
|
31
|
+
## [2.0.1] — prior
|
|
32
|
+
|
|
33
|
+
- Bug fixes and internal cleanup; no public tool-surface change.
|
|
34
|
+
|
|
35
|
+
## [2.0.0] — prior
|
|
36
|
+
|
|
37
|
+
- **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`).
|
|
38
|
+
- Initial preset file schema (`.rethunk/git-mcp-presets.json`) with wrapped and legacy-map layouts.
|
|
39
|
+
|
|
40
|
+
## [1.0.0] — prior
|
|
41
|
+
|
|
42
|
+
- Initial release: `git_status`, `git_inventory`, `git_parity`, `list_presets`.
|
|
43
|
+
|
|
44
|
+
[2.2.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.2.0
|
|
45
|
+
[2.1.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.1.0
|
|
46
|
+
[2.0.1]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.1
|
|
47
|
+
[2.0.0]: https://github.com/Rethunk-AI/mcp-multi-root-git/releases/tag/v2.0.0
|
|
48
|
+
[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
|
-
|
|
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
|
|
package/dist/repo-paths.js
CHANGED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
|
+
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
|
+
import { getCurrentBranch } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
6
|
+
import { requireGitAndRoots } from "./roots.js";
|
|
7
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
8
|
+
const CommitEntrySchema = z.object({
|
|
9
|
+
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."),
|
|
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
|
+
async function runPushAfter(gitTop) {
|
|
24
|
+
const branch = await getCurrentBranch(gitTop);
|
|
25
|
+
if (!branch) {
|
|
26
|
+
return { ok: false, error: "push_detached_head" };
|
|
27
|
+
}
|
|
28
|
+
// Verify upstream exists before attempting push (avoids side effects of auto-set-upstream).
|
|
29
|
+
const upstreamProbe = await spawnGitAsync(gitTop, [
|
|
30
|
+
"rev-parse",
|
|
31
|
+
"--abbrev-ref",
|
|
32
|
+
"--symbolic-full-name",
|
|
33
|
+
"@{u}",
|
|
34
|
+
]);
|
|
35
|
+
if (!upstreamProbe.ok) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
branch,
|
|
39
|
+
error: "push_no_upstream",
|
|
40
|
+
detail: (upstreamProbe.stderr || upstreamProbe.stdout).trim(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const upstream = upstreamProbe.stdout.trim();
|
|
44
|
+
// Explicit remote + branch so a future push refspec change to the upstream does not surprise us.
|
|
45
|
+
const slash = upstream.indexOf("/");
|
|
46
|
+
const remote = slash > 0 ? upstream.slice(0, slash) : "origin";
|
|
47
|
+
const pushResult = await spawnGitAsync(gitTop, ["push", remote, branch]);
|
|
48
|
+
if (!pushResult.ok) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
branch,
|
|
52
|
+
upstream,
|
|
53
|
+
error: "push_failed",
|
|
54
|
+
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ok: true, branch, upstream };
|
|
58
|
+
}
|
|
59
|
+
export function registerBatchCommitTool(server) {
|
|
60
|
+
server.addTool({
|
|
61
|
+
name: "batch_commit",
|
|
62
|
+
description: "Create multiple sequential git commits in a single call. " +
|
|
63
|
+
"Each entry stages the listed files then commits with the given message. " +
|
|
64
|
+
'Stops on first failure. Optional `push: "after"` pushes the current branch ' +
|
|
65
|
+
"to its upstream once all commits succeed. See docs/mcp-tools.md.",
|
|
66
|
+
annotations: {
|
|
67
|
+
readOnlyHint: false,
|
|
68
|
+
destructiveHint: false,
|
|
69
|
+
idempotentHint: false,
|
|
70
|
+
},
|
|
71
|
+
parameters: WorkspacePickSchema.extend({
|
|
72
|
+
commits: z
|
|
73
|
+
.array(CommitEntrySchema)
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(50)
|
|
76
|
+
.describe("Commits to create, applied in order."),
|
|
77
|
+
push: PushModeSchema,
|
|
78
|
+
}),
|
|
79
|
+
execute: async (args) => {
|
|
80
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
81
|
+
if (!pre.ok)
|
|
82
|
+
return jsonRespond(pre.error);
|
|
83
|
+
const rootInput = pre.roots[0];
|
|
84
|
+
if (!rootInput) {
|
|
85
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
86
|
+
}
|
|
87
|
+
const gitTop = gitTopLevel(rootInput);
|
|
88
|
+
if (!gitTop) {
|
|
89
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
90
|
+
}
|
|
91
|
+
const results = [];
|
|
92
|
+
for (let i = 0; i < args.commits.length; i++) {
|
|
93
|
+
const entry = args.commits[i];
|
|
94
|
+
if (!entry)
|
|
95
|
+
break;
|
|
96
|
+
// --- Validate all paths are under the git toplevel ---
|
|
97
|
+
const escapedPaths = [];
|
|
98
|
+
for (const rel of entry.files) {
|
|
99
|
+
const abs = resolvePathForRepo(rel, gitTop);
|
|
100
|
+
if (!isStrictlyUnderGitTop(abs, gitTop)) {
|
|
101
|
+
escapedPaths.push(rel);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (escapedPaths.length > 0) {
|
|
105
|
+
results.push({
|
|
106
|
+
index: i,
|
|
107
|
+
ok: false,
|
|
108
|
+
message: entry.message,
|
|
109
|
+
files: entry.files,
|
|
110
|
+
error: "path_escapes_repository",
|
|
111
|
+
detail: escapedPaths.join(", "),
|
|
112
|
+
});
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
// --- Stage files ---
|
|
116
|
+
const addResult = await spawnGitAsync(gitTop, ["add", "--", ...entry.files]);
|
|
117
|
+
if (!addResult.ok) {
|
|
118
|
+
results.push({
|
|
119
|
+
index: i,
|
|
120
|
+
ok: false,
|
|
121
|
+
message: entry.message,
|
|
122
|
+
files: entry.files,
|
|
123
|
+
error: "stage_failed",
|
|
124
|
+
detail: (addResult.stderr || addResult.stdout).trim(),
|
|
125
|
+
});
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
// --- Commit ---
|
|
129
|
+
const commitResult = await spawnGitAsync(gitTop, ["commit", "-m", entry.message]);
|
|
130
|
+
if (!commitResult.ok) {
|
|
131
|
+
results.push({
|
|
132
|
+
index: i,
|
|
133
|
+
ok: false,
|
|
134
|
+
message: entry.message,
|
|
135
|
+
files: entry.files,
|
|
136
|
+
error: "commit_failed",
|
|
137
|
+
detail: (commitResult.stderr || commitResult.stdout).trim(),
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
// --- Extract SHA from commit output ---
|
|
142
|
+
const shaMatch = /\[[\w/.-]+\s+([0-9a-f]+)\]/.exec(commitResult.stdout);
|
|
143
|
+
results.push({
|
|
144
|
+
index: i,
|
|
145
|
+
ok: true,
|
|
146
|
+
sha: shaMatch?.[1],
|
|
147
|
+
message: entry.message,
|
|
148
|
+
files: entry.files,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const allOk = results.length === args.commits.length && results.every((r) => r.ok);
|
|
152
|
+
// --- Optional push after all commits succeed ---
|
|
153
|
+
const push = allOk && args.push === "after" ? await runPushAfter(gitTop) : undefined;
|
|
154
|
+
if (args.format === "json") {
|
|
155
|
+
return jsonRespond({
|
|
156
|
+
ok: allOk,
|
|
157
|
+
committed: results.filter((r) => r.ok).length,
|
|
158
|
+
total: args.commits.length,
|
|
159
|
+
results: results.map((r) => ({
|
|
160
|
+
index: r.index,
|
|
161
|
+
ok: r.ok,
|
|
162
|
+
...spreadDefined("sha", r.sha),
|
|
163
|
+
message: r.message,
|
|
164
|
+
files: r.files,
|
|
165
|
+
...spreadDefined("error", r.error),
|
|
166
|
+
...spreadDefined("detail", r.detail),
|
|
167
|
+
})),
|
|
168
|
+
...spreadWhen(push !== undefined, {
|
|
169
|
+
push: {
|
|
170
|
+
ok: push?.ok ?? false,
|
|
171
|
+
...spreadDefined("branch", push?.branch),
|
|
172
|
+
...spreadDefined("upstream", push?.upstream),
|
|
173
|
+
...spreadDefined("error", push?.error),
|
|
174
|
+
...spreadDefined("detail", push?.detail),
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
// --- Markdown ---
|
|
180
|
+
const lines = [];
|
|
181
|
+
const header = allOk
|
|
182
|
+
? `# Batch commit: ${results.length}/${args.commits.length} committed`
|
|
183
|
+
: `# Batch commit: ${results.filter((r) => r.ok).length}/${args.commits.length} committed (stopped on error)`;
|
|
184
|
+
lines.push(header, "");
|
|
185
|
+
for (const r of results) {
|
|
186
|
+
const icon = r.ok ? "✓" : "✗";
|
|
187
|
+
const sha = r.sha ? ` \`${r.sha}\`` : "";
|
|
188
|
+
lines.push(`${icon}${sha} ${r.message}`);
|
|
189
|
+
if (!r.ok && r.detail) {
|
|
190
|
+
lines.push(` Error: ${r.error} — ${r.detail}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!allOk && results.length < args.commits.length) {
|
|
194
|
+
const skipped = args.commits.length - results.length;
|
|
195
|
+
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
196
|
+
}
|
|
197
|
+
if (push) {
|
|
198
|
+
lines.push("");
|
|
199
|
+
if (push.ok) {
|
|
200
|
+
lines.push(`Push: ✓ ${push.branch} → ${push.upstream}`);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
lines.push(`Push: ✗ ${push.error}${push.detail ? ` — ${push.detail}` : ""}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { commitListBetween, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRangeToken, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
+
import { requireGitAndRoots } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
async function conflictPaths(gitTop) {
|
|
11
|
+
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
12
|
+
if (!r.ok)
|
|
13
|
+
return [];
|
|
14
|
+
return r.stdout
|
|
15
|
+
.split("\n")
|
|
16
|
+
.map((l) => l.trim())
|
|
17
|
+
.filter((l) => l.length > 0);
|
|
18
|
+
}
|
|
19
|
+
async function cherryPickHead(gitTop) {
|
|
20
|
+
const r = await spawnGitAsync(gitTop, ["rev-parse", "--verify", "--quiet", "CHERRY_PICK_HEAD"]);
|
|
21
|
+
if (!r.ok)
|
|
22
|
+
return undefined;
|
|
23
|
+
const sha = r.stdout.trim();
|
|
24
|
+
return sha === "" ? undefined : sha;
|
|
25
|
+
}
|
|
26
|
+
async function abortCherryPick(gitTop) {
|
|
27
|
+
await spawnGitAsync(gitTop, ["cherry-pick", "--abort"]);
|
|
28
|
+
}
|
|
29
|
+
async function branchExists(gitTop, name) {
|
|
30
|
+
const r = await spawnGitAsync(gitTop, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
31
|
+
return r.ok;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Expand a source spec into a list of SHAs to cherry-pick.
|
|
35
|
+
* - `A..B` / `A...B` → `git rev-list --reverse` of the range
|
|
36
|
+
* - branch name (refs/heads/<name> exists) → `onto..<branch>` oldest-first
|
|
37
|
+
* - SHA or ref → single commit
|
|
38
|
+
*/
|
|
39
|
+
async function resolveSource(gitTop, onto, raw) {
|
|
40
|
+
if (raw.includes("..")) {
|
|
41
|
+
if (!isSafeGitRangeToken(raw)) {
|
|
42
|
+
return { error: "unsafe_ref_token", raw };
|
|
43
|
+
}
|
|
44
|
+
const r = await spawnGitAsync(gitTop, ["rev-list", "--reverse", raw]);
|
|
45
|
+
if (!r.ok) {
|
|
46
|
+
return {
|
|
47
|
+
error: "range_resolution_failed",
|
|
48
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
49
|
+
raw,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const commits = r.stdout
|
|
53
|
+
.split("\n")
|
|
54
|
+
.map((l) => l.trim())
|
|
55
|
+
.filter((l) => l.length > 0);
|
|
56
|
+
return { raw, kind: "range", commits };
|
|
57
|
+
}
|
|
58
|
+
if (!isSafeGitRefToken(raw)) {
|
|
59
|
+
return { error: "unsafe_ref_token", raw };
|
|
60
|
+
}
|
|
61
|
+
if (await branchExists(gitTop, raw)) {
|
|
62
|
+
const commits = await commitListBetween(gitTop, onto, raw);
|
|
63
|
+
if (commits === null) {
|
|
64
|
+
return { error: "range_resolution_failed", raw };
|
|
65
|
+
}
|
|
66
|
+
return { raw, kind: "branch", commits };
|
|
67
|
+
}
|
|
68
|
+
const sha = await resolveRef(gitTop, raw);
|
|
69
|
+
if (!sha) {
|
|
70
|
+
return { error: "source_not_found", raw };
|
|
71
|
+
}
|
|
72
|
+
return { raw, kind: "sha", commits: [sha] };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Pre-filter already-in-destination commits (they would cherry-pick to empty).
|
|
76
|
+
* Also dedupe across sources while preserving first-seen order per-commit.
|
|
77
|
+
*/
|
|
78
|
+
async function filterAndDedupe(gitTop, onto, resolved) {
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const picks = [];
|
|
81
|
+
const perSourceKept = new Map();
|
|
82
|
+
for (const src of resolved) {
|
|
83
|
+
const kept = [];
|
|
84
|
+
for (const sha of src.commits) {
|
|
85
|
+
if (seen.has(sha))
|
|
86
|
+
continue;
|
|
87
|
+
seen.add(sha);
|
|
88
|
+
// Skip commits already reachable from destination (would produce empty commits).
|
|
89
|
+
const contained = await spawnGitAsync(gitTop, ["merge-base", "--is-ancestor", sha, onto]);
|
|
90
|
+
if (contained.ok)
|
|
91
|
+
continue; // already in destination
|
|
92
|
+
picks.push(sha);
|
|
93
|
+
kept.push(sha);
|
|
94
|
+
}
|
|
95
|
+
perSourceKept.set(src.raw, kept);
|
|
96
|
+
}
|
|
97
|
+
return { picks, perSourceKept };
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Tool registration
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export function registerGitCherryPickTool(server) {
|
|
103
|
+
server.addTool({
|
|
104
|
+
name: "git_cherry_pick",
|
|
105
|
+
description: "Play commits from one or more sources onto a destination. Sources may be SHAs, " +
|
|
106
|
+
"`A..B` ranges, or branch names (expanded to `onto..<branch>`, oldest-first). " +
|
|
107
|
+
"Commits already reachable from the destination are skipped. Refuses on dirty tree; " +
|
|
108
|
+
"stops on the first conflict and reports paths. Optional flags auto-delete fully " +
|
|
109
|
+
"merged source branches and their worktrees, skipping protected names. See docs/mcp-tools.md.",
|
|
110
|
+
annotations: {
|
|
111
|
+
readOnlyHint: false,
|
|
112
|
+
destructiveHint: false,
|
|
113
|
+
idempotentHint: false,
|
|
114
|
+
},
|
|
115
|
+
parameters: WorkspacePickSchema.extend({
|
|
116
|
+
sources: z
|
|
117
|
+
.array(z.string().min(1))
|
|
118
|
+
.min(1)
|
|
119
|
+
.max(50)
|
|
120
|
+
.describe("Sources to cherry-pick: SHA, `A..B` range, or branch name. Branch sources " +
|
|
121
|
+
"resolve to `onto..<branch>` (only commits missing from destination)."),
|
|
122
|
+
onto: z
|
|
123
|
+
.string()
|
|
124
|
+
.optional()
|
|
125
|
+
.describe("Destination branch. Defaults to the currently checked-out branch."),
|
|
126
|
+
deleteMergedBranches: z
|
|
127
|
+
.boolean()
|
|
128
|
+
.optional()
|
|
129
|
+
.default(false)
|
|
130
|
+
.describe("After all commits apply, delete each branch-kind source locally " +
|
|
131
|
+
"(`git branch -d`) when it is fully merged into the destination. " +
|
|
132
|
+
"Protected names always skipped; never touches remote refs."),
|
|
133
|
+
deleteMergedWorktrees: z
|
|
134
|
+
.boolean()
|
|
135
|
+
.optional()
|
|
136
|
+
.default(false)
|
|
137
|
+
.describe("After success, remove any local worktree attached to a branch-kind source " +
|
|
138
|
+
"(`git worktree remove`). Protected tails always skipped."),
|
|
139
|
+
}),
|
|
140
|
+
execute: async (args) => {
|
|
141
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
142
|
+
if (!pre.ok)
|
|
143
|
+
return jsonRespond(pre.error);
|
|
144
|
+
const rootInput = pre.roots[0];
|
|
145
|
+
if (!rootInput)
|
|
146
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
147
|
+
const gitTop = gitTopLevel(rootInput);
|
|
148
|
+
if (!gitTop)
|
|
149
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
150
|
+
// --- Resolve destination ---
|
|
151
|
+
const startBranch = await getCurrentBranch(gitTop);
|
|
152
|
+
const onto = args.onto?.trim() || startBranch;
|
|
153
|
+
if (!onto)
|
|
154
|
+
return jsonRespond({ error: "onto_detached_head" });
|
|
155
|
+
if (args.onto !== undefined && !isSafeGitRefToken(args.onto)) {
|
|
156
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.onto });
|
|
157
|
+
}
|
|
158
|
+
// --- Refuse dirty tree ---
|
|
159
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
160
|
+
return jsonRespond({ error: "working_tree_dirty" });
|
|
161
|
+
}
|
|
162
|
+
// --- Ensure destination is checked out ---
|
|
163
|
+
if (onto !== startBranch) {
|
|
164
|
+
const co = await spawnGitAsync(gitTop, ["checkout", onto]);
|
|
165
|
+
if (!co.ok) {
|
|
166
|
+
return jsonRespond({
|
|
167
|
+
error: "checkout_failed",
|
|
168
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!(await resolveRef(gitTop, onto))) {
|
|
173
|
+
return jsonRespond({ error: "destination_not_found", ref: onto });
|
|
174
|
+
}
|
|
175
|
+
// --- Resolve each source ---
|
|
176
|
+
const resolved = [];
|
|
177
|
+
for (const raw of args.sources) {
|
|
178
|
+
const r = await resolveSource(gitTop, onto, raw);
|
|
179
|
+
if ("error" in r) {
|
|
180
|
+
return jsonRespond({
|
|
181
|
+
error: r.error,
|
|
182
|
+
source: raw,
|
|
183
|
+
...spreadDefined("detail", r.detail),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
resolved.push(r);
|
|
187
|
+
}
|
|
188
|
+
// --- Dedupe + skip already-present ---
|
|
189
|
+
const { picks, perSourceKept } = await filterAndDedupe(gitTop, onto, resolved);
|
|
190
|
+
// --- Apply cherry-pick (single atomic call) ---
|
|
191
|
+
// `--empty=drop` silently drops commits that would produce no change against the
|
|
192
|
+
// current tip — makes the tool idempotent when the same patch is re-applied.
|
|
193
|
+
let conflict;
|
|
194
|
+
let appliedCount = 0;
|
|
195
|
+
const preHeadProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
196
|
+
const preHead = preHeadProbe.ok ? preHeadProbe.stdout.trim() : "";
|
|
197
|
+
if (picks.length > 0) {
|
|
198
|
+
const r = await spawnGitAsync(gitTop, ["cherry-pick", "--empty=drop", ...picks]);
|
|
199
|
+
if (!r.ok) {
|
|
200
|
+
const failedSha = await cherryPickHead(gitTop);
|
|
201
|
+
const paths = await conflictPaths(gitTop);
|
|
202
|
+
await abortCherryPick(gitTop);
|
|
203
|
+
conflict = {
|
|
204
|
+
stage: "cherry-pick",
|
|
205
|
+
...spreadDefined("commit", failedSha),
|
|
206
|
+
paths,
|
|
207
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
// Actual commits written = HEAD advance count (empty-drop may skip some).
|
|
212
|
+
const adv = await spawnGitAsync(gitTop, ["rev-list", "--count", `${preHead}..HEAD`]);
|
|
213
|
+
appliedCount = adv.ok ? parseInt(adv.stdout.trim(), 10) || 0 : 0;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const allOk = !conflict;
|
|
217
|
+
// --- Cleanup (only on full success, only branch-kind sources) ---
|
|
218
|
+
const perSourceReport = resolved.map((s) => ({ ...s }));
|
|
219
|
+
if (allOk) {
|
|
220
|
+
for (let i = 0; i < perSourceReport.length; i++) {
|
|
221
|
+
const src = perSourceReport[i];
|
|
222
|
+
if (!src || src.kind !== "branch")
|
|
223
|
+
continue;
|
|
224
|
+
if (isProtectedBranch(src.raw))
|
|
225
|
+
continue;
|
|
226
|
+
if (args.deleteMergedWorktrees) {
|
|
227
|
+
const path = await worktreeForBranch(gitTop, src.raw);
|
|
228
|
+
if (path) {
|
|
229
|
+
const tail = path.split("/").pop() ?? "";
|
|
230
|
+
if (!isProtectedBranch(tail)) {
|
|
231
|
+
const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
|
|
232
|
+
if (r.ok)
|
|
233
|
+
src.worktreeRemoved = path;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (args.deleteMergedBranches) {
|
|
238
|
+
const merged = await isFullyMergedInto(gitTop, src.raw, onto);
|
|
239
|
+
if (merged) {
|
|
240
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", src.raw]);
|
|
241
|
+
if (r.ok)
|
|
242
|
+
src.branchDeleted = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
248
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
249
|
+
if (args.format === "json") {
|
|
250
|
+
return jsonRespond({
|
|
251
|
+
ok: allOk,
|
|
252
|
+
onto,
|
|
253
|
+
...spreadDefined("headSha", headSha),
|
|
254
|
+
applied: appliedCount,
|
|
255
|
+
picked: picks.length,
|
|
256
|
+
results: perSourceReport.map((s) => ({
|
|
257
|
+
source: s.raw,
|
|
258
|
+
kind: s.kind,
|
|
259
|
+
resolvedCommits: s.commits.length,
|
|
260
|
+
keptCommits: perSourceKept.get(s.raw)?.length ?? 0,
|
|
261
|
+
...spreadWhen(s.branchDeleted === true, { branchDeleted: true }),
|
|
262
|
+
...spreadDefined("worktreeRemoved", s.worktreeRemoved),
|
|
263
|
+
})),
|
|
264
|
+
...spreadWhen(conflict !== undefined, {
|
|
265
|
+
conflict: {
|
|
266
|
+
stage: conflict?.stage ?? "cherry-pick",
|
|
267
|
+
...spreadDefined("commit", conflict?.commit),
|
|
268
|
+
paths: conflict?.paths ?? [],
|
|
269
|
+
...spreadDefined("detail", conflict?.detail),
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
// --- Markdown ---
|
|
275
|
+
const lines = [];
|
|
276
|
+
const header = allOk
|
|
277
|
+
? `# Cherry-pick onto \`${onto}\`: ${appliedCount} commit(s) applied`
|
|
278
|
+
: `# Cherry-pick onto \`${onto}\`: stopped on conflict after ${appliedCount} commit(s)`;
|
|
279
|
+
lines.push(header, "");
|
|
280
|
+
for (const s of perSourceReport) {
|
|
281
|
+
const kept = perSourceKept.get(s.raw)?.length ?? 0;
|
|
282
|
+
const tail = [`${s.kind}`, `${kept}/${s.commits.length} picked`];
|
|
283
|
+
if (s.branchDeleted)
|
|
284
|
+
tail.push("branch deleted");
|
|
285
|
+
if (s.worktreeRemoved)
|
|
286
|
+
tail.push(`worktree removed: ${s.worktreeRemoved}`);
|
|
287
|
+
lines.push(`- ${s.raw} — ${tail.join(", ")}`);
|
|
288
|
+
}
|
|
289
|
+
if (conflict) {
|
|
290
|
+
lines.push("", `Conflict at commit \`${conflict.commit ?? "?"}\` (${conflict.stage}):`);
|
|
291
|
+
for (const p of conflict.paths)
|
|
292
|
+
lines.push(` conflict: ${p}`);
|
|
293
|
+
if (conflict.detail)
|
|
294
|
+
lines.push(` detail: ${conflict.detail}`);
|
|
295
|
+
}
|
|
296
|
+
return lines.join("\n");
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|