diff --git a/bin/gstack-merge b/bin/gstack-merge
index 90f030d2c..721a54a0a 100755
--- a/bin/gstack-merge
+++ b/bin/gstack-merge
@@ -6,15 +6,24 @@
// native merge queue, and trunk.io merge queue.
//
// Subcommands:
-// detect [--base B] [--pr N] [--json]
-// submit --regime R --pr N [--base B] [--priority P]
-// wait --pr N --base B [--regime R] [--timeout S] [--interval S]
-// [--enqueue-timeout S] [--once]
-// write-state --pr N --base B --slug SLUG [--regime R] [--sha-timeout S]
-// read-state --slug SLUG --pr N --repo OWNER/NAME [--max-age-ms N] [--json]
+// detect [--base B] [--pr N] [--json]
+// submit --regime R --pr N [--base B] [--priority P]
+// confirm-enqueue --regime R --pr N --base B [--slug S] [--enqueue-timeout S]
+// wait --pr N --base B [--regime R] [--timeout S] [--interval S]
+// [--enqueue-timeout S] [--once]
+// write-state --pr N --base B --slug SLUG [--regime R] [--sha-timeout S]
+// read-state --slug SLUG --pr N --repo OWNER/NAME [--max-age-ms N] [--json]
//
-// Contract: detect/wait/read-state never mutate git/PR state. submit and
-// write-state are the only state-touching subcommands.
+// `submit` enqueues. For a queue regime the merge then happens asynchronously
+// (trunk/GitHub land it), so /land's DEFAULT is submit + confirm-enqueue +
+// return — the user can queue many PRs and walk away. `--watch` swaps
+// confirm-enqueue for the blocking `wait` (poll until landed), which is also
+// what /land-and-deploy uses (it needs the completed merge to deploy).
+//
+// Contract: detect/confirm-enqueue/wait/read-state never mutate git/PR state.
+// submit and write-state are the only state-touching subcommands.
+// confirm-enqueue writes last-enqueue.json (a queued marker, no merge SHA);
+// write-state writes last-land.json (a confirmed merge with a SHA).
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
@@ -238,6 +247,87 @@ function submitViaRest(pr: number, base: string, priority?: string): { code: num
]);
}
+/** Write last-enqueue.json — a queued marker (no merge SHA yet). */
+function writeEnqueueState(slug: string, pr: number, repo: string, regime: Regime, base: string): string {
+ const dir = join(homedir(), '.gstack', 'projects', slug);
+ mkdirSync(dir, { recursive: true });
+ const dest = join(dir, 'last-enqueue.json');
+ const tmp = `${dest}.${process.pid}.tmp`;
+ const body = { schema_version: 1, pr, repo, regime, base, queued: true, ts: new Date().toISOString() };
+ writeFileSync(tmp, JSON.stringify(body, null, 2) + '\n', { mode: 0o600 });
+ renameSync(tmp, dest);
+ return dest;
+}
+
+/**
+ * Confirm a queue regime actually picked the PR up, then RETURN (don't wait for
+ * it to land). This is the default for /land on a queue regime — the whole
+ * point of a merge queue is that you don't babysit. Trunk lands it (optimistic
+ * merge / parallel / batching) on its own.
+ *
+ * Confirmed when: PR already MERGED (fast direct-merge-to-main), OR the
+ * "Trunk Merge Queue ()" check appears (trunk), OR GitHub auto-merge is
+ * enabled (github). Times out for trunk if a posted "/trunk merge" was inert
+ * (App not installed / "GitHub commands" off).
+ */
+async function cmdConfirmEnqueue(flags: Record): Promise {
+ const pr = flags.pr;
+ const base = flags.base;
+ if (!pr || !base) { process.stderr.write('confirm-enqueue: --pr and --base are required\n'); return 2; }
+ const regime = (flags.regime || 'none') as Regime;
+ const enqueueMs = (Number(flags['enqueue-timeout']) || 120) * 1000;
+ const intervalMs = (Number(flags.interval) || 15) * 1000;
+
+ if (regime === 'none') {
+ // Direct merge is synchronous — there is nothing to enqueue.
+ const v = prView(pr) || {};
+ process.stdout.write(`ENQUEUE_NA=none\nPR_STATE=${(v.state || 'OPEN').toUpperCase()}\n`);
+ return 0;
+ }
+
+ const start = Date.now();
+ let confirmed = false;
+ let how = '';
+ while (Date.now() - start < enqueueMs) {
+ const v = prView(pr) || {};
+ if ((v.state || '').toUpperCase() === 'MERGED') { confirmed = true; how = 'already merged'; break; }
+ if (regime === 'trunk' && findQueueCheck(prChecks(pr), base)) {
+ confirmed = true; how = `${trunkQueueCheckName(base)} check present`; break;
+ }
+ if (regime === 'github' && v.autoMergeRequest != null) {
+ confirmed = true; how = 'GitHub auto-merge enabled'; break;
+ }
+ await sleep(Math.min(intervalMs, 15000));
+ }
+
+ if (!confirmed) {
+ if (regime === 'trunk') {
+ process.stderr.write(
+ 'TRUNK_ENQUEUE_TIMEOUT — posted "/trunk merge" but no "' + trunkQueueCheckName(base) +
+ '" check appeared. Is the Trunk GitHub App installed and "GitHub commands" enabled?\n',
+ );
+ } else {
+ process.stderr.write(
+ 'ENQUEUE_UNCONFIRMED — GitHub auto-merge is not enabled on the PR after submit. ' +
+ 'Check the repo merge-queue / auto-merge settings.\n',
+ );
+ }
+ return 1;
+ }
+
+ let stateFile = '';
+ if (flags.slug) stateFile = writeEnqueueState(flags.slug, Number(pr), repoSlug(), regime, base);
+
+ process.stdout.write(`ENQUEUED=${regime}\nENQUEUE_HOW=${how}\n`);
+ if (regime === 'trunk') {
+ process.stdout.write(`WATCH_CHECK=${trunkQueueCheckName(base)}\nWATCH_DASHBOARD=https://app.trunk.io\n`);
+ } else {
+ process.stdout.write('WATCH_CHECK=GitHub merge queue (on the PR)\n');
+ }
+ if (stateFile) process.stdout.write(`ENQUEUE_FILE=${stateFile}\n`);
+ return 0;
+}
+
async function cmdWait(flags: Record): Promise {
const pr = flags.pr;
const base = flags.base;
@@ -416,11 +506,12 @@ async function main(): Promise {
switch (cmd) {
case 'detect': return cmdDetect(flags, bools);
case 'submit': return cmdSubmit(flags);
+ case 'confirm-enqueue': return cmdConfirmEnqueue(flags);
case 'wait': return cmdWait(flags);
case 'write-state': return cmdWriteState(flags);
case 'read-state': return cmdReadState(flags, bools);
default:
- process.stderr.write('usage: gstack-merge [flags]\n');
+ process.stderr.write('usage: gstack-merge [flags]\n');
return 2;
}
}