@rethunk/mcp-multi-root-git 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +95 -0
- package/README.md +1 -1
- package/dist/server/batch-commit-tool.js +61 -13
- package/dist/server/git-cherry-pick-tool.js +285 -0
- package/dist/server/git-diff-summary-tool.js +5 -12
- package/dist/server/git-inventory-tool.js +4 -1
- package/dist/server/git-log-tool.js +16 -18
- package/dist/server/git-merge-tool.js +378 -0
- package/dist/server/git-parity-tool.js +4 -1
- package/dist/server/git-push-tool.js +109 -0
- package/dist/server/git-refs.js +192 -0
- package/dist/server/git-reset-soft-tool.js +82 -0
- package/dist/server/git-status-tool.js +4 -1
- package/dist/server/git-worktree-tool.js +188 -0
- package/dist/server/list-presets-tool.js +4 -1
- package/dist/server/roots.js +17 -0
- package/dist/server/schemas.js +11 -2
- package/dist/server/test-harness.js +27 -0
- package/dist/server/tools.js +17 -2
- package/docs/mcp-tools.md +261 -10
- package/package.json +5 -4
|
@@ -14,12 +14,13 @@ const DEFAULT_SINCE = "7.days";
|
|
|
14
14
|
// The format string itself is safe ASCII; git emits the byte.
|
|
15
15
|
const FIELD_SEP_OUT = "\x01"; // what git outputs (SOH)
|
|
16
16
|
const RECORD_SEP_OUT = "\x02"; // what git outputs (STX) — used as record-START marker
|
|
17
|
-
// git log --pretty tformat:
|
|
17
|
+
// git log --pretty tformat: shaFull, subject, author, email, ISO date.
|
|
18
|
+
// sha7 and ageRelative dropped in v3 (shaFull.slice(0,7) used for display; date is ISO).
|
|
18
19
|
// %x02 is placed at the START of each record (tformat adds \n as terminator after each).
|
|
19
20
|
// Splitting stdout on \x02 then gives empty-first-chunk + one chunk per commit,
|
|
20
21
|
// each structured as: <fields>\x01\n\n <shortstat text>\n
|
|
21
22
|
// Fields are separated by %x01; the trailing \x01 before \n leaves one empty last field (ignored).
|
|
22
|
-
const PRETTY_FORMAT = "%x02%
|
|
23
|
+
const PRETTY_FORMAT = "%x02%H%x01%s%x01%aN%x01%aE%x01%aI%x01";
|
|
23
24
|
// ---------------------------------------------------------------------------
|
|
24
25
|
// Helpers
|
|
25
26
|
// ---------------------------------------------------------------------------
|
|
@@ -101,18 +102,16 @@ async function runGitLog(opts) {
|
|
|
101
102
|
const fieldsPart = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk;
|
|
102
103
|
const statPart = newlineIdx >= 0 ? chunk.slice(newlineIdx + 1) : "";
|
|
103
104
|
const fields = fieldsPart.split(FIELD_SEP_OUT);
|
|
104
|
-
const [
|
|
105
|
-
if (!
|
|
105
|
+
const [shaFull, subject, authorName, email, date] = fields;
|
|
106
|
+
if (!shaFull)
|
|
106
107
|
continue;
|
|
107
108
|
const stat = parseShortstat(statPart);
|
|
108
109
|
const commit = {
|
|
109
|
-
|
|
110
|
-
shaFull: shaFull.trim(),
|
|
110
|
+
sha: shaFull.trim(),
|
|
111
111
|
subject: subject?.trim() ?? "",
|
|
112
112
|
author: authorName?.trim() ?? "",
|
|
113
|
-
email
|
|
113
|
+
...spreadDefined("email", email?.trim() || undefined),
|
|
114
114
|
date: date?.trim() ?? "",
|
|
115
|
-
ageRelative: ageRelative?.trim() ?? "",
|
|
116
115
|
...spreadDefined("filesChanged", stat?.filesChanged),
|
|
117
116
|
...spreadDefined("insertions", stat?.insertions),
|
|
118
117
|
...spreadDefined("deletions", stat?.deletions),
|
|
@@ -123,7 +122,7 @@ async function runGitLog(opts) {
|
|
|
123
122
|
const commits = truncated ? allCommits.slice(0, maxCommits) : allCommits;
|
|
124
123
|
const omittedCount = truncated ? allCommits.length - maxCommits : 0;
|
|
125
124
|
return {
|
|
126
|
-
|
|
125
|
+
workspaceRoot: top,
|
|
127
126
|
repo: basename(top),
|
|
128
127
|
branch: resolvedBranch,
|
|
129
128
|
commits,
|
|
@@ -137,14 +136,14 @@ async function runGitLog(opts) {
|
|
|
137
136
|
function renderLogMarkdown(group, filterSummary) {
|
|
138
137
|
const lines = [];
|
|
139
138
|
lines.push(`### ${group.repo} (${group.branch})${filterSummary ? ` — ${filterSummary}` : ""}`);
|
|
140
|
-
lines.push(`_root: ${group.
|
|
139
|
+
lines.push(`_root: ${group.workspaceRoot}_`);
|
|
141
140
|
lines.push("");
|
|
142
141
|
if (group.commits.length === 0) {
|
|
143
142
|
lines.push("_(no commits match)_");
|
|
144
143
|
}
|
|
145
144
|
else {
|
|
146
145
|
for (const c of group.commits) {
|
|
147
|
-
lines.push(`- \`${c.
|
|
146
|
+
lines.push(`- \`${c.sha.slice(0, 7)}\` ${c.date.slice(0, 10)} ${c.subject} — ${c.author}`);
|
|
148
147
|
}
|
|
149
148
|
}
|
|
150
149
|
if (group.truncated) {
|
|
@@ -160,8 +159,7 @@ export function registerGitLogTool(server) {
|
|
|
160
159
|
server.addTool({
|
|
161
160
|
name: "git_log",
|
|
162
161
|
description: "Path-filtered, time-windowed read-only `git log` across one or more workspace roots. " +
|
|
163
|
-
"Returns structured commit history with author, date, subject, and optional diff stats.
|
|
164
|
-
"See docs/mcp-tools.md.",
|
|
162
|
+
"Returns structured commit history with author, date, subject, and optional diff stats.",
|
|
165
163
|
annotations: {
|
|
166
164
|
readOnlyHint: true,
|
|
167
165
|
},
|
|
@@ -216,7 +214,7 @@ export function registerGitLogTool(server) {
|
|
|
216
214
|
const results = await asyncPool(jobs, GIT_SUBPROCESS_PARALLELISM, async ({ rootInput }) => {
|
|
217
215
|
const top = gitTopLevel(rootInput);
|
|
218
216
|
if (!top) {
|
|
219
|
-
return { _error: true,
|
|
217
|
+
return { _error: true, workspaceRoot: rootInput, error: "not_a_git_repository" };
|
|
220
218
|
}
|
|
221
219
|
const r = await runGitLog({
|
|
222
220
|
top,
|
|
@@ -228,7 +226,7 @@ export function registerGitLogTool(server) {
|
|
|
228
226
|
branch: args.branch,
|
|
229
227
|
});
|
|
230
228
|
if ("error" in r) {
|
|
231
|
-
return { _error: true,
|
|
229
|
+
return { _error: true, workspaceRoot: rootInput, error: r.error };
|
|
232
230
|
}
|
|
233
231
|
return { _error: false, ...r };
|
|
234
232
|
});
|
|
@@ -245,8 +243,8 @@ export function registerGitLogTool(server) {
|
|
|
245
243
|
const groups = results.map((r) => {
|
|
246
244
|
if (r._error) {
|
|
247
245
|
return {
|
|
248
|
-
|
|
249
|
-
repo: basename(r.
|
|
246
|
+
workspaceRoot: r.workspaceRoot,
|
|
247
|
+
repo: basename(r.workspaceRoot ?? ""),
|
|
250
248
|
branch: "",
|
|
251
249
|
commits: [],
|
|
252
250
|
...spreadWhen(true, { error: r.error }),
|
|
@@ -264,7 +262,7 @@ export function registerGitLogTool(server) {
|
|
|
264
262
|
const mdChunks = ["# Git log"];
|
|
265
263
|
for (const r of results) {
|
|
266
264
|
if (r._error) {
|
|
267
|
-
mdChunks.push(`### ${r.
|
|
265
|
+
mdChunks.push(`### ${r.workspaceRoot}\n_error: ${r.error}_`);
|
|
268
266
|
continue;
|
|
269
267
|
}
|
|
270
268
|
const { _error: _e, ...group } = r;
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { spawnGitAsync } from "./git.js";
|
|
3
|
+
import { conflictPaths, getCurrentBranch, isFullyMergedInto, isProtectedBranch, isSafeGitRefToken, isWorkingTreeClean, resolveRef, worktreeForBranch, } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined, spreadWhen } from "./json.js";
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// 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
|
+
// Abort helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
async function abortMerge(gitTop) {
|
|
22
|
+
await spawnGitAsync(gitTop, ["merge", "--abort"]);
|
|
23
|
+
}
|
|
24
|
+
async function abortRebase(gitTop) {
|
|
25
|
+
await spawnGitAsync(gitTop, ["rebase", "--abort"]);
|
|
26
|
+
}
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Per-source merge logic
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Attempt to land `source` on `into`. Caller must ensure `into` is checked out.
|
|
32
|
+
* On conflict the repo state is cleaned (merge/rebase aborted, HEAD restored to `into`).
|
|
33
|
+
*/
|
|
34
|
+
async function mergeOneSource(gitTop, into, source, strategy, mergeMessage) {
|
|
35
|
+
// --- Classify state via merge-base ---
|
|
36
|
+
const intoSha = await resolveRef(gitTop, into);
|
|
37
|
+
const sourceSha = await resolveRef(gitTop, source);
|
|
38
|
+
if (!sourceSha) {
|
|
39
|
+
return { source, ok: false, error: "source_not_found" };
|
|
40
|
+
}
|
|
41
|
+
if (!intoSha) {
|
|
42
|
+
return { source, ok: false, error: "destination_not_found" };
|
|
43
|
+
}
|
|
44
|
+
const mb = await spawnGitAsync(gitTop, ["merge-base", into, source]);
|
|
45
|
+
if (!mb.ok) {
|
|
46
|
+
return {
|
|
47
|
+
source,
|
|
48
|
+
ok: false,
|
|
49
|
+
error: "merge_base_failed",
|
|
50
|
+
detail: (mb.stderr || mb.stdout).trim(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const mergeBase = mb.stdout.trim();
|
|
54
|
+
// source fully contained in into → noop
|
|
55
|
+
if (mergeBase === sourceSha) {
|
|
56
|
+
return { source, ok: true, outcome: "up_to_date", mergedSha: intoSha };
|
|
57
|
+
}
|
|
58
|
+
// into fully contained in source → fast-forward is the right move
|
|
59
|
+
const canFastForward = mergeBase === intoSha;
|
|
60
|
+
// --- ff-only strategy ---
|
|
61
|
+
if (strategy === "ff-only") {
|
|
62
|
+
if (!canFastForward) {
|
|
63
|
+
return {
|
|
64
|
+
source,
|
|
65
|
+
ok: false,
|
|
66
|
+
error: "cannot_fast_forward",
|
|
67
|
+
detail: "destination and source have diverged; retry with strategy: rebase, merge, or auto",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return fastForward(gitTop, source);
|
|
71
|
+
}
|
|
72
|
+
// --- Fast-forward path for auto/rebase when applicable ---
|
|
73
|
+
if (canFastForward && (strategy === "auto" || strategy === "rebase")) {
|
|
74
|
+
return fastForward(gitTop, source);
|
|
75
|
+
}
|
|
76
|
+
// --- merge strategy: always merge commit ---
|
|
77
|
+
if (strategy === "merge") {
|
|
78
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
79
|
+
}
|
|
80
|
+
// --- rebase or auto (diverged case) ---
|
|
81
|
+
const rebased = await rebaseSourceOntoInto(gitTop, into, source);
|
|
82
|
+
if (rebased.ok) {
|
|
83
|
+
// Rebase succeeded; FF destination up to the now-rebased source tip.
|
|
84
|
+
const ff = await fastForward(gitTop, source);
|
|
85
|
+
if (!ff.ok)
|
|
86
|
+
return ff;
|
|
87
|
+
return { ...ff, outcome: "rebase_then_ff" };
|
|
88
|
+
}
|
|
89
|
+
if (strategy === "rebase") {
|
|
90
|
+
return rebased; // caller opted out of merge-commit fallback
|
|
91
|
+
}
|
|
92
|
+
// auto: fall through to merge commit
|
|
93
|
+
return mergeCommit(gitTop, source, mergeMessage, into);
|
|
94
|
+
}
|
|
95
|
+
async function fastForward(gitTop, source) {
|
|
96
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--ff-only", source]);
|
|
97
|
+
if (!r.ok) {
|
|
98
|
+
await abortMerge(gitTop);
|
|
99
|
+
return {
|
|
100
|
+
source,
|
|
101
|
+
ok: false,
|
|
102
|
+
outcome: "conflicts",
|
|
103
|
+
conflictStage: "merge",
|
|
104
|
+
conflictPaths: [],
|
|
105
|
+
error: "merge_failed",
|
|
106
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
110
|
+
return {
|
|
111
|
+
source,
|
|
112
|
+
ok: true,
|
|
113
|
+
outcome: "fast_forward",
|
|
114
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
async function mergeCommit(gitTop, source, message, into) {
|
|
118
|
+
const msg = message?.trim() || `Merge branch '${source}' into ${into}`;
|
|
119
|
+
const r = await spawnGitAsync(gitTop, ["merge", "--no-ff", "--no-edit", "-m", msg, source]);
|
|
120
|
+
if (!r.ok) {
|
|
121
|
+
const paths = await conflictPaths(gitTop);
|
|
122
|
+
await abortMerge(gitTop);
|
|
123
|
+
return {
|
|
124
|
+
source,
|
|
125
|
+
ok: false,
|
|
126
|
+
outcome: "conflicts",
|
|
127
|
+
conflictStage: "merge",
|
|
128
|
+
conflictPaths: paths,
|
|
129
|
+
error: "merge_conflicts",
|
|
130
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const head = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
134
|
+
return {
|
|
135
|
+
source,
|
|
136
|
+
ok: true,
|
|
137
|
+
outcome: "merge_commit",
|
|
138
|
+
mergedSha: head.ok ? head.stdout.trim() : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Rebase `source` onto `into`, then return to `into`.
|
|
143
|
+
* On failure: abort rebase, check out `into` again, return structured conflict.
|
|
144
|
+
*/
|
|
145
|
+
async function rebaseSourceOntoInto(gitTop, into, source) {
|
|
146
|
+
// `git rebase <upstream> <branch>` first switches to <branch>.
|
|
147
|
+
const r = await spawnGitAsync(gitTop, ["rebase", into, source]);
|
|
148
|
+
if (!r.ok) {
|
|
149
|
+
const paths = await conflictPaths(gitTop);
|
|
150
|
+
await abortRebase(gitTop);
|
|
151
|
+
// Ensure we're back on `into` regardless of rebase state.
|
|
152
|
+
await spawnGitAsync(gitTop, ["checkout", into]);
|
|
153
|
+
return {
|
|
154
|
+
source,
|
|
155
|
+
ok: false,
|
|
156
|
+
outcome: "conflicts",
|
|
157
|
+
conflictStage: "rebase",
|
|
158
|
+
conflictPaths: paths,
|
|
159
|
+
error: "rebase_conflicts",
|
|
160
|
+
detail: (r.stderr || r.stdout).trim(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
// Rebase succeeded; switch back to destination so the caller can FF.
|
|
164
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
165
|
+
if (!co.ok) {
|
|
166
|
+
return {
|
|
167
|
+
source,
|
|
168
|
+
ok: false,
|
|
169
|
+
error: "checkout_failed",
|
|
170
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return { source, ok: true }; // caller FFs
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Cleanup helpers
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
async function maybeRemoveWorktree(gitTop, source, enabled) {
|
|
179
|
+
if (!enabled)
|
|
180
|
+
return undefined;
|
|
181
|
+
const path = await worktreeForBranch(gitTop, source);
|
|
182
|
+
if (!path)
|
|
183
|
+
return undefined;
|
|
184
|
+
// Re-check protected names against the worktree path's trailing segment too.
|
|
185
|
+
const tail = path.split("/").pop() ?? "";
|
|
186
|
+
if (isProtectedBranch(tail))
|
|
187
|
+
return undefined;
|
|
188
|
+
const r = await spawnGitAsync(gitTop, ["worktree", "remove", path]);
|
|
189
|
+
return r.ok ? path : undefined;
|
|
190
|
+
}
|
|
191
|
+
async function maybeDeleteBranch(gitTop, source, enabled, into) {
|
|
192
|
+
if (!enabled)
|
|
193
|
+
return false;
|
|
194
|
+
if (isProtectedBranch(source))
|
|
195
|
+
return false;
|
|
196
|
+
// Safety double-check: source must be fully merged into destination.
|
|
197
|
+
const merged = await isFullyMergedInto(gitTop, source, into);
|
|
198
|
+
if (!merged)
|
|
199
|
+
return false;
|
|
200
|
+
const r = await spawnGitAsync(gitTop, ["branch", "-d", source]);
|
|
201
|
+
return r.ok;
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Tool registration
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
export function registerGitMergeTool(server) {
|
|
207
|
+
server.addTool({
|
|
208
|
+
name: "git_merge",
|
|
209
|
+
description: "Merge one or more source branches into a destination. Default strategy `auto` " +
|
|
210
|
+
"cascades fast-forward → rebase → merge-commit per source, preferring linear history. " +
|
|
211
|
+
"Refuses if the working tree is dirty. Stops on the first conflict and reports " +
|
|
212
|
+
"the affected paths. Optional flags auto-delete merged branches and worktrees, " +
|
|
213
|
+
"skipping protected names (main, master, dev, develop, stable, trunk, prod, " +
|
|
214
|
+
"production, release/*, hotfix/*).",
|
|
215
|
+
annotations: {
|
|
216
|
+
readOnlyHint: false,
|
|
217
|
+
destructiveHint: false,
|
|
218
|
+
idempotentHint: false,
|
|
219
|
+
},
|
|
220
|
+
parameters: WorkspacePickSchema.extend({
|
|
221
|
+
sources: z
|
|
222
|
+
.array(z.string().min(1))
|
|
223
|
+
.min(1)
|
|
224
|
+
.max(20)
|
|
225
|
+
.describe("Branches to merge into the destination, in order."),
|
|
226
|
+
into: z
|
|
227
|
+
.string()
|
|
228
|
+
.optional()
|
|
229
|
+
.describe("Destination branch. Defaults to the currently checked-out branch."),
|
|
230
|
+
strategy: StrategySchema,
|
|
231
|
+
message: z
|
|
232
|
+
.string()
|
|
233
|
+
.optional()
|
|
234
|
+
.describe("Merge commit message (used only when a merge commit is created)."),
|
|
235
|
+
deleteMergedBranches: z
|
|
236
|
+
.boolean()
|
|
237
|
+
.optional()
|
|
238
|
+
.default(false)
|
|
239
|
+
.describe("After all sources merge cleanly, delete each source branch locally (`git branch -d`). " +
|
|
240
|
+
"Protected names always skipped. Never affects remote branches."),
|
|
241
|
+
deleteMergedWorktrees: z
|
|
242
|
+
.boolean()
|
|
243
|
+
.optional()
|
|
244
|
+
.default(false)
|
|
245
|
+
.describe("After all sources merge cleanly, remove any local worktree currently checked out " +
|
|
246
|
+
"on a source branch (`git worktree remove`). Protected tails always skipped."),
|
|
247
|
+
}),
|
|
248
|
+
execute: async (args) => {
|
|
249
|
+
const pre = requireSingleRepo(server, args);
|
|
250
|
+
if (!pre.ok)
|
|
251
|
+
return jsonRespond(pre.error);
|
|
252
|
+
const gitTop = pre.gitTop;
|
|
253
|
+
// --- Validate ref tokens early ---
|
|
254
|
+
for (const s of args.sources) {
|
|
255
|
+
if (!isSafeGitRefToken(s)) {
|
|
256
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: s });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (args.into !== undefined && !isSafeGitRefToken(args.into)) {
|
|
260
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: args.into });
|
|
261
|
+
}
|
|
262
|
+
// --- Resolve destination ---
|
|
263
|
+
const startBranch = await getCurrentBranch(gitTop);
|
|
264
|
+
const into = args.into?.trim() || startBranch;
|
|
265
|
+
if (!into) {
|
|
266
|
+
return jsonRespond({ error: "into_detached_head" });
|
|
267
|
+
}
|
|
268
|
+
// --- Refuse dirty tree ---
|
|
269
|
+
if (!(await isWorkingTreeClean(gitTop))) {
|
|
270
|
+
return jsonRespond({ error: "working_tree_dirty" });
|
|
271
|
+
}
|
|
272
|
+
// --- Ensure destination is checked out ---
|
|
273
|
+
if (into !== startBranch) {
|
|
274
|
+
const co = await spawnGitAsync(gitTop, ["checkout", into]);
|
|
275
|
+
if (!co.ok) {
|
|
276
|
+
return jsonRespond({
|
|
277
|
+
error: "checkout_failed",
|
|
278
|
+
detail: (co.stderr || co.stdout).trim(),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Verify destination exists after checkout.
|
|
283
|
+
const intoShaProbe = await resolveRef(gitTop, into);
|
|
284
|
+
if (!intoShaProbe) {
|
|
285
|
+
return jsonRespond({ error: "destination_not_found", ref: into });
|
|
286
|
+
}
|
|
287
|
+
// --- Merge each source sequentially ---
|
|
288
|
+
const strategy = args.strategy ?? "auto";
|
|
289
|
+
const results = [];
|
|
290
|
+
let firstConflict = false;
|
|
291
|
+
for (const source of args.sources) {
|
|
292
|
+
const r = await mergeOneSource(gitTop, into, source, strategy, args.message);
|
|
293
|
+
results.push(r);
|
|
294
|
+
if (!r.ok) {
|
|
295
|
+
firstConflict = true;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const allOk = !firstConflict && results.every((r) => r.ok);
|
|
300
|
+
// --- Cleanup (only on full success) ---
|
|
301
|
+
if (allOk) {
|
|
302
|
+
for (let i = 0; i < results.length; i++) {
|
|
303
|
+
const r = results[i];
|
|
304
|
+
if (!r || r.outcome === "up_to_date")
|
|
305
|
+
continue;
|
|
306
|
+
const worktreeRemoved = await maybeRemoveWorktree(gitTop, r.source, args.deleteMergedWorktrees ?? false);
|
|
307
|
+
const branchDeleted = await maybeDeleteBranch(gitTop, r.source, args.deleteMergedBranches ?? false, into);
|
|
308
|
+
results[i] = {
|
|
309
|
+
...r,
|
|
310
|
+
...spreadDefined("worktreeRemoved", worktreeRemoved),
|
|
311
|
+
...spreadWhen(branchDeleted, { branchDeleted: true }),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const headProbe = await spawnGitAsync(gitTop, ["rev-parse", "HEAD"]);
|
|
316
|
+
const headSha = headProbe.ok ? headProbe.stdout.trim() : undefined;
|
|
317
|
+
if (args.format === "json") {
|
|
318
|
+
return jsonRespond({
|
|
319
|
+
ok: allOk,
|
|
320
|
+
into,
|
|
321
|
+
strategy,
|
|
322
|
+
...spreadDefined("headSha", headSha),
|
|
323
|
+
applied: results.filter((r) => r.ok).length,
|
|
324
|
+
total: args.sources.length,
|
|
325
|
+
results: results.map((r) => ({
|
|
326
|
+
source: r.source,
|
|
327
|
+
ok: r.ok,
|
|
328
|
+
...spreadDefined("outcome", r.outcome),
|
|
329
|
+
...spreadDefined("mergedSha", r.mergedSha),
|
|
330
|
+
...spreadDefined("conflictStage", r.conflictStage),
|
|
331
|
+
...spreadWhen((r.conflictPaths?.length ?? 0) > 0, {
|
|
332
|
+
conflictPaths: r.conflictPaths,
|
|
333
|
+
}),
|
|
334
|
+
...spreadWhen(r.branchDeleted === true, { branchDeleted: true }),
|
|
335
|
+
...spreadDefined("worktreeRemoved", r.worktreeRemoved),
|
|
336
|
+
...spreadDefined("skipReason", r.skipReason),
|
|
337
|
+
...spreadDefined("error", r.error),
|
|
338
|
+
...spreadDefined("detail", r.detail),
|
|
339
|
+
})),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// --- Markdown ---
|
|
343
|
+
const lines = [];
|
|
344
|
+
const applied = results.filter((r) => r.ok).length;
|
|
345
|
+
const header = allOk
|
|
346
|
+
? `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied`
|
|
347
|
+
: `# Merge into \`${into}\`: ${applied}/${args.sources.length} sources applied (stopped on conflict)`;
|
|
348
|
+
lines.push(header, "");
|
|
349
|
+
for (const r of results) {
|
|
350
|
+
const icon = r.ok ? "✓" : "✗";
|
|
351
|
+
const tail = [];
|
|
352
|
+
if (r.outcome)
|
|
353
|
+
tail.push(r.outcome);
|
|
354
|
+
if (r.mergedSha)
|
|
355
|
+
tail.push(`\`${r.mergedSha.slice(0, 7)}\``);
|
|
356
|
+
if (r.branchDeleted)
|
|
357
|
+
tail.push("branch deleted");
|
|
358
|
+
if (r.worktreeRemoved)
|
|
359
|
+
tail.push(`worktree removed: ${r.worktreeRemoved}`);
|
|
360
|
+
lines.push(`${icon} ${r.source}${tail.length ? ` — ${tail.join(", ")}` : ""}`);
|
|
361
|
+
if (!r.ok) {
|
|
362
|
+
if (r.conflictPaths?.length) {
|
|
363
|
+
for (const p of r.conflictPaths)
|
|
364
|
+
lines.push(` conflict: ${p}`);
|
|
365
|
+
}
|
|
366
|
+
if (r.error)
|
|
367
|
+
lines.push(` Error: ${r.error}${r.detail ? ` — ${r.detail}` : ""}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!allOk) {
|
|
371
|
+
const skipped = args.sources.length - results.length;
|
|
372
|
+
if (skipped > 0)
|
|
373
|
+
lines.push("", `${skipped} remaining source(s) skipped.`);
|
|
374
|
+
}
|
|
375
|
+
return lines.join("\n");
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
@@ -8,7 +8,10 @@ import { WorkspacePickSchema } from "./schemas.js";
|
|
|
8
8
|
export function registerGitParityTool(server) {
|
|
9
9
|
server.addTool({
|
|
10
10
|
name: "git_parity",
|
|
11
|
-
description: "Read-only HEAD parity for path pairs.
|
|
11
|
+
description: "Read-only HEAD parity for path pairs.",
|
|
12
|
+
annotations: {
|
|
13
|
+
readOnlyHint: true,
|
|
14
|
+
},
|
|
12
15
|
parameters: WorkspacePickSchema.extend({
|
|
13
16
|
pairs: z
|
|
14
17
|
.array(z.object({
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isSafeGitUpstreamToken, spawnGitAsync } from "./git.js";
|
|
3
|
+
import { getCurrentBranch, inferRemoteFromUpstream, isSafeGitRefToken } from "./git-refs.js";
|
|
4
|
+
import { jsonRespond, spreadDefined } from "./json.js";
|
|
5
|
+
import { requireSingleRepo } from "./roots.js";
|
|
6
|
+
import { WorkspacePickSchema } from "./schemas.js";
|
|
7
|
+
export function registerGitPushTool(server) {
|
|
8
|
+
server.addTool({
|
|
9
|
+
name: "git_push",
|
|
10
|
+
description: "Push the current branch to its configured upstream. " +
|
|
11
|
+
"Use `setUpstream: true` to set tracking (`-u`) when no upstream is configured yet. " +
|
|
12
|
+
"Refuses on detached HEAD. Does not force-push.",
|
|
13
|
+
annotations: {
|
|
14
|
+
readOnlyHint: false,
|
|
15
|
+
destructiveHint: false,
|
|
16
|
+
idempotentHint: false,
|
|
17
|
+
},
|
|
18
|
+
parameters: WorkspacePickSchema.extend({
|
|
19
|
+
remote: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Remote to push to. Defaults to the remote inferred from the current upstream tracking " +
|
|
23
|
+
"ref, or `origin` when `setUpstream` is true."),
|
|
24
|
+
branch: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Branch to push. Defaults to the currently checked-out branch. " +
|
|
28
|
+
"Rejected when HEAD is detached."),
|
|
29
|
+
setUpstream: z
|
|
30
|
+
.boolean()
|
|
31
|
+
.optional()
|
|
32
|
+
.default(false)
|
|
33
|
+
.describe("Set the upstream tracking reference (`git push -u`). " +
|
|
34
|
+
"Use when the branch has not been pushed yet. Remote defaults to `origin`."),
|
|
35
|
+
}),
|
|
36
|
+
execute: async (args) => {
|
|
37
|
+
const pre = requireSingleRepo(server, args);
|
|
38
|
+
if (!pre.ok)
|
|
39
|
+
return jsonRespond(pre.error);
|
|
40
|
+
const { gitTop } = pre;
|
|
41
|
+
// --- Resolve branch ---
|
|
42
|
+
const currentBranch = await getCurrentBranch(gitTop);
|
|
43
|
+
const branch = args.branch?.trim() || currentBranch;
|
|
44
|
+
if (!branch) {
|
|
45
|
+
return jsonRespond({ error: "push_detached_head" });
|
|
46
|
+
}
|
|
47
|
+
if (!isSafeGitRefToken(branch)) {
|
|
48
|
+
return jsonRespond({ error: "unsafe_ref_token", ref: branch });
|
|
49
|
+
}
|
|
50
|
+
// --- Resolve remote ---
|
|
51
|
+
let remote;
|
|
52
|
+
if (args.remote?.trim()) {
|
|
53
|
+
if (!isSafeGitUpstreamToken(args.remote.trim())) {
|
|
54
|
+
return jsonRespond({ error: "unsafe_remote_token", remote: args.remote.trim() });
|
|
55
|
+
}
|
|
56
|
+
remote = args.remote.trim();
|
|
57
|
+
}
|
|
58
|
+
else if (args.setUpstream) {
|
|
59
|
+
// No explicit remote and we're setting upstream — default to origin.
|
|
60
|
+
remote = "origin";
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Infer remote from existing upstream tracking ref.
|
|
64
|
+
const t = await inferRemoteFromUpstream(gitTop);
|
|
65
|
+
if (!t.ok) {
|
|
66
|
+
return jsonRespond({
|
|
67
|
+
error: "push_no_upstream",
|
|
68
|
+
branch,
|
|
69
|
+
detail: t.detail,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
remote = t.remote;
|
|
73
|
+
}
|
|
74
|
+
// --- Push ---
|
|
75
|
+
const pushArgs = ["push"];
|
|
76
|
+
if (args.setUpstream)
|
|
77
|
+
pushArgs.push("-u");
|
|
78
|
+
pushArgs.push(remote, branch);
|
|
79
|
+
const pushResult = await spawnGitAsync(gitTop, pushArgs);
|
|
80
|
+
if (!pushResult.ok) {
|
|
81
|
+
return jsonRespond({
|
|
82
|
+
ok: false,
|
|
83
|
+
branch,
|
|
84
|
+
remote,
|
|
85
|
+
error: "push_failed",
|
|
86
|
+
detail: (pushResult.stderr || pushResult.stdout).trim(),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Probe the upstream tracking ref now (may have been set by -u).
|
|
90
|
+
const upstreamProbe = await spawnGitAsync(gitTop, [
|
|
91
|
+
"rev-parse",
|
|
92
|
+
"--abbrev-ref",
|
|
93
|
+
"--symbolic-full-name",
|
|
94
|
+
"@{u}",
|
|
95
|
+
]);
|
|
96
|
+
const upstream = upstreamProbe.ok ? upstreamProbe.stdout.trim() : `${remote}/${branch}`;
|
|
97
|
+
if (args.format === "json") {
|
|
98
|
+
return jsonRespond({
|
|
99
|
+
ok: true,
|
|
100
|
+
branch,
|
|
101
|
+
remote,
|
|
102
|
+
upstream,
|
|
103
|
+
...spreadDefined("setUpstream", args.setUpstream || undefined),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return `# Push\n✓ ${branch} → ${upstream}`;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|