mergestorm 0.3.61 → 0.3.63

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.
@@ -5,9 +5,9 @@ import { loadConfig } from "../config.js";
5
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
6
6
  import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
7
7
  import { parseGithubOriginRepo } from "../git.js";
8
- import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
8
+ import { branchExists, commitsAheadOf, createBranchFrom, createBranchFromHead, trunkStartRef, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
9
9
  import { buildPrBodyFromCommit } from "../pr-body.js";
10
- import { publishBranchServerSide, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
10
+ import { publishBranchServerSide, assertExplicitStacking, publishWorkingBranch, workingBranchPublication, } from "../server-publish.js";
11
11
  import { gitTopLevel } from "../repo-identity.js";
12
12
  import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
13
13
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
@@ -52,18 +52,20 @@ export function openStackWatch(stackId, opts = {}) {
52
52
  const STACK_USAGE = `usage:
53
53
  mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
54
54
  mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--rest] [--server-side] [--json]
55
- mergestorm stack publish [--branch <branch>] [--base <branch>] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]
55
+ mergestorm stack publish [--branch <branch>] [--base <branch>] [--extend] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]
56
56
  mergestorm stack reset --force
57
57
  mergestorm stack list [--json]
58
58
  mergestorm stack status <stack-id> [--json]
59
59
  mergestorm stack wait <stack-id> [--json] [--timeout <s>]
60
60
  --json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
61
61
  (exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
62
- mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
62
+ mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]
63
63
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
64
64
  Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
65
65
  error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
66
66
  and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
67
+ An ATTENTION line for a merge queue bounce ends with restart="mg stack watch <id> --bounce-id <id> --after-finished-at <iso>":
68
+ restart with that command (plus --head <sha> after a push) so a bounce you already handled is not reported again.
67
69
  --json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
68
70
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--auto-resolve-conflicts on|off|default] [--auto-fix-ci on|off|default] [--json]
69
71
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
@@ -85,17 +87,21 @@ const STACK_USAGE = `usage:
85
87
  stack submit --server-side and stack publish need no GitHub credentials: mg
86
88
  reads your commits from the local repository and sends them to Mergestorm,
87
89
  which writes the commits and the branch on GitHub as the Surge App, opens the
88
- pull request, and registers the stack with Auto patch pinned off. They never
90
+ pull request, and registers the stack. Auto patch follows the stack, the
91
+ repository and your account, like any other stack. They never
89
92
  run gh, git push or git fetch. stack publish does this for one branch and is
90
93
  how you send later commits to a branch that is already published. Mergestorm
91
94
  refuses to move a branch whose head on GitHub matches no local commit unless
92
95
  you name that head with --replace-head. stack publish --status reads how far
93
96
  a branch got (absent, pushed, opened, registered) without changing anything;
94
97
  after a lost response or a failure, run the same command again to resume.
95
- Parenting onto a branch that already belongs to a registered (submitted)
96
- stack requires --extend on create and submit — otherwise mg starts a new
97
- stack from trunk. stack land promotes into the review unit when one exists;
98
- otherwise lands the bottom open PR (unit-less stacks).`;
98
+ stack create starts a new single PR from trunk, even on another PR's
99
+ branch. --extend stacks it on the checked-out branch (or --onto <branch>)
100
+ instead; --onto or publish --base a branch other than trunk without
101
+ --extend is refused. A layer on a registered stack also needs --extend on
102
+ submit. create, submit and publish say which one they made. stack land
103
+ promotes into the review unit when one exists; otherwise lands the bottom
104
+ open PR (unit-less stacks).`;
99
105
  /** Cap the registered-stack guard's API probe so it cannot stall local authoring. */
100
106
  const STACK_GUARD_TIMEOUT_MS = 8_000;
101
107
  async function listRegisteredStacksRetryingOnce(list, cfg) {
@@ -149,6 +155,13 @@ export function assertMayParentOntoRegistered(parentBranch, stacks, extend, verb
149
155
  `• New independent stack: check out trunk and pass \`--onto <trunk>\` (e.g. \`mg stack create --onto main\`).\n` +
150
156
  `• Intentionally grow that stack: pass \`--extend\` on \`stack ${verb}\`.`, 1, "registered_parent");
151
157
  }
158
+ export function stackShapeLine(input) {
159
+ if (input.extendedStack)
160
+ return ` Added ${input.layers === 1 ? "1 layer" : `${input.layers} layers`} to an existing stack (--extend).`;
161
+ if (input.layers === 1)
162
+ return ` A new single PR onto ${input.base}.`;
163
+ return ` A new stack of ${input.layers} layers onto ${input.base}.`;
164
+ }
152
165
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
153
166
  export function requireStackId(raw, usage) {
154
167
  const id = raw?.trim() ?? "";
@@ -522,6 +535,8 @@ export async function cmdStackCreate(args, deps = {}) {
522
535
  const currentBranchFn = deps.currentBranch ?? currentBranch;
523
536
  const branchExistsFn = deps.branchExists ?? branchExists;
524
537
  const createBranchFromHeadFn = deps.createBranchFromHead ?? createBranchFromHead;
538
+ const createBranchFromFn = deps.createBranchFrom ?? createBranchFrom;
539
+ const trunkStartRefFn = deps.trunkStartRef ?? trunkStartRef;
525
540
  const deleteBranchFn = deps.deleteBranch ?? deleteBranch;
526
541
  const discoverTrunkFn = deps.discoverTrunk ?? discoverTrunk;
527
542
  const defaultLayerBranchNameFn = deps.defaultLayerBranchName ?? defaultLayerBranchName;
@@ -539,17 +554,6 @@ export async function cmdStackCreate(args, deps = {}) {
539
554
  if (worktreeDirtyFn(cwd)) {
540
555
  throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
541
556
  }
542
- const parentBranch = onto?.trim() || currentBranchFn(cwd);
543
- if (!parentBranch) {
544
- throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) before `stack create`.");
545
- }
546
- if (!branchExistsFn(parentBranch, cwd)) {
547
- throw new CommandError(`Parent branch not found: ${parentBranch}`);
548
- }
549
- const branch = defaultLayerBranchNameFn(name);
550
- if (branchExistsFn(branch, cwd)) {
551
- throw new CommandError(`Branch already exists: ${branch}`);
552
- }
553
557
  let meta = await loadStackMetaFn(cwd);
554
558
  const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunkFn(cwd);
555
559
  if (!meta) {
@@ -558,8 +562,34 @@ export async function cmdStackCreate(args, deps = {}) {
558
562
  else if (trunkFlag?.trim() && meta.trunk !== trunk) {
559
563
  throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Run \`mg stack reset --force\` to discard local pre-submit state.`);
560
564
  }
561
- // --trunk only sets metadata; parent defaults to current branch. Call that out
562
- // when someone passes --trunk while sitting on a different branch without --onto.
565
+ const checkedOut = currentBranchFn(cwd);
566
+ if (extend && !onto?.trim() && !checkedOut) {
567
+ throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) to stack with --extend.");
568
+ }
569
+ const parentBranch = onto?.trim() || (extend && checkedOut ? checkedOut : trunk);
570
+ assertExplicitStacking({
571
+ parent: parentBranch,
572
+ trunk,
573
+ extend,
574
+ stackCommand: `\`mg stack create ${name ?? "<name>"} --onto ${parentBranch} --extend\``,
575
+ trunkCommand: `\`mg stack create ${name ?? "<name>"}\``,
576
+ });
577
+ let startRef = null;
578
+ if (parentBranch === trunk && checkedOut !== trunk) {
579
+ startRef = trunkStartRefFn(trunk, cwd);
580
+ if (!startRef)
581
+ throw new CommandError(`Trunk branch not found: ${trunk}`);
582
+ }
583
+ else if (!branchExistsFn(parentBranch, cwd)) {
584
+ throw new CommandError(`Parent branch not found: ${parentBranch}`);
585
+ }
586
+ else if (parentBranch !== checkedOut) {
587
+ startRef = parentBranch;
588
+ }
589
+ const branch = defaultLayerBranchNameFn(name);
590
+ if (branchExistsFn(branch, cwd)) {
591
+ throw new CommandError(`Branch already exists: ${branch}`);
592
+ }
563
593
  if (trunkFlag?.trim() && !onto?.trim() && parentBranch !== trunk) {
564
594
  console.error(ansi.dim(` note: --trunk ${trunk} does not change the parent; new layer parents onto \`${parentBranch}\`. ` +
565
595
  `Pass \`--onto ${trunk}\` for a fresh stack from trunk.`));
@@ -603,7 +633,10 @@ export async function cmdStackCreate(args, deps = {}) {
603
633
  throw new CommandError(err instanceof Error ? err.message : String(err));
604
634
  }
605
635
  try {
606
- createBranchFromHeadFn(branch, cwd);
636
+ if (startRef)
637
+ createBranchFromFn(branch, startRef, cwd);
638
+ else
639
+ createBranchFromHeadFn(branch, cwd);
607
640
  }
608
641
  catch (err) {
609
642
  throw new CommandError(err instanceof Error ? err.message : String(err));
@@ -634,7 +667,9 @@ export async function cmdStackCreate(args, deps = {}) {
634
667
  return;
635
668
  }
636
669
  const created = [
637
- ansi.brightGreen(` Created ${branch} (onto ${parentBranch})`),
670
+ ansi.brightGreen(parentBranch === meta.trunk
671
+ ? ` Created ${branch}: a new single PR onto ${parentBranch}${startRef ? ` (branched from ${startRef})` : ""}`
672
+ : ` Created ${branch}: a layer stacked on ${parentBranch} (--extend)`),
638
673
  ];
639
674
  if (registeredHit) {
640
675
  created.push(ansi.dim(` extending registered stack ${registeredHit.stackId}` +
@@ -782,9 +817,6 @@ export async function cmdStackSubmit(args, deps = {}) {
782
817
  }
783
818
  }
784
819
  if (serverSide) {
785
- if (autoPatch === true) {
786
- throw new CommandError("--server-side publishes your own commits and keeps Auto patch off for the stack. Drop --auto-patch on, or submit without --server-side.", REVIEW_EXIT.usage, "usage");
787
- }
788
820
  const parked = plans.find((plan) => plan.githubBase == null);
789
821
  if (parked) {
790
822
  throw new CommandError(`Layer ${parked.branch} opens onto a park freeze, which --server-side does not mint. ` +
@@ -796,6 +828,7 @@ export async function cmdStackSubmit(args, deps = {}) {
796
828
  const remoteHeads = new Map();
797
829
  const published = [];
798
830
  let publishedStackId = null;
831
+ let publishedAutoPatch = null;
799
832
  for (const plan of plans) {
800
833
  if (!branchExistsFn(plan.branch, cwd)) {
801
834
  throw new CommandError(`Stack layer branch missing locally: ${plan.branch}`);
@@ -825,6 +858,7 @@ export async function cmdStackSubmit(args, deps = {}) {
825
858
  });
826
859
  remoteHeads.set(plan.branch, result.head.sha);
827
860
  publishedStackId = result.stack.id;
861
+ publishedAutoPatch = result.stack.autoPatchOverride;
828
862
  published.push({
829
863
  branch: plan.branch,
830
864
  base: result.baseBranch,
@@ -834,19 +868,20 @@ export async function cmdStackSubmit(args, deps = {}) {
834
868
  tree: result.head.tree,
835
869
  });
836
870
  }
837
- const { autoEnqueueWhenReady: _autoLand, autoPatchOverride: _autoPatch, ...otherOverrides } = policy ?? {};
871
+ const { autoEnqueueWhenReady: _autoLand, ...otherOverrides } = policy ?? {};
838
872
  if (publishedStackId && Object.keys(otherOverrides).length > 0) {
839
873
  await (deps.setStackPolicy ?? setStackPolicy)(publishedStackId, otherOverrides, config);
840
874
  }
841
875
  await saveStackMetaFn(removeActiveStack(meta), cwd);
842
876
  const watch = publishedStackId ? openStackWatch(publishedStackId) : null;
843
877
  if (asJson) {
844
- console.log(JSON.stringify({ owner, repo, layers: published, stackId: publishedStackId, trunk: meta.trunk, chain: null, serverSide: true, autoPatchOverride: false, watch }, null, 2));
878
+ console.log(JSON.stringify({ owner, repo, layers: published, stackId: publishedStackId, trunk: meta.trunk, chain: null, serverSide: true, autoPatchOverride: otherOverrides.autoPatchOverride ?? publishedAutoPatch, watch }, null, 2));
845
879
  return;
846
880
  }
847
881
  const lines = published.map((row) => ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${row.created ? "opened" : "exists"}, head ${row.head.slice(0, 12)})`));
882
+ lines.push(ansi.brightGreen(stackShapeLine({ layers: layers.length, base: plans[0]?.githubBase ?? meta.trunk, extendedStack: registeredHits > 0 })));
848
883
  if (publishedStackId)
849
- lines.push(ansi.brightGreen(` Registered stack ${publishedStackId} with Auto patch off`));
884
+ lines.push(ansi.brightGreen(` Registered stack ${publishedStackId}`));
850
885
  lines.push(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
851
886
  if (watch)
852
887
  lines.push(...stackWatchTextLines(watch).map((line) => ` ${line}`));
@@ -963,9 +998,6 @@ export async function cmdStackSubmit(args, deps = {}) {
963
998
  await registerPr(opened[opened.length - 1].prNumber);
964
999
  }
965
1000
  else {
966
- if (opened.length === 0) {
967
- throw new CommandError("No pull requests to register after submit.");
968
- }
969
1001
  await registerPr(opened[0].prNumber);
970
1002
  }
971
1003
  const data = adoptData;
@@ -988,6 +1020,7 @@ export async function cmdStackSubmit(args, deps = {}) {
988
1020
  const verb = row.created ? "opened" : "exists";
989
1021
  summary.push(ansi.brightGreen(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
990
1022
  }
1023
+ summary.push(ansi.brightGreen(stackShapeLine({ layers: layers.length, base: plans[0]?.githubBase ?? meta.trunk, extendedStack: registeredHits > 0 })));
991
1024
  const adoptedTrunk = data.stack?.trunkBranch;
992
1025
  if (adoptedTrunk?.startsWith("mg-stack-")) {
993
1026
  summary.push(ansi.dim(` Bottom base is now ${adoptedTrunk} (Mergestorm-owned trunk). Leave it.`));
@@ -1004,8 +1037,8 @@ export async function cmdStackSubmit(args, deps = {}) {
1004
1037
  await present("Stack", summary);
1005
1038
  }
1006
1039
  export function parseStackPublishArgs(args) {
1007
- const usage = "usage: mergestorm stack publish [--branch <branch>] [--base <branch>] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]";
1008
- const out = { status: false, asJson: false };
1040
+ const usage = "usage: mergestorm stack publish [--branch <branch>] [--base <branch>] [--extend] [--title <title>] [--replace-head <sha>] [--auto-land on|off] [--status] [--json]";
1041
+ const out = { extend: false, status: false, asJson: false };
1009
1042
  const valued = {
1010
1043
  "--branch": "branch",
1011
1044
  "--base": "base",
@@ -1022,6 +1055,10 @@ export function parseStackPublishArgs(args) {
1022
1055
  out.status = true;
1023
1056
  continue;
1024
1057
  }
1058
+ if (a === "--extend") {
1059
+ out.extend = true;
1060
+ continue;
1061
+ }
1025
1062
  if (a === "--auto-land") {
1026
1063
  out.autoLand = parseOnOff(args[++i], "--auto-land", "stack publish");
1027
1064
  continue;
@@ -1057,7 +1094,7 @@ export async function cmdStackPublish(args, deps = {}) {
1057
1094
  ...(status.head ? [ansi.dim(` head ${status.head.sha} (tree ${status.head.tree ?? "unknown"})`)] : []),
1058
1095
  ...(status.pr ? [ansi.dim(` #${status.pr.number} → ${status.pr.base} ${status.pr.url}`)] : []),
1059
1096
  ...(status.stack
1060
- ? [ansi.dim(` stack ${status.stack.id} auto-patch ${status.stack.autoPatchOverride === false ? "off" : "not pinned off"} auto-land ${status.stack.autoLand ? "on" : "off"}`)]
1097
+ ? [ansi.dim(` stack ${status.stack.id} auto-patch ${status.stack.autoPatchOverride === null ? "default" : status.stack.autoPatchOverride ? "on" : "off"} auto-land ${status.stack.autoLand ? "on" : "off"}`)]
1061
1098
  : []),
1062
1099
  ]);
1063
1100
  return;
@@ -1067,6 +1104,7 @@ export async function cmdStackPublish(args, deps = {}) {
1067
1104
  ...(parsed.title ? { title: parsed.title } : {}),
1068
1105
  ...(typeof parsed.autoLand === "boolean" ? { autoLand: parsed.autoLand } : {}),
1069
1106
  ...(parsed.replaceHead ? { replaceHead: parsed.replaceHead } : {}),
1107
+ extend: parsed.extend,
1070
1108
  }, publishDeps);
1071
1109
  const watch = openStackWatch(result.stack.id);
1072
1110
  if (parsed.asJson) {
@@ -1077,7 +1115,8 @@ export async function cmdStackPublish(args, deps = {}) {
1077
1115
  await present("Stack", [
1078
1116
  ansi.brightGreen(` ${result.branch} ${moved} at ${result.head.sha.slice(0, 12)} (tree ${result.head.tree.slice(0, 12)})`),
1079
1117
  ansi.brightGreen(` #${result.pr.number} ${result.branch} → ${result.baseBranch} (${result.pr.created ? "opened" : "exists"}) ${result.pr.url}`),
1080
- ansi.brightGreen(` Registered stack ${result.stack.id} with Auto patch off`),
1118
+ ansi.brightGreen(result.stacked ? ` A layer stacked on ${result.baseBranch}.` : ` A single PR onto ${result.baseBranch}.`),
1119
+ ansi.brightGreen(` Registered stack ${result.stack.id}`),
1081
1120
  ...stackWatchTextLines(watch).map((line) => ` ${line}`),
1082
1121
  ]);
1083
1122
  }
@@ -1175,13 +1214,15 @@ export async function cmdStackWait(args, deps = {}) {
1175
1214
  }
1176
1215
  }
1177
1216
  export async function cmdStackWatch(args, deps = {}) {
1178
- const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]";
1217
+ const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--bounce-id <id>] [--after-finished-at <iso>] [--json]";
1179
1218
  const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
1180
1219
  const positional = [];
1181
1220
  const ignore = [];
1182
1221
  let until = "attention";
1183
1222
  let maxMinutes;
1184
1223
  let head;
1224
+ let bounceId;
1225
+ let afterFinishedAt;
1185
1226
  let json = false;
1186
1227
  for (let i = 0; i < args.length; i++) {
1187
1228
  const arg = args[i];
@@ -1212,6 +1253,17 @@ export async function cmdStackWatch(args, deps = {}) {
1212
1253
  if (!/^[0-9a-f]{7,40}$/i.test(head))
1213
1254
  throw fail("--head must be a commit SHA");
1214
1255
  }
1256
+ else if (flag === "--bounce-id") {
1257
+ bounceId = value();
1258
+ if (!/^[0-9a-z-]{1,64}$/i.test(bounceId))
1259
+ throw fail("--bounce-id must be a merge queue entry id");
1260
+ }
1261
+ else if (flag === "--after-finished-at") {
1262
+ const raw = value();
1263
+ if (Number.isNaN(Date.parse(raw)))
1264
+ throw fail("--after-finished-at must be an ISO timestamp");
1265
+ afterFinishedAt = new Date(raw).toISOString();
1266
+ }
1215
1267
  else if (arg.startsWith("-"))
1216
1268
  throw fail();
1217
1269
  else
@@ -1227,7 +1279,12 @@ export async function cmdStackWatch(args, deps = {}) {
1227
1279
  json,
1228
1280
  signal: deps.signal,
1229
1281
  ...(maxMinutes !== undefined ? { maxMs: maxMinutes * 60_000 } : {}),
1230
- ...(head ? { cursor: { stackId: stackId.trim().toLowerCase(), enrolledHeadSha: head.toLowerCase() } } : {}),
1282
+ ...(head || bounceId || afterFinishedAt ? { cursor: {
1283
+ stackId: stackId.trim().toLowerCase(),
1284
+ enrolledHeadSha: head ? head.toLowerCase() : null,
1285
+ ...(bounceId ? { bounceId } : {}),
1286
+ ...(afterFinishedAt ? { afterFinishedAt } : {}),
1287
+ } } : {}),
1231
1288
  });
1232
1289
  if (result.exitCode === STACK_WATCH_EXIT.landed)
1233
1290
  return;
@@ -1273,7 +1330,7 @@ export async function cmdStackStatus(args, deps = {}) {
1273
1330
  freshCursor: true,
1274
1331
  });
1275
1332
  if (asJson) {
1276
- console.log(JSON.stringify({ ...stack, held, repair, queueWait, watch }, null, 2));
1333
+ console.log(JSON.stringify({ ...stack, attention, held, repair, queueWait, watch }, null, 2));
1277
1334
  return;
1278
1335
  }
1279
1336
  const lines = formatStackHuman(stack, false);
@@ -1287,7 +1344,7 @@ export async function cmdStackStatus(args, deps = {}) {
1287
1344
  }
1288
1345
  if (queueWait && !attention)
1289
1346
  lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
1290
- lines.push(...[...stackHeldLine(held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
1347
+ lines.push(...[...stackHeldLine(attention ? null : held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
1291
1348
  await present("Stack status", lines);
1292
1349
  }
1293
1350
  /** Human summary lines for a `stack set` write, one per flag. */
@@ -1446,7 +1503,7 @@ export async function cmdStack(args, options = {}) {
1446
1503
  return cmdStackAdopt(rest);
1447
1504
  if (sub === "restack")
1448
1505
  return cmdStackRestack(rest);
1449
- if (sub === "land" || sub === "land-next")
1506
+ if (sub === "land")
1450
1507
  return cmdStackLand(rest);
1451
1508
  throw new CommandError(`${STACK_USAGE}\nunknown stack subcommand: ${sub}`);
1452
1509
  }
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const DEFAULT_API = "https://api.mergestorm.ai";
2
2
  export type Config = {
3
3
  apiKey?: string;
4
4
  apiBase?: string;
5
+ ignoreEnv?: boolean;
5
6
  };
6
7
  export declare function mergestormHome(env?: NodeJS.ProcessEnv): string;
7
8
  export declare function configPath(): string;
package/dist/config.js CHANGED
@@ -30,9 +30,12 @@ export async function clearConfig() {
30
30
  await rm(configPath(), { force: true });
31
31
  }
32
32
  export function apiBase(cfg) {
33
- return (process.env.MERGESTORM_API_URL?.trim() || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
33
+ const fromEnv = cfg.ignoreEnv ? undefined : process.env.MERGESTORM_API_URL?.trim();
34
+ return (fromEnv || cfg.apiBase || DEFAULT_API).replace(/\/$/, "");
34
35
  }
35
36
  export function resolveApiKey(cfg) {
37
+ if (cfg.ignoreEnv)
38
+ return cfg.apiKey;
36
39
  return process.env.MERGESTORM_API_KEY?.trim() || cfg.apiKey;
37
40
  }
38
41
  /** Short display form of a stored key (prefix only). */
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable machine codes for CommandError control flow.
3
3
  * UX copy in `message` may change freely; match on `code`, never on message text.
4
4
  */
5
- export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
5
+ export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "implicit_stack" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention" | "head_changed" | "base_changed" | "tree_mismatch" | "protected_branch" | "payload_too_large" | "unsupported_publication" | "publication_failed" | "unknown_label";
6
6
  /** Stable one-shot exit codes for machine callers of `mergestorm review`. */
7
7
  export declare const REVIEW_EXIT: {
8
8
  readonly success: 0;
@@ -15,6 +15,8 @@ export declare function discoverTrunk(cwd?: string): string;
15
15
  export declare function branchExists(name: string, cwd?: string): boolean;
16
16
  export declare function deleteBranch(name: string, cwd?: string): void;
17
17
  export declare function createBranchFromHead(name: string, cwd?: string): void;
18
+ export declare function createBranchFrom(name: string, start: string, cwd?: string): void;
19
+ export declare function trunkStartRef(trunk: string, cwd?: string): string | null;
18
20
  /** Slugify a free-form name into a safe branch segment. */
19
21
  export declare function slugifyBranchName(raw: string): string;
20
22
  export declare function defaultLayerBranchName(explicit?: string): string;
package/dist/git-stack.js CHANGED
@@ -78,6 +78,21 @@ export function deleteBranch(name, cwd = process.cwd()) {
78
78
  export function createBranchFromHead(name, cwd = process.cwd()) {
79
79
  git(["checkout", "-b", name], cwd);
80
80
  }
81
+ export function createBranchFrom(name, start, cwd = process.cwd()) {
82
+ git(["checkout", "-b", name, start], cwd);
83
+ }
84
+ export function trunkStartRef(trunk, cwd = process.cwd()) {
85
+ for (const ref of [`refs/remotes/origin/${trunk}`, `refs/heads/${trunk}`]) {
86
+ try {
87
+ git(["rev-parse", "--verify", "--quiet", ref], cwd);
88
+ return ref === `refs/heads/${trunk}` ? trunk : `origin/${trunk}`;
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ }
94
+ return null;
95
+ }
81
96
  /** Slugify a free-form name into a safe branch segment. */
82
97
  export function slugifyBranchName(raw) {
83
98
  const s = raw
@@ -1,6 +1,4 @@
1
- export declare const LEGACY_STACK_META_REL: string;
2
1
  export declare function gitTopLevel(cwd?: string): string;
3
- export declare function gitCommonDir(cwd?: string): string;
4
2
  /**
5
3
  * Each worktree has its own authoring state.
6
4
  * Independent clones remain isolated even when they point at the same remote.
@@ -9,4 +7,3 @@ export declare function stableRepoId(cwd?: string): string;
9
7
  export declare function cliStacksRoot(): string;
10
8
  export declare function repoStateDir(cwd?: string, stacksRoot?: string): string;
11
9
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
12
- export declare function legacyStackMetaPath(cwd?: string): string;
@@ -4,7 +4,6 @@ import path from "node:path";
4
4
  import { mergestormHome } from "./config.js";
5
5
  import { CommandError } from "./errors.js";
6
6
  import { git } from "./git.js";
7
- export const LEGACY_STACK_META_REL = path.join(".mergestorm", "stack.json");
8
7
  /** Classify git "not a repo" failures once at the boundary; callers match on code. */
9
8
  function rethrowIfNotARepo(err) {
10
9
  const detail = err instanceof Error ? err.message : String(err);
@@ -21,25 +20,6 @@ export function gitTopLevel(cwd = process.cwd()) {
21
20
  rethrowIfNotARepo(err);
22
21
  }
23
22
  }
24
- export function gitCommonDir(cwd = process.cwd()) {
25
- let raw;
26
- try {
27
- raw = git(["rev-parse", "--git-common-dir"], cwd).trim();
28
- }
29
- catch (err) {
30
- rethrowIfNotARepo(err);
31
- }
32
- const absolute = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
33
- try {
34
- return realpathSync(absolute);
35
- }
36
- catch (err) {
37
- if (err?.code === "ENOENT") {
38
- throw new CommandError(`git common directory is missing (${absolute}); this is often a linked worktree whose main repository was deleted. Run \`mg stack reset --force\` to clear local pre-submit state.`, 1, "not_a_repo");
39
- }
40
- throw err;
41
- }
42
- }
43
23
  /**
44
24
  * Each worktree has its own authoring state.
45
25
  * Independent clones remain isolated even when they point at the same remote.
@@ -67,6 +47,3 @@ export function repoStateDir(cwd = process.cwd(), stacksRoot = cliStacksRoot())
67
47
  export function stackMetaPath(cwd = process.cwd(), stacksRoot = cliStacksRoot()) {
68
48
  return path.join(repoStateDir(cwd, stacksRoot), "stack.json");
69
49
  }
70
- export function legacyStackMetaPath(cwd = process.cwd()) {
71
- return path.join(gitTopLevel(cwd), LEGACY_STACK_META_REL);
72
- }
@@ -1,5 +1,6 @@
1
1
  import { getStackPublication, submitStackPublication, type StackPublication, type StackPublicationChange, type StackPublicationCommit, type StackPublicationIdentity, type StackPublicationStatus } from "./api.js";
2
2
  import { loadConfig, type Config } from "./config.js";
3
+ import { discoverTrunk } from "./git-stack.js";
3
4
  export declare const SERVER_PUBLISH_MAX_COMMITS = 20;
4
5
  export declare const SERVER_PUBLISH_MAX_CHANGES = 500;
5
6
  export declare const SERVER_PUBLISH_MAX_FILE_BYTES: number;
@@ -39,6 +40,7 @@ export type ServerPublishDeps = {
39
40
  read?: GitRead;
40
41
  getStackPublication?: typeof getStackPublication;
41
42
  submitStackPublication?: typeof submitStackPublication;
43
+ assertBase?: (base: string) => void;
42
44
  };
43
45
  export type ServerPublishResult = StackPublication & {
44
46
  baseBranch: string;
@@ -54,17 +56,28 @@ export type WorkingBranchInput = {
54
56
  body?: string;
55
57
  autoLand?: boolean;
56
58
  replaceHead?: string;
59
+ extend?: boolean;
57
60
  cfg?: Config;
58
61
  };
59
62
  export type WorkingBranchDeps = ServerPublishDeps & {
60
63
  publishBranchServerSide?: typeof publishBranchServerSide;
61
64
  loadConfig?: typeof loadConfig;
65
+ discoverTrunk?: typeof discoverTrunk;
62
66
  };
67
+ export declare function assertExplicitStacking(input: {
68
+ parent: string;
69
+ trunk: string;
70
+ extend: boolean;
71
+ stackCommand: string;
72
+ trunkCommand: string;
73
+ }): void;
63
74
  export declare function workingBranchTarget(input: WorkingBranchInput): {
64
75
  cwd: string;
65
76
  owner: string;
66
77
  repo: string;
67
78
  branch: string;
68
79
  };
69
- export declare function publishWorkingBranch(input: WorkingBranchInput, deps?: WorkingBranchDeps): Promise<ServerPublishResult>;
80
+ export declare function publishWorkingBranch(input: WorkingBranchInput, deps?: WorkingBranchDeps): Promise<ServerPublishResult & {
81
+ stacked: boolean;
82
+ }>;
70
83
  export declare function workingBranchPublication(input: WorkingBranchInput, deps?: Pick<WorkingBranchDeps, "getStackPublication" | "loadConfig">): Promise<StackPublicationStatus>;
@@ -180,6 +180,7 @@ export async function publishBranchServerSide(input, deps = {}) {
180
180
  throw new CommandError(`Branch missing locally: ${branch}`);
181
181
  const remote = await status(cfg, { owner, repo, branch });
182
182
  const baseBranch = remote.pr?.base ?? input.baseBranch;
183
+ deps.assertBase?.(baseBranch);
183
184
  const localBase = input.localBaseRef ??
184
185
  (resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
185
186
  if (!resolves(read, localBase, cwd))
@@ -229,6 +230,13 @@ export async function publishBranchServerSide(input, deps = {}) {
229
230
  }
230
231
  return { ...publication, baseBranch };
231
232
  }
233
+ export function assertExplicitStacking(input) {
234
+ if (input.extend || input.parent === input.trunk)
235
+ return;
236
+ throw new CommandError(`\`${input.parent}\` is not trunk (\`${input.trunk}\`), and stacking onto it needs --extend.\n` +
237
+ `• Stack onto \`${input.parent}\`: ${input.stackCommand}\n` +
238
+ `• New stack from \`${input.trunk}\`: ${input.trunkCommand}`, 1, "implicit_stack");
239
+ }
232
240
  export function workingBranchTarget(input) {
233
241
  const cwd = input.cwd ?? process.cwd();
234
242
  const origin = input.owner && input.repo ? { owner: input.owner, repo: input.repo } : parseGithubOriginRepo(cwd);
@@ -242,17 +250,36 @@ export function workingBranchTarget(input) {
242
250
  }
243
251
  export async function publishWorkingBranch(input, deps = {}) {
244
252
  const { cwd, owner, repo, branch } = workingBranchTarget(input);
245
- let baseBranch = input.base;
253
+ const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, discoverTrunk: findTrunk = discoverTrunk, ...publishDeps } = deps;
254
+ let trunk = null;
255
+ let trunkError = null;
256
+ try {
257
+ trunk = findTrunk(cwd);
258
+ }
259
+ catch (err) {
260
+ trunkError = err;
261
+ }
262
+ const trunkMessage = trunkError instanceof Error ? trunkError.message : String(trunkError);
263
+ const baseBranch = input.base ?? trunk;
246
264
  if (!baseBranch) {
247
- try {
248
- baseBranch = discoverTrunk(cwd);
265
+ throw new CommandError(`${trunkMessage} Name the base branch.`);
266
+ }
267
+ const assertBase = (parent) => {
268
+ if (trunk) {
269
+ assertExplicitStacking({
270
+ parent,
271
+ trunk,
272
+ extend: input.extend === true,
273
+ stackCommand: `\`mg stack publish --base ${parent} --extend\` (MCP stack_publish: extend true)`,
274
+ trunkCommand: "`mg stack publish` with no --base (MCP stack_publish: no base)",
275
+ });
249
276
  }
250
- catch (err) {
251
- throw new CommandError(`${err instanceof Error ? err.message : String(err)} Name the base branch.`);
277
+ else if (input.extend !== true) {
278
+ throw new CommandError(`${trunkMessage} \`${parent}\` cannot be verified as trunk, so stacking onto it needs --extend.`, 1, "implicit_stack");
252
279
  }
253
- }
254
- const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, ...publishDeps } = deps;
255
- return publish({
280
+ };
281
+ assertBase(baseBranch);
282
+ const result = await publish({
256
283
  cfg: input.cfg ?? (await load()),
257
284
  cwd,
258
285
  owner,
@@ -263,7 +290,8 @@ export async function publishWorkingBranch(input, deps = {}) {
263
290
  body: input.body ?? buildPrBodyFromCommit(tipCommitMessage(branch, cwd)),
264
291
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
265
292
  ...(input.replaceHead ? { replaceHead: input.replaceHead } : {}),
266
- }, publishDeps);
293
+ }, { ...publishDeps, assertBase });
294
+ return { ...result, stacked: !trunk || result.baseBranch !== trunk };
267
295
  }
268
296
  export async function workingBranchPublication(input, deps = {}) {
269
297
  const { owner, repo, branch } = workingBranchTarget(input);
@@ -170,6 +170,8 @@ export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no rev
170
170
  export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
171
171
  export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
172
172
  export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
173
+ export declare const CYCLONE_NOT_RUN_ATTENTION_MS: number;
174
+ export declare const CYCLONE_NOT_RUN_REASON = "not_run";
173
175
  export declare function cycloneStatusWords(layer: Pick<StackLayerDto, "cycloneStatus" | "cycloneHandoff" | "headSha">): string | null;
174
176
  export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
175
177
  export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
@@ -190,7 +192,6 @@ export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land r
190
192
  export declare const AUTO_LAND_WAIT_ATTENTION_MS: number;
191
193
  export declare const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS: number;
192
194
  export declare function autoLandWaitLayer(stack: StackDto): StackLayerDto | undefined;
193
- export declare function autoLandWaitBlocker(stack: StackDto, layer: StackLayerDto, nowMs: number): string | null;
194
195
  export declare const MERGE_QUEUE_STALL_ATTENTION_MS: number;
195
196
  export type StackQueueWait = {
196
197
  prNumber: number;
@@ -217,26 +218,12 @@ export declare function restackFailedRepairSteps(branch: string, liveParent: str
217
218
  detail?: string | null;
218
219
  }): string;
219
220
  export declare function agentsCannotClear(reason: StackBlockerReason, stack?: StackDto, layer?: StackLayerDto): boolean;
220
- export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
221
+ export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null, nowMs?: number): StackRepairHint | null;
221
222
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
222
223
  /** Pair-gate attention plus live hard blocks on every other open layer. */
223
224
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
224
- attention: null;
225
- held: StackHeldBlocker;
226
- issues: StackBlocker[];
227
- currentCandidate: {
228
- prNumber: number;
229
- headSha: string | null;
230
- } | null;
231
- bounce: undefined;
232
- busy: StackAgentBusy[];
233
- agents: StackLayerAgents | null;
234
- repair: StackRepairHint | null;
235
- landGatePending: StackLandGatePending | null;
236
- queueWait: StackQueueWait | null;
237
- } | {
238
225
  attention: StackBlocker | null;
239
- held: null;
226
+ held: StackHeldBlocker | null;
240
227
  issues: StackBlocker[];
241
228
  currentCandidate: {
242
229
  prNumber: number;