@rethunk/mcp-multi-root-git 2.1.0 → 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/CHANGELOG.md +48 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +72 -2
- package/dist/server/git-cherry-pick-tool.js +299 -0
- package/dist/server/git-merge-tool.js +393 -0
- package/dist/server/git-refs.js +151 -0
- package/dist/server/tools.js +4 -0
- package/docs/mcp-tools.md +149 -2
- package/package.json +5 -4
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
|
-
**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,68 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { isStrictlyUnderGitTop, resolvePathForRepo } from "../repo-paths.js";
|
|
3
3
|
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getCurrentBranch } from "./git-refs.js";
|
|
5
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
6
|
import { requireGitAndRoots } 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
|
+
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
|
+
}
|
|
11
59
|
export function registerBatchCommitTool(server) {
|
|
12
60
|
server.addTool({
|
|
13
61
|
name: "batch_commit",
|
|
14
62
|
description: "Create multiple sequential git commits in a single call. " +
|
|
15
63
|
"Each entry stages the listed files then commits with the given message. " +
|
|
16
|
-
|
|
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.",
|
|
17
66
|
annotations: {
|
|
18
67
|
readOnlyHint: false,
|
|
19
68
|
destructiveHint: false,
|
|
@@ -25,6 +74,7 @@ export function registerBatchCommitTool(server) {
|
|
|
25
74
|
.min(1)
|
|
26
75
|
.max(50)
|
|
27
76
|
.describe("Commits to create, applied in order."),
|
|
77
|
+
push: PushModeSchema,
|
|
28
78
|
}),
|
|
29
79
|
execute: async (args) => {
|
|
30
80
|
const pre = requireGitAndRoots(server, args, undefined);
|
|
@@ -99,6 +149,8 @@ export function registerBatchCommitTool(server) {
|
|
|
99
149
|
});
|
|
100
150
|
}
|
|
101
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;
|
|
102
154
|
if (args.format === "json") {
|
|
103
155
|
return jsonRespond({
|
|
104
156
|
ok: allOk,
|
|
@@ -113,6 +165,15 @@ export function registerBatchCommitTool(server) {
|
|
|
113
165
|
...spreadDefined("error", r.error),
|
|
114
166
|
...spreadDefined("detail", r.detail),
|
|
115
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
|
+
}),
|
|
116
177
|
});
|
|
117
178
|
}
|
|
118
179
|
// --- Markdown ---
|
|
@@ -133,6 +194,15 @@ export function registerBatchCommitTool(server) {
|
|
|
133
194
|
const skipped = args.commits.length - results.length;
|
|
134
195
|
lines.push("", `${skipped} remaining commit(s) skipped.`);
|
|
135
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
|
+
}
|
|
136
206
|
return lines.join("\n");
|
|
137
207
|
},
|
|
138
208
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { gitTopLevel, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { getCurrentBranch, isFullyMergedInto, isProtectedBranch, 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
|
+
// Schemas
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const StrategySchema = z
|
|
11
|
+
.enum(["auto", "ff-only", "rebase", "merge"])
|
|
12
|
+
.optional()
|
|
13
|
+
.default("auto")
|
|
14
|
+
.describe("`auto` (default): cascade fast-forward → rebase → merge-commit per source. " +
|
|
15
|
+
"`ff-only`: only fast-forward, fail if diverged. " +
|
|
16
|
+
"`rebase`: rebase source onto destination, then fast-forward (no merge-commit fallback). " +
|
|
17
|
+
"`merge`: always create a merge commit (no fast-forward).");
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Conflict helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
async function conflictPaths(gitTop) {
|
|
22
|
+
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return [];
|
|
25
|
+
return r.stdout
|
|
26
|
+
.split("\n")
|
|
27
|
+
.map((l) => l.trim())
|
|
28
|
+
.filter((l) => l.length > 0);
|
|
29
|
+
}
|
|
30
|
+
async function abortMerge(gitTop) {
|
|
31
|
+
await spawnGitAsync(gitTop, ["merge", "--abort"]);
|
|
32
|
+
}
|
|
33
|
+
async function abortRebase(gitTop) {
|
|
34
|
+
await spawnGitAsync(gitTop, ["rebase", "--abort"]);
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Per-source merge logic
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
/**
|
|
40
|
+
* Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
|
|
41
|
+
* On conflict the repo state is cleaned (merge/rebase aborted, HEAD restored to `into`).
|
|
42
|
+
*/
|
|
43
|
+
async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
44
|
+
// --- Classify state via merge-base ---
|
|
45
|
+
const intoSha = await resolveRef(gitTop, into);
|
|
46
|
+
const sourceSha = await resolveRef(gitTop, source);
|
|
47
|
+
if (!sourceSha) {
|
|
48
|
+
return { source, ok: false, error: "source_not_found" };
|
|
49
|
+
}
|
|
50
|
+
if (!intoSha) {
|
|
51
|
+
return { source, ok: false, error: "destination_not_found" };
|
|
52
|
+
}
|
|
53
|
+
const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
|
|
54
|
+
if (!mb.ok) {
|
|
55
|
+
return {
|
|
56
|
+
source,
|
|
57
|
+
ok: false,
|
|
58
|
+
error: "merge_base_failed",
|
|
59
|
+
detail: (mb.stderr || mb.stdout).trim(),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const mergeBase = mb.stdout.trim();
|
|
63
|
+
// source fully contained in into → noop
|
|
64
|
+
if (mergeBase === sourceSha) {
|
|
65
|
+
return { source, ok: true, outcome: "up_to_date", mergedSha: intoSha };
|
|
66
|
+
}
|
|
67
|
+
// into fully contained in source → fast-forward is the right move
|
|
68
|
+
const canFastForward = mergeBase === intoSha;
|
|
69
|
+
// --- ff-only strategy ---
|
|
70
|
+
if (strategy === "ff-only") {
|
|
71
|
+
if (!canFastForward) {
|
|
72
|
+
return {
|
|
73
|
+
source,
|
|
74
|
+
ok: false,
|
|
75
|
+
error: "cannot_fast_forward",
|
|
76
|
+
detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return fastForward(gitTop, source);
|
|
80
|
+
}
|
|
81
|
+
// --- Fast-forward path for auto/rebase when applicable ---
|
|
82
|
+
if (canFastForward && (strategy === "auto" || strategy === "rebase")) {
|
|
83
|
+
return fastForward(gitTop, source);
|
|
84
|
+
}
|
|
85
|
+
// --- merge strategy: always merge commit ---
|
|
86
|
+
if (strategy === "merge") {
|
|
87
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
88
|
+
}
|
|
89
|
+
// --- rebase or auto (diverged case) ---
|
|
90
|
+
const rebased = await rebaseSourceOntoInto(gitTop, into, source);
|
|
91
|
+
if (rebased.ok) {
|
|
92
|
+
// Rebase succeeded; FF destination up to the now-rebased source tip.
|
|
93
|
+
const ff = await fastForward(gitTop, source);
|
|
94
|
+
if (!ff.ok)
|
|
95
|
+
return ff;
|
|
96
|
+
return { ...ff, outcome: "rebase_then_ff" };
|
|
97
|
+
}
|
|
98
|
+
if (strategy === "rebase") {
|
|
99
|
+
return rebased; // caller opted out of merge-commit fallback
|
|
100
|
+
}
|
|
101
|
+
// auto: fall through to merge commit
|
|
102
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
103
|
+
}
|
|
104
|
+
async function fastForward(gitTop, source) {
|
|
105
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
await abortMerge(gitTop);
|
|
108
|
+
return {
|
|
109
|
+
source,
|
|
110
|
+
ok: false,
|
|
111
|
+
outcome: "conflicts",
|
|
112
|
+
conflictStage: "merge",
|
|
113
|
+
conflictPaths: [],
|
|
114
|
+
error: "merge_failed",
|
|
115
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
119
|
+
return {
|
|
120
|
+
source,
|
|
121
|
+
ok: true,
|
|
122
|
+
outcome: "fast_forward",
|
|
123
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
async function mergeCommit(gitTop, source, message, into) {
|
|
127
|
+
const msg = message?.trim() || `Merge branch '${source}' into ${into}`;
|
|
128
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
const paths = await conflictPaths(gitTop);
|
|
131
|
+
await abortMerge(gitTop);
|
|
132
|
+
return {
|
|
133
|
+
source,
|
|
134
|
+
ok: false,
|
|
135
|
+
outcome: "conflicts",
|
|
136
|
+
conflictStage: "merge",
|
|
137
|
+
conflictPaths: paths,
|
|
138
|
+
error: "merge_conflicts",
|
|
139
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
143
|
+
return {
|
|
144
|
+
source,
|
|
145
|
+
ok: true,
|
|
146
|
+
outcome: "merge_commit",
|
|
147
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Rebase `source` onto `into`, then return to `into`.
|
|
152
|
+
* On failure: abort rebase, check out `into` again, return structured conflict.
|
|
153
|
+
*/
|
|
154
|
+
async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
155
|
+
// `git rebase <upstream> <branch>` first switches to <branch>.
|
|
156
|
+
const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
|
|
157
|
+
if (!r.ok) {
|
|
158
|
+
const paths = await conflictPaths(gitTop);
|
|
159
|
+
await abortRebase(gitTop);
|
|
160
|
+
// Ensure we're back on `into` regardless of rebase state.
|
|
161
|
+
await spawnGitAsync(gitTop, ["checkout", into]);
|
|
162
|
+
return {
|
|
163
|
+
source,
|
|
164
|
+
ok: false,
|
|
165
|
+
outcome: "conflicts",
|
|
166
|
+
conflictStage: "rebase",
|
|
167
|
+
conflictPaths: paths,
|
|
168
|
+
error: "rebase_conflicts",
|
|
169
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Rebase succeeded; switch back to destination so the caller can FF.
|
|
173
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
174
|
+
if (!co.ok) {
|
|
175
|
+
return {
|
|
176
|
+
source,
|
|
177
|
+
ok: false,
|
|
178
|
+
error: "checkout_failed",
|
|
179
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { source, ok: true }; // caller FFs
|
|
183
|
+
}
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Cleanup helpers
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
async function maybeRemoveWorktree(gitTop, source, enabled) {
|
|
188
|
+
if (!enabled)
|
|
189
|
+
return undefined;
|
|
190
|
+
const path = await worktreeForBranch(gitTop, source);
|
|
191
|
+
if (!path)
|
|
192
|
+
return undefined;
|
|
193
|
+
// Re-check protected names against the worktree path's trailing segment too.
|
|
194
|
+
const tail = path.split("/").pop() ?? "";
|
|
195
|
+
if (isProtectedBranch(tail))
|
|
196
|
+
return undefined;
|
|
197
|
+
const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
|
|
198
|
+
return r.ok ? path : undefined;
|
|
199
|
+
}
|
|
200
|
+
async function maybeDeleteBranch(gitTop, source, enabled, into) {
|
|
201
|
+
if (!enabled)
|
|
202
|
+
return false;
|
|
203
|
+
if (isProtectedBranch(source))
|
|
204
|
+
return false;
|
|
205
|
+
// Safety double-check: source must be fully merged into destination.
|
|
206
|
+
const merged = await isFullyMergedInto(gitTop, source, into);
|
|
207
|
+
if (!merged)
|
|
208
|
+
return false;
|
|
209
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", source]);
|
|
210
|
+
return r.ok;
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Tool registration
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
export function registerGitMergeTool(server) {
|
|
216
|
+
server.addTool({
|
|
217
|
+
name: "git_merge",
|
|
218
|
+
description: "Merge one or more source branches into a destination. Default strategy `auto` " +
|
|
219
|
+
"cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
|
|
220
|
+
"Refuses if the working tree is dirty. Stops on the first conflict and reports " +
|
|
221
|
+
"the affected paths. Optional flags auto-delete merged branches and worktrees, " +
|
|
222
|
+
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
223
|
+
"production, release/*, hotfix/*). See docs/mcp-tools.md.",
|
|
224
|
+
annotations: {
|
|
225
|
+
readOnlyHint: false,
|
|
226
|
+
destructiveHint: false,
|
|
227
|
+
idempotentHint: false,
|
|
228
|
+
},
|
|
229
|
+
parameters: WorkspacePickSchema.extend({
|
|
230
|
+
sources: z
|
|
231
|
+
.array(z.string().min(1))
|
|
232
|
+
.min(1)
|
|
233
|
+
.max(20)
|
|
234
|
+
.describe("Branches to merge into the destination, in order."),
|
|
235
|
+
into: z
|
|
236
|
+
.string()
|
|
237
|
+
.optional()
|
|
238
|
+
.describe("Destination branch. Defaults to the currently checked-out branch."),
|
|
239
|
+
strategy: StrategySchema,
|
|
240
|
+
message: z
|
|
241
|
+
.string()
|
|
242
|
+
.optional()
|
|
243
|
+
.describe("Merge commit message (used only when a merge commit is created)."),
|
|
244
|
+
deleteMergedBranches: z
|
|
245
|
+
.boolean()
|
|
246
|
+
.optional()
|
|
247
|
+
.default(false)
|
|
248
|
+
.describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
|
|
249
|
+
"Protected names (main, master, dev, develop, stable, trunk, prod, production, " +
|
|
250
|
+
"release/*, hotfix/*) are always skipped. Never affects remote branches."),
|
|
251
|
+
deleteMergedWorktrees: z
|
|
252
|
+
.boolean()
|
|
253
|
+
.optional()
|
|
254
|
+
.default(false)
|
|
255
|
+
.describe("After all sources merge cleanly, remove any local worktree currently checked out " +
|
|
256
|
+
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
257
|
+
}),
|
|
258
|
+
execute: async (args) => {
|
|
259
|
+
const pre = requireGitAndRoots(server, args, undefined);
|
|
260
|
+
if (!pre.ok)
|
|
261
|
+
return jsonRespond(pre.error);
|
|
262
|
+
const rootInput = pre.roots[0];
|
|
263
|
+
if (!rootInput)
|
|
264
|
+
return jsonRespond({ error: "no_workspace_root" });
|
|
265
|
+
const gitTop = gitTopLevel(rootInput);
|
|
266
|
+
if (!gitTop)
|
|
267
|
+
return jsonRespond({ error: "not_a_git_repository", path: rootInput });
|
|
268
|
+
// --- Validate ref tokens early ---
|
|
269
|
+
for (const s of args.sources) {
|
|
270
|
+
if (!isSafeGitRefToken(s)) {
|
|
271
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: s });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
|
|
275
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.into });
|
|
276
|
+
}
|
|
277
|
+
// --- Resolve destination ---
|
|
278
|
+
const startBranch = await getCurrentBranch(gitTop);
|
|
279
|
+
const into = args.into?.trim() || startBranch;
|
|
280
|
+
if (!into) {
|
|
281
|
+
return jsonRespond({ error: "into_detached_head" });
|
|
282
|
+
}
|
|
283
|
+
// --- Refuse dirty tree ---
|
|
284
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
285
|
+
return jsonRespond({ error: "working_tree_dirty" });
|
|
286
|
+
}
|
|
287
|
+
// --- Ensure destination is checked out ---
|
|
288
|
+
if (into !== startBranch) {
|
|
289
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
290
|
+
if (!co.ok) {
|
|
291
|
+
return jsonRespond({
|
|
292
|
+
error: "checkout_failed",
|
|
293
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Verify destination exists after checkout.
|
|
298
|
+
const intoShaProbe = await resolveRef(gitTop, into);
|
|
299
|
+
if (!intoShaProbe) {
|
|
300
|
+
return jsonRespond({ error: "destination_not_found", ref: into });
|
|
301
|
+
}
|
|
302
|
+
// --- Merge each source sequentially ---
|
|
303
|
+
const strategy = args.strategy ?? "auto";
|
|
304
|
+
const results = [];
|
|
305
|
+
let firstConflict = false;
|
|
306
|
+
for (const source of args.sources) {
|
|
307
|
+
const r = await mergeOneSource(gitTop, into, source, strategy, args.message);
|
|
308
|
+
results.push(r);
|
|
309
|
+
if (!r.ok) {
|
|
310
|
+
firstConflict = true;
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const allOk = !firstConflict && results.every((r) => r.ok);
|
|
315
|
+
// --- Cleanup (only on full success) ---
|
|
316
|
+
if (allOk) {
|
|
317
|
+
for (let i = 0; i < results.length; i++) {
|
|
318
|
+
const r = results[i];
|
|
319
|
+
if (!r || r.outcome === "up_to_date")
|
|
320
|
+
continue;
|
|
321
|
+
const worktreeRemoved = await maybeRemoveWorktree(gitTop, r.source, args.deleteMergedWorktrees ?? false);
|
|
322
|
+
const branchDeleted = await maybeDeleteBranch(gitTop, r.source, args.deleteMergedBranches ?? false, into);
|
|
323
|
+
results[i] = {
|
|
324
|
+
...r,
|
|
325
|
+
...spreadDefined("worktreeRemoved", worktreeRemoved),
|
|
326
|
+
...spreadWhen(branchDeleted, { branchDeleted: true }),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
331
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
332
|
+
if (args.format === "json") {
|
|
333
|
+
return jsonRespond({
|
|
334
|
+
ok: allOk,
|
|
335
|
+
into,
|
|
336
|
+
strategy,
|
|
337
|
+
...spreadDefined("headSha", headSha),
|
|
338
|
+
applied: results.filter((r) => r.ok).length,
|
|
339
|
+
total: args.sources.length,
|
|
340
|
+
results: results.map((r) => ({
|
|
341
|
+
source: r.source,
|
|
342
|
+
ok: r.ok,
|
|
343
|
+
...spreadDefined("outcome", r.outcome),
|
|
344
|
+
...spreadDefined("mergedSha", r.mergedSha),
|
|
345
|
+
...spreadDefined("conflictStage", r.conflictStage),
|
|
346
|
+
...spreadWhen((r.conflictPaths?.length ?? 0) > 0, {
|
|
347
|
+
conflictPaths: r.conflictPaths,
|
|
348
|
+
}),
|
|
349
|
+
...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
|
|
350
|
+
...spreadDefined("worktreeRemoved", r.worktreeRemoved),
|
|
351
|
+
...spreadDefined("skipReason", r.skipReason),
|
|
352
|
+
...spreadDefined("error", r.error),
|
|
353
|
+
...spreadDefined("detail", r.detail),
|
|
354
|
+
})),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// --- Markdown ---
|
|
358
|
+
const lines = [];
|
|
359
|
+
const applied = results.filter((r) => r.ok).length;
|
|
360
|
+
const header = allOk
|
|
361
|
+
? `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied`
|
|
362
|
+
: `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied (stopped on conflict)`;
|
|
363
|
+
lines.push(header, "");
|
|
364
|
+
for (const r of results) {
|
|
365
|
+
const icon = r.ok ? "✓" : "✗";
|
|
366
|
+
const tail = [];
|
|
367
|
+
if (r.outcome)
|
|
368
|
+
tail.push(r.outcome);
|
|
369
|
+
if (r.mergedSha)
|
|
370
|
+
tail.push(`\`${r.mergedSha.slice(0, 7)}\``);
|
|
371
|
+
if (r.branchDeleted)
|
|
372
|
+
tail.push("branch deleted");
|
|
373
|
+
if (r.worktreeRemoved)
|
|
374
|
+
tail.push(`worktree removed: ${r.worktreeRemoved}`);
|
|
375
|
+
lines.push(`${icon} ${r.source}${tail.length ? ` — ${tail.join(", ")}` : ""}`);
|
|
376
|
+
if (!r.ok) {
|
|
377
|
+
if (r.conflictPaths?.length) {
|
|
378
|
+
for (const p of r.conflictPaths)
|
|
379
|
+
lines.push(` conflict: ${p}`);
|
|
380
|
+
}
|
|
381
|
+
if (r.error)
|
|
382
|
+
lines.push(` Error: ${r.error}${r.detail ? ` — ${r.detail}` : ""}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!allOk) {
|
|
386
|
+
const skipped = args.sources.length - results.length;
|
|
387
|
+
if (skipped > 0)
|
|
388
|
+
lines.push("", `${skipped} remaining source(s) skipped.`);
|
|
389
|
+
}
|
|
390
|
+
return lines.join("\n");
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawnGitAsync } from "./git.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Protected branch names — never auto-delete, never cascade destructive ops onto
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const PROTECTED_EXACT = new Set([
|
|
6
|
+
"main",
|
|
7
|
+
"master",
|
|
8
|
+
"dev",
|
|
9
|
+
"develop",
|
|
10
|
+
"stable",
|
|
11
|
+
"trunk",
|
|
12
|
+
"prod",
|
|
13
|
+
"production",
|
|
14
|
+
"HEAD",
|
|
15
|
+
]);
|
|
16
|
+
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
|
|
17
|
+
/** True when a branch name is on the protected list and must not be auto-deleted. */
|
|
18
|
+
export function isProtectedBranch(name) {
|
|
19
|
+
const t = name.trim();
|
|
20
|
+
if (t === "")
|
|
21
|
+
return true;
|
|
22
|
+
if (PROTECTED_EXACT.has(t))
|
|
23
|
+
return true;
|
|
24
|
+
return PROTECTED_PATTERN.test(t);
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Conservative check for branch/ref names passed to git argv.
|
|
31
|
+
* Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
|
|
32
|
+
* sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
|
|
33
|
+
* and pathological tokens.
|
|
34
|
+
*/
|
|
35
|
+
export function isSafeGitRefToken(s) {
|
|
36
|
+
const t = s.trim();
|
|
37
|
+
if (t.length === 0 || t.length > 256)
|
|
38
|
+
return false;
|
|
39
|
+
if (t.startsWith("-"))
|
|
40
|
+
return false;
|
|
41
|
+
if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith("."))
|
|
42
|
+
return false;
|
|
43
|
+
if (t.includes(".."))
|
|
44
|
+
return false;
|
|
45
|
+
if (t.includes("@{"))
|
|
46
|
+
return false;
|
|
47
|
+
if (t.includes("//"))
|
|
48
|
+
return false;
|
|
49
|
+
return /^[A-Za-z0-9_./+-]+$/.test(t);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
|
|
53
|
+
* used by `git log` / `git cherry-pick`. Splits once and validates each side.
|
|
54
|
+
*/
|
|
55
|
+
export function isSafeGitRangeToken(s) {
|
|
56
|
+
const t = s.trim();
|
|
57
|
+
if (t.includes("...")) {
|
|
58
|
+
const parts = t.split("...");
|
|
59
|
+
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
|
|
60
|
+
}
|
|
61
|
+
if (t.includes("..")) {
|
|
62
|
+
const parts = t.split("..");
|
|
63
|
+
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
|
|
64
|
+
}
|
|
65
|
+
return isSafeGitRefToken(t);
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Async helpers
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
/** Current branch name; `null` if detached HEAD. */
|
|
71
|
+
export async function getCurrentBranch(cwd) {
|
|
72
|
+
const r = await spawnGitAsync(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]);
|
|
73
|
+
if (!r.ok)
|
|
74
|
+
return null;
|
|
75
|
+
const name = r.stdout.trim();
|
|
76
|
+
return name === "" ? null : name;
|
|
77
|
+
}
|
|
78
|
+
/** Resolve a ref to its full SHA; `null` if unknown. */
|
|
79
|
+
export async function resolveRef(cwd, ref) {
|
|
80
|
+
if (!isSafeGitRefToken(ref))
|
|
81
|
+
return null;
|
|
82
|
+
const r = await spawnGitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return null;
|
|
85
|
+
const sha = r.stdout.trim();
|
|
86
|
+
return sha === "" ? null : sha;
|
|
87
|
+
}
|
|
88
|
+
/** Working tree clean (no staged, no unstaged, no untracked). */
|
|
89
|
+
export async function isWorkingTreeClean(cwd) {
|
|
90
|
+
const r = await spawnGitAsync(cwd, ["status", "--porcelain"]);
|
|
91
|
+
if (!r.ok)
|
|
92
|
+
return false;
|
|
93
|
+
return r.stdout.trim() === "";
|
|
94
|
+
}
|
|
95
|
+
/** True when every commit on `branch` is reachable from `target`. */
|
|
96
|
+
export async function isFullyMergedInto(cwd, branch, target) {
|
|
97
|
+
if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target))
|
|
98
|
+
return false;
|
|
99
|
+
const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
|
|
100
|
+
return r.ok;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
|
|
104
|
+
* Returns `null` on git failure.
|
|
105
|
+
*/
|
|
106
|
+
export async function commitListBetween(cwd, excludeRef, includeRef) {
|
|
107
|
+
if (!isSafeGitRefToken(excludeRef) || !isSafeGitRefToken(includeRef))
|
|
108
|
+
return null;
|
|
109
|
+
const r = await spawnGitAsync(cwd, ["rev-list", "--reverse", `${excludeRef}..${includeRef}`]);
|
|
110
|
+
if (!r.ok)
|
|
111
|
+
return null;
|
|
112
|
+
return r.stdout
|
|
113
|
+
.split("\n")
|
|
114
|
+
.map((l) => l.trim())
|
|
115
|
+
.filter((l) => l.length > 0);
|
|
116
|
+
}
|
|
117
|
+
/** Parse `git worktree list --porcelain` into structured entries. */
|
|
118
|
+
export async function listWorktrees(cwd) {
|
|
119
|
+
const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
|
|
120
|
+
if (!r.ok)
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
let cur = {};
|
|
124
|
+
for (const line of r.stdout.split("\n")) {
|
|
125
|
+
if (line.startsWith("worktree ")) {
|
|
126
|
+
if (cur.path)
|
|
127
|
+
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
|
|
128
|
+
cur = { path: line.slice("worktree ".length).trim() };
|
|
129
|
+
}
|
|
130
|
+
else if (line.startsWith("HEAD ")) {
|
|
131
|
+
cur.head = line.slice("HEAD ".length).trim();
|
|
132
|
+
}
|
|
133
|
+
else if (line.startsWith("branch ")) {
|
|
134
|
+
// e.g. `branch refs/heads/foo`
|
|
135
|
+
const ref = line.slice("branch ".length).trim();
|
|
136
|
+
cur.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
|
|
137
|
+
}
|
|
138
|
+
else if (line === "detached") {
|
|
139
|
+
cur.branch = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (cur.path)
|
|
143
|
+
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Path of the worktree currently checked out on `branch`; `null` if none. */
|
|
147
|
+
export async function worktreeForBranch(cwd, branch) {
|
|
148
|
+
const trees = await listWorktrees(cwd);
|
|
149
|
+
const hit = trees.find((t) => t.branch === branch);
|
|
150
|
+
return hit?.path ?? null;
|
|
151
|
+
}
|
package/dist/server/tools.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { registerBatchCommitTool } from "./batch-commit-tool.js";
|
|
2
|
+
import { registerGitCherryPickTool } from "./git-cherry-pick-tool.js";
|
|
2
3
|
import { registerGitDiffSummaryTool } from "./git-diff-summary-tool.js";
|
|
3
4
|
import { registerGitInventoryTool } from "./git-inventory-tool.js";
|
|
4
5
|
import { registerGitLogTool } from "./git-log-tool.js";
|
|
6
|
+
import { registerGitMergeTool } from "./git-merge-tool.js";
|
|
5
7
|
import { registerGitParityTool } from "./git-parity-tool.js";
|
|
6
8
|
import { registerGitStatusTool } from "./git-status-tool.js";
|
|
7
9
|
import { registerListPresetsTool } from "./list-presets-tool.js";
|
|
@@ -14,5 +16,7 @@ export function registerRethunkGitTools(server) {
|
|
|
14
16
|
registerBatchCommitTool(server);
|
|
15
17
|
registerGitDiffSummaryTool(server);
|
|
16
18
|
registerGitLogTool(server);
|
|
19
|
+
registerGitMergeTool(server);
|
|
20
|
+
registerGitCherryPickTool(server);
|
|
17
21
|
registerPresetsResource(server);
|
|
18
22
|
}
|
package/docs/mcp-tools.md
CHANGED
|
@@ -17,7 +17,9 @@ MCP clients expose tools as `{serverName}_{toolName}`. With the server registere
|
|
|
17
17
|
| `list_presets` | `rethunk-git_list_presets` | List preset names/counts from `.rethunk/git-mcp-presets.json`; invalid JSON/schema surface as errors. Workspace pick + `format` only. |
|
|
18
18
|
| `git_log` | `rethunk-git_git_log` | Path-filtered, time-windowed `git log` across one or more workspace roots. Returns commit history with author, date, subject, and shortstat. Args: `since`, `paths`, `grep`, `author`, `maxCommits`, `branch`, plus workspace pick args + `format`. |
|
|
19
19
|
| `git_diff_summary` | `rethunk-git_git_diff_summary` | Structured, token-efficient diff viewer. Returns per-file diffs with additions/deletions counts, truncated to configurable line limits, with lock files/dist/vendor excluded by default. Args: `range`, `fileFilter`, `maxLinesPerFile`, `maxFiles`, `excludePatterns`, plus workspace pick args + `format`. **Read-only.** |
|
|
20
|
-
| `batch_commit` | `rethunk-git_batch_commit` | Create multiple sequential git commits in a single call. Each entry stages the listed files then commits with the given message. Stops on first failure. Args: `commits` (array of `{message, files}`), plus workspace pick args + `format`. **Mutating — not idempotent.** |
|
|
20
|
+
| `batch_commit` | `rethunk-git_batch_commit` | Create multiple sequential git commits in a single call. Each entry stages the listed files then commits with the given message. Stops on first failure. Optional `push: "after"` pushes the current branch to its upstream once every commit lands. Args: `commits` (array of `{message, files}`), `push?`, plus workspace pick args + `format`. **Mutating — not idempotent.** |
|
|
21
|
+
| `git_merge` | `rethunk-git_git_merge` | 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\*). Args: `sources`, `into?`, `strategy?`, `message?`, cleanup flags + workspace pick + `format`. **Mutating.** |
|
|
22
|
+
| `git_cherry_pick` | `rethunk-git_git_cherry_pick` | 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 cleanup flags as `git_merge` (branch-kind sources only, protected names skipped). Args: `sources`, `onto?`, cleanup flags + workspace pick + `format`. **Mutating.** |
|
|
21
23
|
|
|
22
24
|
Pass **`format: "json"`** on any tool for structured JSON instead of markdown (default).
|
|
23
25
|
|
|
@@ -152,6 +154,7 @@ v2 field-omission rules: `filesChanged`, `insertions`, `deletions` are omitted w
|
|
|
152
154
|
| Parameter | Type | Notes |
|
|
153
155
|
|-----------|------|-------|
|
|
154
156
|
| `commits` | `{message: string, files: string[]}[]` | Commits to create in order. 1–50 entries. Each `files` entry is a path relative to the git root; all must stay within the git toplevel. |
|
|
157
|
+
| `push` | `"never"` \| `"after"` | Default `"never"`. `"after"` pushes the current branch to its upstream **once all commits succeed**. Never auto-sets upstream — branches without an upstream fail with `push_no_upstream`. Commits are **not** rolled back on push failure. Enum reserved for future modes such as `"force-with-lease"`. |
|
|
155
158
|
| `workspaceRoot` | string | Explicit root; highest priority. |
|
|
156
159
|
| `rootIndex` | int | Pick one of several MCP roots (0-based). |
|
|
157
160
|
| `format` | `"markdown"` \| `"json"` | Output format. Default: `"markdown"`. |
|
|
@@ -175,12 +178,19 @@ v2 field-omission rules: `filesChanged`, `insertions`, `deletions` are omitted w
|
|
|
175
178
|
"sha": "b2c3d4e",
|
|
176
179
|
"message": "chore: update config",
|
|
177
180
|
"files": ["config.json"]
|
|
178
|
-
}]
|
|
181
|
+
}],
|
|
182
|
+
"push": {
|
|
183
|
+
"ok": true,
|
|
184
|
+
"branch": "main",
|
|
185
|
+
"upstream": "origin/main"
|
|
186
|
+
}
|
|
179
187
|
}
|
|
180
188
|
```
|
|
181
189
|
|
|
182
190
|
On first failure `ok` is `false`, `committed` reflects only the entries that succeeded before the error, and the failing entry includes `error` and `detail` fields. Remaining entries are skipped and not included in `results`.
|
|
183
191
|
|
|
192
|
+
The `push` object is present only when `push: "after"` was requested **and** every commit landed. On push failure the top-level `ok` stays `true` (the commits themselves succeeded) while `push.ok` is `false` and `push.error` carries the code.
|
|
193
|
+
|
|
184
194
|
### `batch_commit` — error codes (per-result `error` field)
|
|
185
195
|
|
|
186
196
|
| Code | Meaning |
|
|
@@ -190,6 +200,143 @@ On first failure `ok` is `false`, `committed` reflects only the entries that suc
|
|
|
190
200
|
| `commit_failed` | `git commit` failed (e.g. nothing staged, hooks rejected). |
|
|
191
201
|
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
192
202
|
|
|
203
|
+
### `batch_commit` — push error codes (`push.error` field)
|
|
204
|
+
|
|
205
|
+
| Code | Meaning |
|
|
206
|
+
|------|---------|
|
|
207
|
+
| `push_detached_head` | HEAD is detached; no branch to push. |
|
|
208
|
+
| `push_no_upstream` | Current branch has no configured upstream. `batch_commit` will not auto-set one — do `git push -u origin <branch>` yourself (or re-run without `push`). |
|
|
209
|
+
| `push_failed` | `git push` exited non-zero (network error, non-fast-forward, hook rejection). `detail` carries the stderr/stdout from git. |
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
### `git_merge` — parameters
|
|
214
|
+
|
|
215
|
+
| Parameter | Type | Notes |
|
|
216
|
+
|-----------|------|-------|
|
|
217
|
+
| `sources` | `string[]` | Source branches to merge, in order. 1–20 entries. Each must be a valid git ref token. |
|
|
218
|
+
| `into` | string | Destination branch. Defaults to the currently checked-out branch. Rejected when HEAD is detached. |
|
|
219
|
+
| `strategy` | `"auto"` \| `"ff-only"` \| `"rebase"` \| `"merge"` | Default `"auto"`: cascade **fast-forward → rebase → merge-commit** per source. `"ff-only"` fails on divergence. `"rebase"` rebases source onto destination and fast-forwards; no merge-commit fallback. `"merge"` always creates a merge commit (`--no-ff`). |
|
|
220
|
+
| `message` | string | Merge commit message, used only when a merge commit is created. Defaults to `Merge branch '<source>' into <into>`. |
|
|
221
|
+
| `deleteMergedBranches` | boolean | Default `false`. After **all** sources land cleanly, delete each source branch locally (`git branch -d`). **Protected names always skipped** (main, master, dev, develop, stable, trunk, prod, production, `release/*`, `release-*`, `hotfix/*`, `hotfix-*`). Never touches remote refs. |
|
|
222
|
+
| `deleteMergedWorktrees` | boolean | Default `false`. After success, remove any local worktree currently checked out on a source branch (`git worktree remove`). Protected tails always skipped. |
|
|
223
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
224
|
+
|
|
225
|
+
### `git_merge` — JSON shape (`format: "json"`)
|
|
226
|
+
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"ok": true,
|
|
230
|
+
"into": "main",
|
|
231
|
+
"strategy": "auto",
|
|
232
|
+
"headSha": "a1b2c3d4e5f6…",
|
|
233
|
+
"applied": 2,
|
|
234
|
+
"total": 2,
|
|
235
|
+
"results": [
|
|
236
|
+
{
|
|
237
|
+
"source": "feature/a",
|
|
238
|
+
"ok": true,
|
|
239
|
+
"outcome": "fast_forward",
|
|
240
|
+
"mergedSha": "a1b2c3d4e5f6…",
|
|
241
|
+
"branchDeleted": true,
|
|
242
|
+
"worktreeRemoved": "/tmp/agent-a"
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"source": "feature/b",
|
|
246
|
+
"ok": true,
|
|
247
|
+
"outcome": "rebase_then_ff",
|
|
248
|
+
"mergedSha": "b2c3d4e5f6a1…"
|
|
249
|
+
}
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**`outcome`** (per source): `fast_forward`, `rebase_then_ff`, `merge_commit`, `up_to_date`, or `conflicts`. Cleanup fields (`branchDeleted`, `worktreeRemoved`) are only emitted when the corresponding flag was set and the operation actually ran — both are omitted for up-to-date sources and are never populated on partial-failure runs.
|
|
255
|
+
|
|
256
|
+
On conflict: top-level `ok` is `false`, the conflicting entry has `ok: false` with `conflictStage` (`"rebase"` or `"merge"`), `conflictPaths` (array of paths with unresolved markers), and an `error` code. Remaining sources are not attempted.
|
|
257
|
+
|
|
258
|
+
### `git_merge` — error codes
|
|
259
|
+
|
|
260
|
+
| Code | Meaning |
|
|
261
|
+
|------|---------|
|
|
262
|
+
| `unsafe_ref_token` | A source or `into` contains characters outside the argv-safe subset (spaces, shell meta, `..`, `@{`, leading `-`, trailing `.lock`). |
|
|
263
|
+
| `into_detached_head` | HEAD is detached and no `into` was given — the tool needs a concrete destination branch. |
|
|
264
|
+
| `working_tree_dirty` | Uncommitted changes present. Commit, stash, or discard before merging. |
|
|
265
|
+
| `checkout_failed` | Could not switch to `into`. `detail` carries git's stderr. |
|
|
266
|
+
| `destination_not_found` | `into` does not resolve to a commit. |
|
|
267
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
268
|
+
| `source_not_found` (per source) | A source branch name does not resolve. |
|
|
269
|
+
| `cannot_fast_forward` (per source) | `strategy: "ff-only"` refused because branches have diverged. |
|
|
270
|
+
| `rebase_conflicts` (per source) | Rebase encountered conflicts. Repo state is cleaned before returning. |
|
|
271
|
+
| `merge_conflicts` (per source) | Merge commit encountered conflicts. Repo state is cleaned before returning. |
|
|
272
|
+
| `merge_failed` (per source) | `git merge --ff-only` failed unexpectedly. `detail` carries stderr. |
|
|
273
|
+
| `merge_base_failed` (per source) | `git merge-base` failed (usually unrelated histories). |
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
### `git_cherry_pick` — parameters
|
|
278
|
+
|
|
279
|
+
| Parameter | Type | Notes |
|
|
280
|
+
|-----------|------|-------|
|
|
281
|
+
| `sources` | `string[]` | Source specs. 1–50 entries. Each entry is one of: a full/short SHA, an `A..B` / `A...B` range, or a branch name. Branch names expand to `onto..<branch>` (oldest-first). |
|
|
282
|
+
| `onto` | string | Destination branch. Defaults to the currently checked-out branch. Rejected when HEAD is detached. |
|
|
283
|
+
| `deleteMergedBranches` | boolean | Default `false`. After all commits apply, delete each **branch-kind** source locally (`git branch -d`) when it is fully merged into the destination by SHA-reachability (not patch-equivalence). Protected names always skipped; never touches remote refs. |
|
|
284
|
+
| `deleteMergedWorktrees` | boolean | Default `false`. After success, remove any local worktree attached to a branch-kind source (`git worktree remove`). Protected tails always skipped. |
|
|
285
|
+
| `workspaceRoot`, `rootIndex`, `format` | — | Standard workspace pick + output format. |
|
|
286
|
+
|
|
287
|
+
### `git_cherry_pick` — JSON shape (`format: "json"`)
|
|
288
|
+
|
|
289
|
+
```json
|
|
290
|
+
{
|
|
291
|
+
"ok": true,
|
|
292
|
+
"onto": "main",
|
|
293
|
+
"headSha": "a1b2c3d…",
|
|
294
|
+
"picked": 3,
|
|
295
|
+
"applied": 2,
|
|
296
|
+
"results": [
|
|
297
|
+
{ "source": "feature/a", "kind": "branch", "resolvedCommits": 2, "keptCommits": 2 },
|
|
298
|
+
{ "source": "abcdef1", "kind": "sha", "resolvedCommits": 1, "keptCommits": 1 }
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**`picked`** is the number of unique SHAs fed to `git cherry-pick` after SHA-reachability filtering. **`applied`** is the number of new commits actually added to HEAD — may be less than `picked` because the tool passes `--empty=drop` to git, so patch-equivalent commits are skipped at apply time without error.
|
|
304
|
+
|
|
305
|
+
**`kind`** is `"sha"`, `"range"`, or `"branch"`. **`resolvedCommits`** is how many commits the source expanded to; **`keptCommits`** is how many survived SHA-reachability dedupe. Cleanup fields (`branchDeleted`, `worktreeRemoved`) are only emitted for branch-kind sources when the corresponding flag was set and the operation succeeded.
|
|
306
|
+
|
|
307
|
+
On conflict, the response has `ok: false` and a top-level `conflict` object:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"ok": false,
|
|
312
|
+
"onto": "main",
|
|
313
|
+
"picked": 2,
|
|
314
|
+
"applied": 0,
|
|
315
|
+
"results": [ ... ],
|
|
316
|
+
"conflict": {
|
|
317
|
+
"stage": "cherry-pick",
|
|
318
|
+
"commit": "abcdef1",
|
|
319
|
+
"paths": ["src/foo.ts"],
|
|
320
|
+
"detail": "…git stderr…"
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Repo state is cleaned (`git cherry-pick --abort`) before returning — no partially-applied index.
|
|
326
|
+
|
|
327
|
+
### `git_cherry_pick` — error codes
|
|
328
|
+
|
|
329
|
+
| Code | Meaning |
|
|
330
|
+
|------|---------|
|
|
331
|
+
| `unsafe_ref_token` | A source or `onto` contains characters outside the argv-safe subset. |
|
|
332
|
+
| `onto_detached_head` | HEAD is detached and no `onto` was given. |
|
|
333
|
+
| `working_tree_dirty` | Uncommitted changes present. Commit, stash, or discard before cherry-picking. |
|
|
334
|
+
| `checkout_failed` | Could not switch to `onto`. |
|
|
335
|
+
| `destination_not_found` | `onto` does not resolve to a commit. |
|
|
336
|
+
| `source_not_found` | A source spec resolves to neither a branch, a range, nor a commit. |
|
|
337
|
+
| `range_resolution_failed` | `git rev-list` failed to expand a range spec. |
|
|
338
|
+
| `not_a_git_repository` | The resolved workspace root is not inside a git repository. |
|
|
339
|
+
|
|
193
340
|
---
|
|
194
341
|
|
|
195
342
|
## Resource
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rethunk/mcp-multi-root-git",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "MCP stdio server: multi-root git status, inventory, and HEAD parity checks. Generic tools usable by any workspace.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"docs/install.md",
|
|
15
15
|
"docs/mcp-tools.md",
|
|
16
16
|
"AGENTS.md",
|
|
17
|
+
"CHANGELOG.md",
|
|
17
18
|
"HUMANS.md",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
@@ -59,9 +60,9 @@
|
|
|
59
60
|
"zod": "^4.3.6"
|
|
60
61
|
},
|
|
61
62
|
"devDependencies": {
|
|
62
|
-
"@biomejs/biome": "^2.4.
|
|
63
|
-
"@types/node": "^22.
|
|
64
|
-
"rimraf": "^6.
|
|
63
|
+
"@biomejs/biome": "^2.4.11",
|
|
64
|
+
"@types/node": "^22.19.17",
|
|
65
|
+
"rimraf": "^6.1.3",
|
|
65
66
|
"typescript": "^6.0.2"
|
|
66
67
|
}
|
|
67
68
|
}
|