@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.
@@ -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: sha7, shaFull, subject, author, email, ISO date, relative date.
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%h%x01%H%x01%s%x01%aN%x01%aE%x01%aI%x01%ar%x01";
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 [sha7, shaFull, subject, authorName, email, date, ageRelative] = fields;
105
- if (!sha7 || !shaFull)
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
- sha7: sha7.trim(),
110
- shaFull: shaFull.trim(),
110
+ sha: shaFull.trim(),
111
111
  subject: subject?.trim() ?? "",
112
112
  author: authorName?.trim() ?? "",
113
- email: email?.trim() ?? "",
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
- workspace_root: top,
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.workspace_root}_`);
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.sha7}\` ${c.ageRelative} ${c.subject} — ${c.author}`);
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, workspace_root: rootInput, error: "not_a_git_repo" };
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, workspace_root: rootInput, error: r.error };
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
- workspace_root: r.workspace_root,
249
- repo: basename(r.workspace_root),
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.workspace_root}\n_error: ${r.error}_`);
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. See docs/mcp-tools.md.",
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
+ }