gh-stack 0.18.0 → 0.19.1

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.
@@ -1,5 +1,11 @@
1
1
  // gh-stack restack — Rebase children onto updated parents
2
2
  // Port of reference/gh-stack-sync.sh — the most critical command
3
+ //
4
+ // Every run (restack, sync, join's restack, restack --onto) keeps its state in
5
+ // `.git/.gh-stack/restack-state.json` from its first step until its last push,
6
+ // with an action log of what it did (#15). `restack --resume` continues from
7
+ // that state, and a bare `restack`/`sync` offers to resume a pending run
8
+ // instead of starting a different one on top of it (#29).
3
9
  import * as p from "../lib/output.ts";
4
10
  import pc from "picocolors";
5
11
  import * as git from "../lib/git.ts";
@@ -9,32 +15,121 @@ import {
9
15
  buildRebaseChain,
10
16
  getOrderedBranches,
11
17
  saveRestackState,
12
- loadRestackState,
13
18
  clearRestackState,
14
19
  readMetadata,
15
20
  writeMetadata,
16
21
  } from "../lib/metadata.ts";
22
+ import { restackStateFile } from "../lib/paths.ts";
17
23
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
18
24
  import { takeSnapshot, resolveRestackBoundary, findRecordedBaseTip } from "../lib/snapshot.ts";
19
- import { confirmAction } from "../lib/ui.ts";
20
- import type { StackMetadata } from "../types.ts";
25
+ import { guardRemoteContent } from "../lib/remote-guard.ts";
26
+ import { setPrBase } from "../lib/github.ts";
27
+ import { confirmAction, isAutoYes } from "../lib/ui.ts";
28
+ import { describeSource, flagSetting, resolveFlag, type Resolved } from "../lib/config.ts";
29
+ import {
30
+ describeRun,
31
+ findPendingRun,
32
+ formatRunLog,
33
+ loadRunState,
34
+ outstandingPushes,
35
+ remainingSteps,
36
+ stoppedAt,
37
+ } from "../lib/run-log.ts";
38
+ import { RUN_STATE_VERSION } from "../types.ts";
39
+ import type { RestackState, RunLogEntry, Stack, StackMetadata } from "../types.ts";
21
40
 
22
41
  /**
23
- * `--no-push` / `GH_STACK_NO_PUSH=1`: rebase locally, never publish (#36).
24
- * Module-level because every rebase path funnels through `pushBranchRef`.
25
- * Wins over `--yes`, which still auto-accepts the rebases themselves.
42
+ * `--no-push` / `GH_STACK_NO_PUSH=1` / `[restack] push = false`: rebase
43
+ * locally, never publish (#36, #39). Module-level because every rebase path
44
+ * funnels through `pushBranchRef`. Wins over `--yes`, which still auto-accepts
45
+ * the rebases themselves.
26
46
  */
27
47
  let noPush = false;
48
+ /** Why pushing is off, for the local-only summary (e.g. "--no-push"). */
49
+ let noPushReason = "--no-push";
50
+
51
+ /**
52
+ * The run in progress. Its state is saved after every step, so an interrupted
53
+ * run can be resumed and can report what it did. Null in a dry run.
54
+ */
55
+ let run: RestackState | null = null;
56
+
57
+ /**
58
+ * `--overwrite-remote`: rewrite branches even when origin has commits they
59
+ * lack (#28). Off by default: such a run stops before touching anything.
60
+ */
61
+ let overwriteRemote = false;
62
+
63
+ /**
64
+ * Origin tips the remote guard checked and cleared for overwriting this run
65
+ * (they hold only earlier versions of local commits, or --overwrite-remote
66
+ * said so). Pushes over them use an explicit lease on that sha.
67
+ */
68
+ let verifiedTips = new Map<string, string>();
69
+
70
+ /** Stop before a run rewrites `branches` if origin holds content they lack (#28). */
71
+ async function guardRemote(
72
+ meta: StackMetadata,
73
+ branches: string[],
74
+ dryRun: boolean,
75
+ ): Promise<void> {
76
+ const cleared = await guardRemoteContent((await readMetadata()) ?? meta, branches, {
77
+ command: "restack",
78
+ overwrite: overwriteRemote,
79
+ dryRun,
80
+ });
81
+ for (const [branch, sha] of cleared) verifiedTips.set(branch, sha);
82
+ }
83
+
84
+ /** What one invocation accumulates while it walks a chain. */
85
+ interface ChainContext {
86
+ /** Branches rebased by this invocation. */
87
+ rebased: string[];
88
+ /** Every branch this invocation is responsible for publishing; verified at the end. */
89
+ touched: string[];
90
+ /** Branches whose push failed, was declined or held: their children are held too. */
91
+ unpublished: Set<string>;
92
+ /** Branch tips on origin (one `ls-remote` per run, kept current as we push). */
93
+ remote: Map<string, string> | null;
94
+ dryRun: boolean;
95
+ verbose: boolean;
96
+ }
97
+
98
+ function newContext(dryRun: boolean, verbose: boolean): ChainContext {
99
+ return { rebased: [], touched: [], unpublished: new Set(), remote: null, dryRun, verbose };
100
+ }
28
101
 
29
- /** Whether `--no-push` (or `GH_STACK_NO_PUSH=1`) is in effect for these args. */
30
- export function wantsNoPush(args: string[]): boolean {
31
- return args.includes("--no-push") || process.env.GH_STACK_NO_PUSH === "1";
102
+ /**
103
+ * Origin's branch tips, read from the remote itself (not remote-tracking refs,
104
+ * which can be stale — #23) on first use.
105
+ */
106
+ async function remoteTips(ctx: ChainContext): Promise<Map<string, string>> {
107
+ ctx.remote ??= await git.remoteHeads();
108
+ return ctx.remote;
109
+ }
110
+
111
+ /**
112
+ * Whether restack/sync push, from `--push`/`--no-push`, `GH_STACK_NO_PUSH`, or
113
+ * the `[restack] push` config default. Says so when a default turned pushing
114
+ * off, since nothing on the command line explains it.
115
+ */
116
+ export async function resolvePush(args: string[]): Promise<Resolved> {
117
+ const push = await resolveFlag("restack.push", args);
118
+ if (!push.value && push.source !== "flag") {
119
+ p.log.info(pc.dim(`Not pushing: ${describeSource(push)}. Pass --push to override.`));
120
+ }
121
+ return push;
122
+ }
123
+
124
+ /** What to call the source of a "don't push" decision in the summary. */
125
+ function pushReason(push: Resolved): string {
126
+ return push.source === "flag" ? "--no-push" : describeSource(push);
32
127
  }
33
128
 
34
129
  export default async function restack(args: string[]): Promise<void> {
35
130
  const dryRun = args.includes("--dry-run");
36
- noPush = wantsNoPush(args);
37
131
  const resume = args.includes("--resume");
132
+ const fresh = args.includes("--fresh");
38
133
  const verbose = args.includes("--verbose") || args.includes("-v");
39
134
 
40
135
  // --onto <branch>: re-root the current stack onto a new base (e.g. move a
@@ -55,12 +150,40 @@ USAGE
55
150
  OPTIONS
56
151
  --onto <ref> Re-root the stack onto a new base ref (changes stack base)
57
152
  --yes, -y Skip confirmations (auto-accept all rebases and pushes)
58
- --no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1).
153
+ --no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1,
154
+ or \`[restack] push = false\` in config; see gh-stack config).
59
155
  Wins over --yes for pushes; rebases are still auto-accepted
60
- --resume Resume after resolving rebase conflicts
156
+ --push Push even when a config default or env var says not to
157
+ --resume Continue an interrupted restack/sync (after a conflict, a
158
+ failed push, or a kill). Keeps the interrupted run's push
159
+ decision unless --push/--no-push is passed. If the run had
160
+ finished, prints what it did
161
+ --fresh Discard an interrupted run instead of resuming it, then
162
+ restack from the current branch
163
+ --overwrite-remote
164
+ Rewrite branches even though origin has commits they lack
165
+ (see below). Those commits are dropped from origin
61
166
  --dry-run Show what would happen without executing
62
167
  --verbose Show diagnostic info (snapshot vs merge-base)
63
168
 
169
+ COMMITS ONLY ON ORIGIN
170
+ Before rewriting anything, restack and sync check each branch's copy on
171
+ origin. If it has commits the local branch never had (e.g. the squash
172
+ commit \`merge --collapse\` adds to a parent PR's branch on GitHub), the run
173
+ stops and names them: pull or cherry-pick them first. Earlier versions of
174
+ local commits (left on origin by a --no-push or interrupted run) don't
175
+ count. Interactive runs ask; --yes stops.
176
+
177
+ INTERRUPTED RUNS
178
+ A restack or sync saves its progress after every rebase and push. If one
179
+ was interrupted, a bare \`gh-stack restack\` (or \`sync\`) asks whether to
180
+ resume it; with --yes it resumes. It never starts a different run on top
181
+ of an unfinished one unless you pass --fresh.
182
+
183
+ A branch whose parent's push failed (or was declined) is not pushed:
184
+ its PR would be diffed against the parent's old history. Run
185
+ \`gh-stack restack --resume\` to retry the pushes, parents first.
186
+
64
187
  EXAMPLES
65
188
  gh-stack restack Interactive (prompts before each rebase)
66
189
  gh-stack restack --yes Non-interactive (for agents/CI)
@@ -76,26 +199,53 @@ ALIASES
76
199
  return;
77
200
  }
78
201
 
79
- // Reject if a git rebase is already in progress (unless we're resuming).
80
- // Letting commands run on top of a half-finished rebase produces deeply
81
- // confusing state — better to make the user finish or abort first.
82
- if (!resume && (await git.isRebaseInProgress())) {
83
- p.cancel(
84
- `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green("gh-stack restack")} (or ${pc.green("gh-stack restack --resume")} to continue).`,
85
- );
202
+ run = null;
203
+ overwriteRemote = args.includes("--overwrite-remote");
204
+ verifiedTips = new Map();
205
+
206
+ if (resume && (fresh || ontoFlag)) {
207
+ p.cancel(`--resume can't be combined with ${fresh ? "--fresh" : "--onto"}.`);
86
208
  process.exit(1);
87
209
  }
88
210
 
89
- // Ensure clean working tree (unless resuming)
211
+ // Reject if a git rebase is already in progress (unless we're resuming,
212
+ // which reports it itself). Letting commands run on top of a half-finished
213
+ // rebase produces deeply confusing state.
214
+ if (!resume && (await git.isRebaseInProgress())) {
215
+ await refuseRebaseInProgress("restack");
216
+ }
217
+
218
+ // An interrupted run is pending: resume it (or, on request, discard it)
219
+ // rather than silently starting a different run on top of it (#29).
220
+ let resuming = resume;
90
221
  if (!resume) {
222
+ if (dryRun) await notePendingRun();
223
+ else {
224
+ const preflight = async () => {
225
+ await ensureCleanWorkingTree();
226
+ await resolveFlag("restack.push", args); // a bad setting stops here
227
+ const meta = await ensureMetadata();
228
+ if (!ontoFlag) {
229
+ const branch = await git.currentBranch();
230
+ if (!findStackForBranch(meta, branch)) refuseNotInStack(branch);
231
+ }
232
+ };
233
+ resuming =
234
+ (await routePendingRun("restack", { fresh, onto: ontoFlag, preflight })) === "resume";
235
+ }
236
+ }
237
+
238
+ // A resume keeps the push decision of the run it resumes (unless this
239
+ // command passes --push/--no-push), so it's settled in handleResume.
240
+ if (!resuming) {
241
+ const push = await resolvePush(args);
242
+ noPush = !push.value;
243
+ noPushReason = pushReason(push);
91
244
  await ensureCleanWorkingTree();
92
245
  }
93
246
 
94
247
  const meta = await ensureMetadata();
95
- const rebasedBranches: string[] = [];
96
- // Every branch restack is responsible for this run (rebased OR already
97
- // up to date) — we push each one and verify its ref landed at the end.
98
- const touched: string[] = [];
248
+ const ctx = newContext(dryRun, verbose);
99
249
 
100
250
  p.intro(pc.cyan("Git Stack Restack"));
101
251
 
@@ -104,60 +254,75 @@ ALIASES
104
254
  await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
105
255
 
106
256
  if (ontoFlag) {
107
- await handleReroot(meta, ontoFlag, rebasedBranches, touched, dryRun, verbose);
108
- } else if (resume) {
109
- await handleResume(meta, rebasedBranches, touched, verbose);
257
+ await handleReroot(meta, ontoFlag, ctx);
258
+ } else if (resuming) {
259
+ const outcome = await handleResume(meta, ctx, flagSetting("restack.push", args));
260
+ if (outcome === "already-finished") return;
110
261
  } else {
111
- await handleFreshRestack(meta, rebasedBranches, touched, dryRun, verbose);
262
+ await handleFreshRestack(meta, ctx);
112
263
  }
113
264
 
114
- // Summary
265
+ await finishRun(meta, ctx, "Stack Restack Complete!");
266
+ }
267
+
268
+ /**
269
+ * End of a run: print its action log, verify every ref it touched landed on
270
+ * origin, and mark the saved state finished — or leave it pending (exit 1)
271
+ * when pushes are outstanding, so `restack --resume` retries them.
272
+ */
273
+ async function finishRun(
274
+ meta: StackMetadata,
275
+ ctx: ChainContext,
276
+ doneMessage: string,
277
+ ): Promise<void> {
115
278
  console.log();
116
- if (rebasedBranches.length === 0) {
279
+ if (run && run.log.length > 0) {
280
+ p.log.info(`Run log (${describeRun(run)}):`);
281
+ for (const line of formatRunLog(run)) console.log(line);
282
+ } else if (ctx.rebased.length === 0) {
117
283
  p.log.warn("No branches were rebased");
118
- } else {
119
- p.log.success("Rebased branches:");
120
- for (const branch of rebasedBranches) {
121
- console.log(` ${pc.green("✓")} ${branch}`);
122
- }
284
+ }
285
+
286
+ if (ctx.dryRun) {
287
+ p.outro(pc.green(doneMessage));
288
+ return;
123
289
  }
124
290
 
125
291
  // Verification pass: assert every touched branch's ref actually landed on
126
292
  // origin. Fail loudly (listing the stale ones) rather than reporting success
127
293
  // when a push didn't take. (#12)
128
- if (!dryRun) {
129
- const stale = await findStaleRefs(touched, meta);
130
- if (stale.length > 0 && noPush) {
131
- // Expected under --no-push: report what's ahead of origin so a later
132
- // submit/restack knows what to publish, but don't fail the run.
133
- console.log();
134
- p.log.info("Not pushed (--no-push) — these refs are ahead of origin:");
135
- for (const { branch, local, remote } of stale) {
136
- console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
137
- }
138
- console.log();
139
- console.log(
140
- ` Publish when ready: ${pc.green("gh-stack restack")} or ${pc.green("gh-stack submit")}`,
141
- );
142
- p.outro(pc.green("Stack Restack Complete (local only)"));
143
- return;
294
+ const stale = await findStaleRefs(ctx.touched, meta, await remoteTips(ctx));
295
+ if (stale.length > 0 && noPush) {
296
+ // Expected under --no-push: report what's ahead of origin so a later
297
+ // submit/restack knows what to publish, but don't fail the run.
298
+ console.log();
299
+ p.log.info(`Not pushed (${noPushReason}) — these refs are ahead of origin:`);
300
+ for (const { branch, local, remote } of stale) {
301
+ console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
144
302
  }
145
- if (stale.length > 0) {
146
- console.log();
147
- p.log.error("Some branch refs are NOT in sync with origin:");
148
- for (const { branch, local, remote } of stale) {
149
- console.log(` ${pc.red("✗")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
150
- }
151
- console.log();
152
- console.log(
153
- ` Reconcile with: ${pc.green("gh-stack restack")} (re-runs the push for stale refs)`,
154
- );
155
- p.outro(pc.red("Restack finished with un-pushed refs"));
156
- process.exit(1);
303
+ console.log();
304
+ console.log(
305
+ ` Publish when ready: ${pc.green("gh-stack restack --push")} or ${pc.green("gh-stack submit")}`,
306
+ );
307
+ await markRunFinished();
308
+ p.outro(pc.green(`${doneMessage.replace(/!$/, "")} (local only)`));
309
+ return;
310
+ }
311
+ if (stale.length > 0) {
312
+ console.log();
313
+ p.log.error("Some branch refs are NOT in sync with origin:");
314
+ for (const { branch, local, remote } of stale) {
315
+ console.log(` ${pc.red("✗")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
157
316
  }
317
+ console.log();
318
+ console.log(` Retry the pushes (parents first): ${pc.green("gh-stack restack --resume")}`);
319
+ if (run) await saveRun(); // stays pending: the pushes are what's left
320
+ p.outro(pc.red("Restack finished with un-pushed refs"));
321
+ process.exit(1);
158
322
  }
159
323
 
160
- p.outro(pc.green("Stack Restack Complete!"));
324
+ await markRunFinished();
325
+ p.outro(pc.green(doneMessage));
161
326
  }
162
327
 
163
328
  /**
@@ -180,10 +345,11 @@ ALIASES
180
345
  export async function findStaleRefs(
181
346
  branches: string[],
182
347
  meta: StackMetadata,
348
+ remote?: Map<string, string>,
183
349
  ): Promise<{ branch: string; local: string; remote: string }[]> {
184
350
  const stale: { branch: string; local: string; remote: string }[] = [];
185
351
  const seen = new Set<string>();
186
- const remoteHeads = await git.remoteHeads();
352
+ const remoteHeads = remote ?? (await git.remoteHeads());
187
353
  for (const branch of branches) {
188
354
  if (seen.has(branch) || branch === "main" || branch === "master") continue;
189
355
  seen.add(branch);
@@ -204,101 +370,401 @@ export async function findStaleRefs(
204
370
  return stale;
205
371
  }
206
372
 
207
- async function handleResume(
208
- meta: StackMetadata,
209
- rebasedBranches: string[],
210
- touched: string[],
211
- verbose: boolean,
212
- ): Promise<void> {
213
- p.log.warn("Resuming from saved state...");
373
+ // ── Interrupted runs (#29) ──
214
374
 
215
- const state = await loadRestackState();
216
- if (!state) {
217
- p.cancel("No saved state found. Nothing to resume.");
218
- process.exit(1);
375
+ /**
376
+ * A rebase is in progress, so no new run may start. When it's the conflict a
377
+ * gh-stack run stopped on, say so and give that run's way forward.
378
+ */
379
+ export async function refuseRebaseInProgress(command: string): Promise<never> {
380
+ const state = await loadRunState();
381
+ if (state && !state.finished_at && state.rebase) {
382
+ p.cancel(
383
+ `An interrupted ${state.command} stopped on a conflict rebasing ${pc.yellow(state.rebase.branch)} onto ${pc.yellow(state.rebase.onto)}, and that rebase is still in progress.\n\n` +
384
+ ` Resolve the conflicts, then finish the ${state.command}:\n` +
385
+ ` ${pc.green("git add <files>")}\n` +
386
+ ` ${pc.green("git rebase --continue")}\n` +
387
+ ` ${pc.green("gh-stack restack --resume")}\n\n` +
388
+ ` Or give up on it: ${pc.red("git rebase --abort")}`,
389
+ );
390
+ } else {
391
+ p.cancel(
392
+ `Git rebase already in progress.\n\n Finish or abort it first:\n ${pc.green("git rebase --continue")}\n ${pc.red("git rebase --abort")}\n\n Then re-run ${pc.green(`gh-stack ${command}`)}.`,
393
+ );
219
394
  }
220
- // An interrupted --no-push run stays local-only when resumed.
221
- if (state.no_push) noPush = true;
395
+ process.exit(1);
396
+ }
222
397
 
223
- // Check if rebase is still in progress
224
- if (await git.isRebaseInProgress()) {
398
+ /** Print what an interrupted run did and what it has left. */
399
+ function describePendingRun(state: RestackState): void {
400
+ console.log(` ${pc.dim("Stack:")} ${state.stack_name}`);
401
+ if (state.log.length > 0) {
402
+ console.log(` ${pc.dim("Done so far:")}`);
403
+ for (const line of formatRunLog(state)) console.log(` ${line}`);
404
+ }
405
+ const left = remainingSteps(state);
406
+ if (left.length > 0) {
407
+ console.log(` ${pc.dim("Left to do:")}`);
408
+ for (const step of left) console.log(` ${pc.dim("→")} ${step}`);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Before a fresh restack/sync: if an interrupted run is pending, decide what
414
+ * to do about it. Interactive: ask (resume / start fresh / cancel). Under
415
+ * --yes: resume, never start fresh. `--fresh` discards it (also the way out
416
+ * of a state file gh-stack can't read).
417
+ *
418
+ * `preflight` is the new run's own preconditions (clean tree, valid push
419
+ * setting, the current branch in a stack, ...). It runs before anything is
420
+ * discarded, so a command that's going to refuse anyway leaves the
421
+ * interrupted run intact.
422
+ */
423
+ export async function routePendingRun(
424
+ command: "restack" | "sync",
425
+ opts: { fresh: boolean; onto?: string; preflight: () => Promise<void> },
426
+ ): Promise<"resume" | "fresh"> {
427
+ if (opts.fresh) await opts.preflight();
428
+ const state = await findPendingRun({ discard: opts.fresh });
429
+ if (!state) return "fresh";
430
+
431
+ if (opts.fresh) {
432
+ p.log.warn(
433
+ `--fresh: discarding the interrupted ${describeRun(state)} (stopped at ${stoppedAt(state)}).`,
434
+ );
435
+ describePendingRun(state);
436
+ await clearRestackState();
437
+ return "fresh";
438
+ }
439
+
440
+ if (opts.onto) {
225
441
  p.cancel(
226
- `Rebase still in progress.\n\n Complete it first:\n ${pc.green("git rebase --continue")}\n\n Or abort:\n ${pc.red("git rebase --abort")}`,
442
+ `An interrupted ${state.command} is pending (stopped at ${stoppedAt(state)}).\n\n` +
443
+ ` Finish it first: ${pc.green("gh-stack restack --resume")}\n` +
444
+ ` Or discard it: ${pc.green(`gh-stack restack --onto ${opts.onto} --fresh`)}`,
227
445
  );
228
446
  process.exit(1);
229
447
  }
230
448
 
231
- const currentBranch = await git.currentBranch();
232
- const expectedBranch = state.chain[state.current_index]!;
449
+ const message = `An interrupted ${state.command} was detected (stopped at ${stoppedAt(state)}). Resume it?`;
450
+ if (isAutoYes()) {
451
+ p.log.warn(`${message} ${pc.dim("(auto-yes: resuming)")}`);
452
+ describePendingRun(state);
453
+ return "resume";
454
+ }
233
455
 
234
- // Check for branch mismatch (likely means rebase was aborted)
235
- if (currentBranch !== expectedBranch) {
236
- p.log.warn("Branch mismatch detected");
237
- p.log.info(`Expected: ${pc.blue(expectedBranch)}`);
238
- p.log.info(`Current: ${pc.blue(currentBranch)}`);
239
- console.log();
240
- p.log.info("This likely means the rebase was aborted. Cleaning up stale state...");
456
+ p.log.warn(`An interrupted ${describeRun(state)} was detected.`);
457
+ describePendingRun(state);
458
+ const choice = await p.select({
459
+ message,
460
+ options: [
461
+ { value: "resume", label: "Resume it", hint: "recommended" },
462
+ {
463
+ value: "fresh",
464
+ label: `Discard it and start a new ${command}`,
465
+ hint: "same as --fresh",
466
+ },
467
+ { value: "cancel", label: "Cancel" },
468
+ ],
469
+ });
470
+ if (p.isCancel(choice) || choice === "cancel") {
471
+ p.cancel("Cancelled — the interrupted run is still saved.");
472
+ process.exit(0);
473
+ }
474
+ if (choice === "fresh") {
475
+ await opts.preflight();
241
476
  await clearRestackState();
242
- console.log();
243
- console.log(` State cleared. To sync the stack, run from the desired branch:`);
244
- console.log(` ${pc.green("gh-stack restack")}`);
245
- return;
477
+ return "fresh";
246
478
  }
479
+ return "resume";
480
+ }
247
481
 
248
- // Rebase completed successfully
249
- p.log.success("Rebase completed successfully");
250
- rebasedBranches.push(expectedBranch);
251
- touched.push(expectedBranch);
252
- await clearRestackState();
253
-
254
- // Prompt to push the resumed branch
255
- await pushBranchRef(meta, expectedBranch);
256
-
257
- // Continue with remaining branches in chain
258
- const stackName = state.stack_name;
259
- const stack = meta.stacks[stackName];
260
- if (!stack) return;
261
-
262
- const chain = state.chain;
263
- const currentIndex = state.current_index + 1;
264
-
265
- if (currentIndex < chain.length) {
266
- await processChain(
267
- meta,
268
- stackName,
269
- chain,
270
- currentIndex,
271
- rebasedBranches,
272
- touched,
273
- false,
274
- verbose,
275
- );
482
+ /** Dry run: mention a pending run (a real run would offer to resume it), change nothing. */
483
+ export async function notePendingRun(): Promise<void> {
484
+ const state = await findPendingRun();
485
+ if (!state) return;
486
+ p.log.warn(
487
+ `An interrupted ${state.command} is pending (stopped at ${stoppedAt(state)}). A real run would offer to resume it (or pass --fresh to discard it).`,
488
+ );
489
+ }
490
+
491
+ /**
492
+ * For commands that rewrite or merge the stack outside a restack run (merge,
493
+ * join): refuse while a run is pending. Its un-pushed branches would otherwise
494
+ * be merged, or rebased again, in their stale state.
495
+ */
496
+ export async function ensureNoPendingRun(command: string): Promise<void> {
497
+ const state = await findPendingRun();
498
+ if (!state) return;
499
+ // One run can be pending per worktree, and `command` restacks internally,
500
+ // which would overwrite this run's state: so this is global, not per stack.
501
+ const meta = await readMetadata();
502
+ const branch = await git.currentBranch().catch(() => null);
503
+ const here = meta && branch ? findStackForBranch(meta, branch) : null;
504
+ const pending = state.stack_name;
505
+ const freshEffect = !here
506
+ ? `it drops ${pending}'s remaining steps; run it from a branch of the stack you want restacked`
507
+ : here === pending
508
+ ? `it drops the remaining steps and restacks ${here} from the current branch`
509
+ : `it drops ${pending}'s remaining steps, then restacks ${here} (the current branch's stack), not ${pending}`;
510
+ p.cancel(
511
+ `An interrupted ${state.command} of stack ${pc.yellow(pending)} is pending (stopped at ${stoppedAt(state)}). Only one run can be pending, and ${command} would start another.\n\n` +
512
+ ` Finish it (from any branch):\n ${pc.green("gh-stack restack --resume")}\n\n` +
513
+ ` Or discard it:\n ${pc.green("gh-stack restack --fresh")} ${pc.dim(`(${freshEffect})`)}`,
514
+ );
515
+ process.exit(1);
516
+ }
517
+
518
+ // ── Run state ──
519
+
520
+ async function saveRun(): Promise<void> {
521
+ if (run) await saveRestackState(run);
522
+ }
523
+
524
+ async function beginRun(init: {
525
+ command: string;
526
+ stackName: string;
527
+ chain: string[];
528
+ pushOnly: string[];
529
+ startIndex?: number;
530
+ }): Promise<void> {
531
+ run = {
532
+ version: RUN_STATE_VERSION,
533
+ command: init.command,
534
+ stack_name: init.stackName,
535
+ chain: init.chain,
536
+ push_only: init.pushOnly,
537
+ current_index: init.startIndex ?? 0,
538
+ rebase: null,
539
+ declined: [],
540
+ push: !noPush,
541
+ started_at: new Date().toISOString(),
542
+ finished_at: null,
543
+ log: [],
544
+ };
545
+ await saveRun();
546
+ }
547
+
548
+ async function record(entry: Omit<RunLogEntry, "at">): Promise<void> {
549
+ if (!run) return;
550
+ run.log.push({ at: new Date().toISOString(), ...entry });
551
+ await saveRun();
552
+ }
553
+
554
+ async function markRunFinished(): Promise<void> {
555
+ if (!run) return;
556
+ run.rebase = null;
557
+ run.finished_at = new Date().toISOString();
558
+ await saveRun();
559
+ }
560
+
561
+ /**
562
+ * Record a rebase about to start (with what it takes to run it again), so a
563
+ * resume can tell a finished rebase from one that never ran or was aborted.
564
+ */
565
+ async function startRebase(
566
+ branch: string,
567
+ onto: string,
568
+ preTip: string,
569
+ boundary: string | null,
570
+ ): Promise<void> {
571
+ if (!run) return;
572
+ run.rebase = { branch, onto, onto_sha: await git.revParse(onto), pre_tip: preTip, boundary };
573
+ await saveRun();
574
+ }
575
+
576
+ /** The rebase succeeded: what's left for this branch is its push. */
577
+ async function finishRebase(branch: string, onto: string, ctx: ChainContext): Promise<void> {
578
+ ctx.rebased.push(branch);
579
+ ctx.unpublished.delete(branch); // a declined rebase that's now done; a failed push re-adds it
580
+ if (!run) return;
581
+ run.rebase = null;
582
+ run.declined = run.declined.filter((b) => b !== branch);
583
+ if (!run.push_only.includes(branch)) run.push_only.push(branch);
584
+ await record({ branch, action: "rebased", onto });
585
+ }
586
+
587
+ /** Stop on a rebase conflict, leaving the run resumable (exit 2). */
588
+ function conflictStop(): never {
589
+ console.log();
590
+ console.log(pc.red("━".repeat(40)));
591
+ console.log(pc.red(" Rebase Conflict"));
592
+ console.log(pc.red("━".repeat(40)));
593
+ console.log();
594
+ console.log(pc.yellow("Conflicts detected during rebase."));
595
+ console.log();
596
+ console.log(" Please resolve the conflicts, then:");
597
+ console.log(` ${pc.green("git rebase --continue")}`);
598
+ console.log(` ${pc.green("gh-stack restack --resume")}`);
599
+ console.log();
600
+ console.log(" Or give up on the run:");
601
+ console.log(` ${pc.red("git rebase --abort")}`);
602
+ console.log(
603
+ ` ${pc.red("gh-stack restack --fresh")} ${pc.dim("(a resume would run this rebase again)")}`,
604
+ );
605
+ process.exit(2);
606
+ }
607
+
608
+ /** The chain's branches that are, or sit above, one of `roots`. */
609
+ function withDescendants(stack: Stack, chain: string[], roots: string[]): Set<string> {
610
+ const out = new Set(roots);
611
+ for (const branch of chain) {
612
+ const parent = stack.branches[branch]?.parent;
613
+ if (parent && out.has(parent)) out.add(branch);
276
614
  }
615
+ return out;
277
616
  }
278
617
 
279
- async function handleFreshRestack(
618
+ async function handleResume(
280
619
  meta: StackMetadata,
281
- rebasedBranches: string[],
282
- touched: string[],
283
- dryRun: boolean,
284
- verbose: boolean,
285
- ): Promise<void> {
286
- const currentBranch = await git.currentBranch();
287
- const stackName = findStackForBranch(meta, currentBranch);
620
+ ctx: ChainContext,
621
+ explicitPush: Resolved | null,
622
+ ): Promise<"resumed" | "already-finished"> {
623
+ const state = await loadRunState();
624
+ if (!state) {
625
+ p.cancel(
626
+ `Nothing to resume: there's no saved run state (${await restackStateFile()}).\n\n` +
627
+ ` If a rebase is in progress, finish it first: ${pc.green("git rebase --continue")}\n` +
628
+ ` Then reconcile the stack from the branch to restack from:\n` +
629
+ ` ${pc.green("gh-stack restack")}\n` +
630
+ ` It's safe to re-run: branches already on their parent aren't rebased again,\n` +
631
+ ` and refs that differ from origin are pushed.`,
632
+ );
633
+ process.exit(1);
634
+ }
288
635
 
289
- if (!stackName) {
636
+ if (state.finished_at) {
637
+ p.log.success(
638
+ `Nothing left to resume — the last run (${describeRun(state)}) finished every step.`,
639
+ );
640
+ for (const line of formatRunLog(state)) console.log(line);
641
+ p.outro(pc.green("Nothing to resume"));
642
+ return "already-finished";
643
+ }
644
+
645
+ // Keep the interrupted run's push decision, so a local-only run stays local
646
+ // even if the config changed meanwhile. An explicit flag on --resume wins,
647
+ // and becomes the run's decision for any later resume too.
648
+ if (explicitPush) {
649
+ noPush = !explicitPush.value;
650
+ noPushReason = "--no-push";
651
+ state.push = explicitPush.value;
652
+ } else {
653
+ noPush = !state.push;
654
+ noPushReason = "resumed a local-only run; its push decision is kept";
655
+ }
656
+
657
+ if (await git.isRebaseInProgress()) {
658
+ p.cancel(
659
+ `Rebase still in progress.\n\n Complete it first:\n ${pc.green("git rebase --continue")}\n\n Or abort:\n ${pc.red("git rebase --abort")}`,
660
+ );
661
+ process.exit(1);
662
+ }
663
+ await ensureCleanWorkingTree();
664
+
665
+ // The world may have changed since: refuse rather than resume a chain that
666
+ // no longer matches the stack.
667
+ const saved = meta.stacks[state.stack_name];
668
+ let problem: string | null = saved ? null : `stack ${state.stack_name} no longer exists`;
669
+ for (const branch of state.chain) {
670
+ if (problem) break;
671
+ if (!saved!.branches[branch]) problem = `${branch} is no longer in stack ${state.stack_name}`;
672
+ else if (!(await git.localBranchExists(branch))) problem = `branch ${branch} no longer exists`;
673
+ }
674
+ if (problem) {
290
675
  p.cancel(
291
- `Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack add")}`,
676
+ `Can't resume the interrupted ${state.command}: ${problem}.\n\n` +
677
+ ` Discard it and restack from the current branch:\n ${pc.green("gh-stack restack --fresh")}`,
292
678
  );
293
679
  process.exit(1);
294
680
  }
295
681
 
682
+ p.log.warn(`Resuming the interrupted ${describeRun(state)} (stopped at ${stoppedAt(state)})`);
683
+ if (state.log.length > 0) {
684
+ p.log.info("Done before the interruption:");
685
+ for (const line of formatRunLog(state)) console.log(line);
686
+ }
687
+
688
+ // Origin may have moved while the run was stopped.
689
+ await guardRemote(meta, state.chain, ctx.dryRun);
690
+
691
+ // A dry run reads the state but never writes it: `run` stays null, so
692
+ // nothing below records or saves.
693
+ if (!ctx.dryRun) {
694
+ run = state;
695
+ await saveRun(); // persists a --push/--no-push override
696
+ }
697
+
698
+ // Branches the run already got past only need their pushes reconciled
699
+ // (failed or held ones are retried, parents first); the rest restack as usual.
700
+ // A declined rebase isn't done, and neither is anything above it: those are
701
+ // offered again, and pushed only once they sit on their parents.
702
+ const stack = meta.stacks[state.stack_name]!;
703
+ const redo = withDescendants(stack, state.chain, state.declined);
704
+ for (const b of state.declined) ctx.unpublished.add(b);
705
+ const pushOnly = new Set(
706
+ [...state.push_only, ...state.chain.slice(0, state.current_index)].filter((b) => !redo.has(b)),
707
+ );
708
+
709
+ if (state.rebase) {
710
+ const r = state.rebase;
711
+ const tip = await git.revParse(r.branch);
712
+ if (await git.isAncestor(r.onto_sha, tip)) {
713
+ p.log.success(`Rebase of ${pc.yellow(r.branch)} onto ${pc.yellow(r.onto)} completed`);
714
+ await finishRebase(r.branch, r.onto, ctx);
715
+ pushOnly.add(r.branch);
716
+ } else if (tip === r.pre_tip) {
717
+ // Saved as started, but the branch never moved: the run was killed
718
+ // before git began, or the user aborted the rebase. Either way the step
719
+ // isn't done, so run it again. An abort just meets its conflict again
720
+ // (and `--fresh` discards the run).
721
+ p.log.warn(
722
+ `The rebase of ${pc.yellow(r.branch)} onto ${pc.yellow(r.onto)} didn't happen (interrupted before it started, or aborted). Running it again.`,
723
+ );
724
+ if (ctx.dryRun) {
725
+ p.log.warn(`[DRY RUN] Would rebase ${r.branch} onto ${r.onto}`);
726
+ } else {
727
+ await git.checkout(r.branch);
728
+ const ok = r.boundary
729
+ ? await git.rebaseOnto(r.onto, r.boundary, r.branch)
730
+ : await git.rebase(r.onto);
731
+ if (!ok) conflictStop();
732
+ p.log.success("Rebase successful");
733
+ await finishRebase(r.branch, r.onto, ctx);
734
+ }
735
+ pushOnly.add(r.branch);
736
+ } else {
737
+ p.cancel(
738
+ `${pc.yellow(r.branch)} moved since the ${state.command} stopped: it isn't rebased onto ${r.onto} (${r.onto_sha.slice(0, 8)}), and it isn't back at its pre-rebase tip ${r.pre_tip.slice(0, 8)} either. Not guessing what happened.\n\n` +
739
+ ` Put ${r.branch} on ${r.onto} yourself, then: ${pc.green("gh-stack restack --resume")}\n` +
740
+ ` Or discard the run: ${pc.green("gh-stack restack --fresh")}`,
741
+ );
742
+ process.exit(1);
743
+ }
744
+ }
745
+
746
+ await processChain(meta, state.stack_name, state.chain, 0, pushOnly, ctx);
747
+ return "resumed";
748
+ }
749
+
750
+ function refuseNotInStack(branch: string): never {
751
+ p.cancel(
752
+ `Branch ${pc.blue(branch)} is not in any stack.\n\n Add it with:\n ${pc.green("gh-stack add")}`,
753
+ );
754
+ process.exit(1);
755
+ }
756
+
757
+ async function handleFreshRestack(meta: StackMetadata, ctx: ChainContext): Promise<void> {
758
+ const currentBranch = await git.currentBranch();
759
+ const stackName = findStackForBranch(meta, currentBranch);
760
+ if (!stackName) refuseNotInStack(currentBranch);
761
+
296
762
  const stack = meta.stacks[stackName]!;
297
763
  await ensureValidStack(meta, stackName);
298
764
 
299
765
  // Build the rebase chain from current branch
300
766
  p.log.info("Building rebase chain...");
301
- let chain = buildRebaseChain(stack, currentBranch);
767
+ const chain = buildRebaseChain(stack, currentBranch);
302
768
 
303
769
  if (chain.length === 0) {
304
770
  p.cancel("Could not build rebase chain");
@@ -316,10 +782,12 @@ async function handleFreshRestack(
316
782
  // Skip the stack's root branch (parent = base) when the base is the trunk —
317
783
  // rebasing onto trunk is `sync`'s job, not restack's. For a split stack
318
784
  // whose base is a sibling branch, the root SHOULD be rebased onto that base
319
- // so changes propagate, so we don't skip it.
785
+ // so changes propagate, so we don't skip it. A skipped root is still pushed:
786
+ // if you committed on it, its ref is stale on origin (#12).
320
787
  const base = stackBase(stack);
321
788
  const baseIsTrunk = base === "main" || base === "master";
322
789
  const baseBranches = baseIsTrunk ? chain.filter((b) => stack.branches[b]?.parent === base) : [];
790
+ const toRebase = chain.filter((b) => !baseBranches.includes(b));
323
791
  if (baseBranches.length > 0) {
324
792
  p.log.warn(`Skipping base branch(es) (parent: ${base}):`);
325
793
  for (const baseBranch of baseBranches) {
@@ -332,41 +800,30 @@ async function handleFreshRestack(
332
800
  );
333
801
  console.log();
334
802
 
335
- chain = chain.filter((b) => stack.branches[b]?.parent !== base);
336
-
337
- // We don't rebase the base branch, but if you committed on it its ref is
338
- // now stale on origin — push it so the PR reflects the new commit. (#12)
339
- if (!dryRun) {
340
- for (const baseBranch of baseBranches) {
341
- if (await git.localBranchExists(baseBranch)) {
342
- touched.push(baseBranch);
343
- await pushBranchRef(meta, baseBranch);
344
- }
803
+ if (toRebase.length === 0) {
804
+ p.log.info("No child branches to rebase — reconciling the base branch ref.");
805
+ } else {
806
+ p.log.success(`Processing ${toRebase.length} branch(es):`);
807
+ for (const branch of toRebase) {
808
+ const parent = stack.branches[branch]?.parent || "(unknown)";
809
+ console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
345
810
  }
811
+ console.log();
346
812
  }
347
-
348
- if (chain.length === 0) {
349
- p.log.info("No child branches to rebase — base branch ref reconciled above.");
350
- return;
351
- }
352
-
353
- p.log.success(`Processing ${chain.length} branch(es):`);
354
- for (const branch of chain) {
355
- const parent = stack.branches[branch]?.parent || "(unknown)";
356
- console.log(` ${pc.yellow("→")} ${branch} ${pc.blue(`(parent: ${parent})`)}`);
357
- }
358
- console.log();
359
813
  }
360
814
 
361
- if (!dryRun) {
815
+ // The base branches are force-pushed too, so they're checked with the rest.
816
+ await guardRemote(meta, chain, ctx.dryRun);
817
+
818
+ if (!ctx.dryRun) {
362
819
  // Take snapshot before destructive operation. This preserves the current
363
820
  // tip of every branch so subsequent restacks (after a parent is rewritten)
364
821
  // can find the correct rebase base.
365
- await takeSnapshot(meta, stackName, "restack");
822
+ if (toRebase.length > 0) await takeSnapshot(meta, stackName, "restack");
823
+ await beginRun({ command: "restack", stackName, chain, pushOnly: baseBranches });
366
824
  }
367
825
 
368
- // Process the chain
369
- await processChain(meta, stackName, chain, 0, rebasedBranches, touched, dryRun, verbose);
826
+ await processChain(meta, stackName, chain, 0, new Set(baseBranches), ctx);
370
827
  }
371
828
 
372
829
  /**
@@ -412,10 +869,7 @@ async function resolveRerootBoundary(
412
869
  async function handleReroot(
413
870
  meta: StackMetadata,
414
871
  newBase: string,
415
- rebasedBranches: string[],
416
- touched: string[],
417
- dryRun: boolean,
418
- verbose: boolean,
872
+ ctx: ChainContext,
419
873
  ): Promise<void> {
420
874
  const currentBranch = await git.currentBranch();
421
875
  const stackName = findStackForBranch(meta, currentBranch);
@@ -473,12 +927,13 @@ async function handleReroot(
473
927
 
474
928
  p.log.info(`Re-rooting ${pc.yellow(stackName)}: ${pc.dim(oldBase)} → ${pc.green(newBase)}`);
475
929
  p.log.info(`Root branch: ${pc.yellow(root)}`);
476
- if (verbose) {
930
+ if (ctx.verbose) {
477
931
  p.log.info(`Rebase base: ${oldBaseTip.slice(0, 8)} (${oldBaseSource})`);
478
932
  }
479
933
  console.log();
480
934
 
481
- if (dryRun) {
935
+ if (ctx.dryRun) {
936
+ await guardRemote(meta, buildRebaseChain(stack, root), true);
482
937
  p.log.warn(`[DRY RUN] Would rebase ${root} onto ${newBase} (from ${oldBaseTip.slice(0, 8)})`);
483
938
  const children = ordered.slice(1);
484
939
  if (children.length > 0) {
@@ -495,55 +950,189 @@ async function handleReroot(
495
950
  process.exit(0);
496
951
  }
497
952
 
953
+ await guardRemote(meta, buildRebaseChain(stack, root), false);
954
+
498
955
  // Snapshot before rewriting, then commit the new base into metadata.
499
956
  await takeSnapshot(meta, stackName, "reroot");
500
957
  stack.base = newBase;
501
958
  stack.branches[root]!.parent = newBase;
502
959
  await writeMetadata(meta);
503
960
 
961
+ // The root first, then everything above it (parents before children).
962
+ const chain = buildRebaseChain(stack, root);
963
+ await beginRun({ command: "restack --onto", stackName, chain, pushOnly: [] });
964
+
504
965
  // Rebase the root onto the new base, dropping the old base's commits.
505
966
  p.log.info(`Checking out ${pc.yellow(root)}...`);
506
967
  await git.checkout(root);
507
968
  p.log.info(`Rebasing ${pc.yellow(root)} onto ${pc.yellow(newBase)}...`);
508
- const ok = await git.rebaseOnto(newBase, oldBaseTip, root);
969
+ await startRebase(root, newBase, await git.revParse(root), oldBaseTip);
970
+ if (!(await git.rebaseOnto(newBase, oldBaseTip, root))) conflictStop();
509
971
 
510
- if (!ok) {
511
- console.log();
512
- p.log.error("Rebase conflict — resolve and run:");
513
- console.log(` ${pc.green("git rebase --continue")}`);
514
- console.log(
515
- ` ${pc.green("gh-stack restack")} ${pc.dim("(from the first child, to restack the rest)")}`,
516
- );
517
- process.exit(2);
972
+ await finishRebase(root, newBase, ctx);
973
+
974
+ // Push the moved root, then restack the children onto it.
975
+ await processChain(meta, stackName, chain, 0, new Set([root]), ctx);
976
+ }
977
+
978
+ /**
979
+ * Push one branch under restack's conventions: confirm, `--force-with-lease`,
980
+ * never publish a local-only branch, and do nothing under `--no-push`. Exported
981
+ * for `join` (#32), which syncs the incoming branch itself and publishes it
982
+ * before retargeting PRs onto it.
983
+ */
984
+ export async function publishBranch(
985
+ meta: StackMetadata,
986
+ branch: string,
987
+ opts: { noPush: boolean },
988
+ ): Promise<void> {
989
+ await withNoPush(opts.noPush, () => pushBranchRef(meta, branch, newContext(false, false)));
990
+ }
991
+
992
+ /**
993
+ * Restack `chain` from `startIndex`: the same loop `restack` and `--resume` run,
994
+ * including the conflict stop (exit 2, resumable with `restack --resume`).
995
+ * The branches before `startIndex` are taken as done. Exported for `join`
996
+ * (#32). Returns the branches it rebased and touched.
997
+ */
998
+ export async function restackChain(
999
+ meta: StackMetadata,
1000
+ stackName: string,
1001
+ chain: string[],
1002
+ startIndex: number,
1003
+ opts: { noPush: boolean; verbose: boolean },
1004
+ ): Promise<{ rebased: string[]; touched: string[] }> {
1005
+ const ctx = newContext(false, opts.verbose);
1006
+ await withNoPush(opts.noPush, async () => {
1007
+ const done = chain.slice(0, startIndex);
1008
+ await beginRun({ command: "join", stackName, chain, pushOnly: done, startIndex });
1009
+ await processChain(meta, stackName, chain, startIndex, new Set(done), ctx);
1010
+ if (outstandingPushes(run!).length === 0) await markRunFinished();
1011
+ });
1012
+ return { rebased: ctx.rebased, touched: ctx.touched };
1013
+ }
1014
+
1015
+ /**
1016
+ * sync's rebase phase, under the same saved state and action log as restack:
1017
+ * rebase the stack's root onto `trunkRef`, push it, then restack every branch
1018
+ * above it. A conflict stops with exit 2, resumable with `restack --resume`.
1019
+ * The caller has fetched the trunk and taken the snapshot.
1020
+ */
1021
+ export async function syncChain(
1022
+ meta: StackMetadata,
1023
+ stackName: string,
1024
+ ordered: string[],
1025
+ opts: { trunkRef: string; push: Resolved; verified: Map<string, string> },
1026
+ ): Promise<void> {
1027
+ noPush = !opts.push.value;
1028
+ noPushReason = pushReason(opts.push);
1029
+ run = null;
1030
+ verifiedTips = opts.verified;
1031
+ const ctx = newContext(false, false);
1032
+ const root = ordered[0]!;
1033
+ const { trunkRef } = opts;
1034
+
1035
+ await beginRun({ command: "sync", stackName, chain: ordered, pushOnly: [root] });
1036
+
1037
+ // Step 1: Rebase the root onto the trunk
1038
+ console.log();
1039
+ console.log(pc.cyan("━".repeat(40)));
1040
+ console.log(`${pc.blue("Step 1:")} Rebase ${pc.yellow(root)} onto ${trunkRef}`);
1041
+ console.log(pc.cyan("━".repeat(40)));
1042
+ console.log();
1043
+
1044
+ if (await git.isAncestor(trunkRef, root)) {
1045
+ p.log.success(`${root} is already up to date with ${trunkRef}`);
1046
+ await record({ branch: root, action: "up-to-date", onto: trunkRef });
1047
+ } else {
1048
+ const confirmed = await confirmAction(`Rebase ${pc.yellow(root)} onto ${trunkRef}?`);
1049
+ if (!confirmed) {
1050
+ await clearRestackState(); // nothing happened: no run to resume
1051
+ run = null;
1052
+ p.cancel("Cancelled");
1053
+ process.exit(0);
1054
+ }
1055
+
1056
+ p.log.info(`Checking out ${pc.yellow(root)}...`);
1057
+ await git.checkout(root);
1058
+ await startRebase(root, trunkRef, await git.revParse(root), null);
1059
+
1060
+ p.log.info(`Rebasing onto ${trunkRef}...`);
1061
+ if (!(await git.rebase(trunkRef))) conflictStop();
1062
+ p.log.success("Base branch rebased onto main");
1063
+ await finishRebase(root, trunkRef, ctx);
518
1064
  }
519
1065
 
520
- rebasedBranches.push(root);
521
- touched.push(root);
522
- await pushBranchRef(meta, root);
1066
+ // Push the root, whether it was rebased or already current: a commit made on
1067
+ // it since the last push is stale on origin too. Its children are held back
1068
+ // if this push doesn't land.
1069
+ ctx.touched.push(root);
1070
+ await pushStep(meta, stackName, root, ctx);
1071
+ run!.current_index = 1;
1072
+ await saveRun();
523
1073
 
524
- // Restack the children onto the moved root.
1074
+ // Step 2: Restack the rest of the stack
525
1075
  const children = ordered.slice(1);
526
1076
  if (children.length > 0) {
527
- const chain = buildRebaseChain(stack, children[0]!);
528
- await processChain(meta, stackName, chain, 0, rebasedBranches, touched, false, verbose);
1077
+ console.log();
1078
+ console.log(pc.cyan("━".repeat(40)));
1079
+ console.log(`${pc.blue("Step 2:")} Restack ${children.length} child branch(es)`);
1080
+ console.log(pc.cyan("━".repeat(40)));
1081
+ await processChain(meta, stackName, ordered, 1, new Set([root]), ctx);
1082
+ }
1083
+
1084
+ await finishRun(meta, ctx, "Sync complete!");
1085
+ }
1086
+
1087
+ /**
1088
+ * Run `fn` with the module-level `noPush` set, restoring it afterwards. The
1089
+ * exported entry points take `noPush` as a parameter; this scopes it instead of
1090
+ * threading it through processChain/pushBranchRef, which restack() also drives.
1091
+ */
1092
+ async function withNoPush<T>(value: boolean, fn: () => Promise<T>): Promise<T> {
1093
+ const saved = noPush;
1094
+ noPush = value;
1095
+ try {
1096
+ return await fn();
1097
+ } finally {
1098
+ noPush = saved;
529
1099
  }
530
1100
  }
531
1101
 
1102
+ /**
1103
+ * Walk `chain` from `startIndex`: rebase each branch onto its parent (unless
1104
+ * it's in `pushOnly`), then push it. The run state is saved before each rebase
1105
+ * and after each step, so an interruption anywhere leaves a resumable run.
1106
+ */
532
1107
  async function processChain(
533
1108
  meta: StackMetadata,
534
1109
  stackName: string,
535
1110
  chain: string[],
536
1111
  startIndex: number,
537
- rebasedBranches: string[],
538
- touched: string[],
539
- dryRun: boolean,
540
- verbose: boolean,
1112
+ pushOnly: Set<string>,
1113
+ ctx: ChainContext,
541
1114
  ): Promise<void> {
542
1115
  const stack = meta.stacks[stackName]!;
1116
+ const { dryRun, verbose } = ctx;
543
1117
 
544
1118
  for (let i = startIndex; i < chain.length; i++) {
545
1119
  const branch = chain[i]!;
546
1120
  const parent = stack.branches[branch]?.parent;
1121
+ if (run && !dryRun) {
1122
+ run.current_index = i;
1123
+ run.rebase = null;
1124
+ await saveRun();
1125
+ }
1126
+
1127
+ // Done already (rebased earlier, or a root restack doesn't rebase): only
1128
+ // its ref needs to reach origin.
1129
+ if (pushOnly.has(branch)) {
1130
+ if (!dryRun && (await git.localBranchExists(branch))) {
1131
+ ctx.touched.push(branch);
1132
+ await pushStep(meta, stackName, branch, ctx);
1133
+ }
1134
+ continue;
1135
+ }
547
1136
 
548
1137
  if (!parent) {
549
1138
  p.log.warn(`Skipping ${branch} (no parent defined)`);
@@ -564,8 +1153,9 @@ async function processChain(
564
1153
  if (await git.isAncestor(parent, branch)) {
565
1154
  p.log.success("Already up to date with parent");
566
1155
  if (!dryRun) {
567
- touched.push(branch);
568
- await pushBranchRef(meta, branch);
1156
+ await record({ branch, action: "up-to-date", onto: parent });
1157
+ ctx.touched.push(branch);
1158
+ await pushStep(meta, stackName, branch, ctx);
569
1159
  }
570
1160
  continue;
571
1161
  }
@@ -580,6 +1170,11 @@ async function processChain(
580
1170
  const confirmed = await confirmAction(`Rebase ${pc.yellow(branch)} onto ${pc.yellow(parent)}?`);
581
1171
  if (!confirmed) {
582
1172
  p.log.warn("Skipping rebase");
1173
+ // Not done: a resume offers it again, and nothing above it is pushed
1174
+ // until it sits on its parent.
1175
+ ctx.unpublished.add(branch);
1176
+ if (run && !run.declined.includes(branch)) run.declined.push(branch);
1177
+ await record({ branch, action: "rebase-declined", onto: parent });
583
1178
  continue;
584
1179
  }
585
1180
 
@@ -618,7 +1213,7 @@ async function processChain(
618
1213
  console.log(" Rebase it manually with an explicit boundary:");
619
1214
  console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
620
1215
  console.log();
621
- console.log(` Then re-run ${pc.green("gh-stack restack")} to continue the chain.`);
1216
+ console.log(` Then re-run ${pc.green("gh-stack restack --resume")} to continue the chain.`);
622
1217
  process.exit(1);
623
1218
  }
624
1219
 
@@ -626,16 +1221,11 @@ async function processChain(
626
1221
  p.log.info(`Checking out ${pc.yellow(branch)}...`);
627
1222
  await git.checkout(branch);
628
1223
 
629
- // Save state for resume
630
- await saveRestackState({
631
- current_index: i,
632
- stack_name: stackName,
633
- chain,
634
- ...(noPush && { no_push: true }),
635
- });
636
-
637
1224
  let success: boolean;
638
1225
  const branchTip = await git.revParse(branch);
1226
+ // Saved before the rebase starts, so a conflict (or a kill) leaves a
1227
+ // resumable run that knows whether this rebase finished or must run again.
1228
+ await startRebase(branch, parent, branchTip, boundary.sha);
639
1229
  if (boundary.sha === branchTip) {
640
1230
  // The child has no commits of its own (a placeholder branch created off
641
1231
  // the parent before any work landed). Nothing to replay — move the ref
@@ -661,38 +1251,24 @@ async function processChain(
661
1251
 
662
1252
  if (success) {
663
1253
  p.log.success("Rebase successful");
664
- rebasedBranches.push(branch);
665
- touched.push(branch);
666
- await clearRestackState();
1254
+ await finishRebase(branch, parent, ctx);
1255
+ ctx.touched.push(branch);
667
1256
 
668
1257
  // Push immediately (don't batch) so an interrupt can't leave later
669
1258
  // branches un-pushed.
670
- await pushBranchRef(meta, branch);
1259
+ await pushStep(meta, stackName, branch, ctx);
671
1260
  } else {
672
- // Conflict!
673
- console.log();
674
- console.log(pc.red("━".repeat(40)));
675
- console.log(pc.red(" Rebase Conflict"));
676
- console.log(pc.red("━".repeat(40)));
677
- console.log();
678
- console.log(pc.yellow("Conflicts detected during rebase."));
679
- console.log();
680
- console.log(" Please resolve the conflicts, then:");
681
- console.log(` ${pc.green("git rebase --continue")}`);
682
- console.log(` ${pc.green("gh-stack restack --resume")}`);
683
- console.log();
684
- console.log(" Or abort the rebase:");
685
- console.log(` ${pc.red("git rebase --abort")}`);
686
- process.exit(2);
1261
+ conflictStop();
687
1262
  }
688
1263
  }
1264
+
1265
+ if (run && !dryRun) {
1266
+ run.current_index = chain.length;
1267
+ run.rebase = null;
1268
+ await saveRun();
1269
+ }
689
1270
  }
690
1271
 
691
- /**
692
- * Reconcile a single branch's ref with origin. Pushes when the local tip
693
- * differs from origin OR the remote branch doesn't exist yet (so a branch
694
- * that was never pushed still lands). No-op when already in sync. (#12)
695
- */
696
1272
  /**
697
1273
  * Whether a branch has been published — it has a PR recorded in metadata.
698
1274
  * restack/sync keep published branches' remotes in sync after a rebase, but must
@@ -705,13 +1281,140 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
705
1281
  return meta.stacks[stackName]?.branches[branch]?.pr != null;
706
1282
  }
707
1283
 
708
- async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
709
- if (branch === "main" || branch === "master") return;
1284
+ /**
1285
+ * The parent `branch` must not be pushed onto, if any: one whose push failed,
1286
+ * was declined or held this run, or whose origin copy doesn't contain the
1287
+ * local tip `branch` was rebased onto (#29). A child pushed onto such a parent
1288
+ * gets its PR diffed against the parent's old history — the "1000+ files
1289
+ * changed" PR. A parent merely behind origin is fine: the PR's merge-base is
1290
+ * still the commit the child sits on. The trunk and a parent with no remote
1291
+ * copy are never held against.
1292
+ */
1293
+ async function unpublishedParent(
1294
+ branch: string,
1295
+ parent: string | undefined,
1296
+ ctx: ChainContext,
1297
+ ): Promise<string | null> {
1298
+ if (!parent || parent === "main" || parent === "master" || parent === branch) return null;
1299
+ if (ctx.unpublished.has(parent)) return parent;
1300
+ const remote = (await remoteTips(ctx)).get(parent);
1301
+ if (!remote || !(await git.localBranchExists(parent))) return null;
1302
+ const local = await git.revParse(parent);
1303
+ if (local === remote || (await git.isAncestor(local, remote))) return null;
1304
+ return parent;
1305
+ }
1306
+
1307
+ /** Whether pushing `branch` would change origin (it differs, or a published branch is missing). */
1308
+ async function needsPush(meta: StackMetadata, branch: string, ctx: ChainContext): Promise<boolean> {
1309
+ const remote = (await remoteTips(ctx)).get(branch);
1310
+ if (!remote) return branchHasPr(meta, branch);
1311
+ return remote !== (await git.revParse(branch));
1312
+ }
1313
+
1314
+ /**
1315
+ * Push one branch as a step of the run: held back when its parent isn't
1316
+ * published (#29), otherwise pushed, with the outcome recorded in the log.
1317
+ */
1318
+ async function pushStep(
1319
+ meta: StackMetadata,
1320
+ stackName: string,
1321
+ branch: string,
1322
+ ctx: ChainContext,
1323
+ ): Promise<void> {
1324
+ if (noPush || branch === "main" || branch === "master") return;
1325
+ const parent = meta.stacks[stackName]?.branches[branch]?.parent;
1326
+
1327
+ const heldBy = await unpublishedParent(branch, parent, ctx);
1328
+ if (heldBy) {
1329
+ if (await needsPush(meta, branch, ctx)) {
1330
+ p.log.warn(
1331
+ `Not pushing ${pc.yellow(branch)}: its parent ${pc.yellow(heldBy)} isn't on origin at the commit ${branch} sits on, so its PR would be diffed against ${heldBy}'s old history. Push ${heldBy} first — ${pc.green("gh-stack restack --resume")} retries both, parent first.`,
1332
+ );
1333
+ ctx.unpublished.add(branch);
1334
+ await record({ branch, action: "push-held", detail: `${heldBy} not on origin` });
1335
+ }
1336
+ return;
1337
+ }
1338
+
1339
+ const outcome = await pushBranchRef(meta, branch, ctx);
1340
+ switch (outcome) {
1341
+ case "pushed":
1342
+ (await remoteTips(ctx)).set(branch, await git.revParse(branch));
1343
+ await record({ branch, action: "pushed" });
1344
+ await retargetIfStale(meta, stackName, branch);
1345
+ break;
1346
+ case "in-sync":
1347
+ await retargetIfStale(meta, stackName, branch);
1348
+ break;
1349
+ case "failed":
1350
+ ctx.unpublished.add(branch);
1351
+ await record({ branch, action: "push-failed" });
1352
+ break;
1353
+ case "declined":
1354
+ ctx.unpublished.add(branch);
1355
+ await record({ branch, action: "push-declined" });
1356
+ break;
1357
+ case "local-only":
1358
+ await record({ branch, action: "local-only" });
1359
+ break;
1360
+ default:
1361
+ break; // already in sync, or pushing is off
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * A PR still targeting a branch that's no longer its parent (an advance past a
1367
+ * merged bottom whose push, or retarget, failed): now that the branch is on
1368
+ * origin, point the PR at its parent. Only acts on a recorded `prBase` that
1369
+ * differs from the parent, so it never guesses a PR's base.
1370
+ */
1371
+ async function retargetIfStale(
1372
+ meta: StackMetadata,
1373
+ stackName: string,
1374
+ branch: string,
1375
+ ): Promise<void> {
1376
+ const entry = meta.stacks[stackName]?.branches[branch];
1377
+ if (entry?.pr == null || !entry.prBase || entry.prBase === entry.parent) return;
1378
+ if (!(await setPrBase(entry.pr, entry.parent))) {
1379
+ p.log.warn(
1380
+ `PR #${entry.pr} still targets ${entry.prBase}; couldn't retarget it to ${entry.parent}. ${pc.green("gh-stack submit")} retries.`,
1381
+ );
1382
+ return;
1383
+ }
1384
+ p.log.success(`PR #${entry.pr} base → ${pc.green(entry.parent)}`);
1385
+ entry.prBase = entry.parent;
1386
+ const fresh = await readMetadata();
1387
+ const saved = fresh?.stacks[stackName]?.branches[branch];
1388
+ if (fresh && saved) {
1389
+ saved.prBase = entry.parent;
1390
+ await writeMetadata(fresh);
1391
+ }
1392
+ }
1393
+
1394
+ type PushOutcome = "no-push" | "in-sync" | "local-only" | "pushed" | "failed" | "declined";
1395
+
1396
+ /**
1397
+ * Reconcile a single branch's ref with origin. Pushes when the local tip
1398
+ * differs from origin OR the remote branch doesn't exist yet (so a branch
1399
+ * that was never pushed still lands). No-op when already in sync. (#12)
1400
+ *
1401
+ * "In sync" is judged from the run's view of origin (`ls-remote`, kept current
1402
+ * as we push), the same view the hold check and the end-of-run check use. A
1403
+ * stale remote-tracking ref (#23) must not make one of them say "in sync"
1404
+ * while another says "not pushed": the run would retry forever.
1405
+ */
1406
+ async function pushBranchRef(
1407
+ meta: StackMetadata,
1408
+ branch: string,
1409
+ ctx: ChainContext,
1410
+ ): Promise<PushOutcome> {
1411
+ if (branch === "main" || branch === "master") return "in-sync";
710
1412
  // --no-push: leave origin alone; the end-of-run summary lists what's ahead.
711
- if (noPush) return;
1413
+ if (noPush) return "no-push";
712
1414
 
713
1415
  const localSha = await git.revParse(branch);
714
- const hasRemote = await git.remoteBranchExists(branch);
1416
+ const remoteSha = (await remoteTips(ctx)).get(branch) ?? null;
1417
+ const hasRemote = remoteSha !== null;
715
1418
 
716
1419
  // Don't publish an unsubmitted branch. A local-only branch (no remote AND no
717
1420
  // PR) is intentionally not on origin — restack/sync rebase it locally but
@@ -722,12 +1425,10 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
722
1425
  p.log.info(
723
1426
  `Skipping push of ${pc.yellow(branch)} — local-only (not submitted). Run ${pc.green("gh-stack submit")} to open its PR.`,
724
1427
  );
725
- return;
1428
+ return "local-only";
726
1429
  }
727
1430
 
728
- const remoteSha = hasRemote ? await git.revParse(`origin/${branch}`).catch(() => null) : null;
729
-
730
- if (hasRemote && remoteSha === localSha) return; // already in sync — nothing to do
1431
+ if (remoteSha === localSha) return "in-sync";
731
1432
 
732
1433
  console.log();
733
1434
  console.log(pc.cyan("━".repeat(40)));
@@ -748,15 +1449,20 @@ async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void>
748
1449
  const confirmed = await confirmAction(
749
1450
  hasRemote ? "Push with --force-with-lease?" : `Push ${branch} to origin?`,
750
1451
  );
751
- if (confirmed) {
752
- p.log.info(`Pushing ${branch}...`);
753
- const ok = await git.pushStackedBranch(branch);
754
- if (ok) {
755
- p.log.success("Pushed successfully");
756
- } else {
757
- p.log.error("Push failed — you may need to push manually later");
758
- }
759
- } else {
1452
+ if (!confirmed) {
760
1453
  p.log.warn("Skipping push (you'll need to push manually)");
1454
+ return "declined";
1455
+ }
1456
+ p.log.info(`Pushing ${branch}...`);
1457
+ // A tip the remote guard already checked (only earlier versions of local
1458
+ // commits, or --overwrite-remote) gets an explicit lease on exactly that sha.
1459
+ const result = await git.forcePushWithLease(branch, { expect: verifiedTips.get(branch) });
1460
+ if (result.ok) {
1461
+ p.log.success("Pushed successfully");
1462
+ return "pushed";
761
1463
  }
1464
+ p.log.error(`Push of ${branch} failed — branches above it won't be pushed onto it`);
1465
+ for (const line of git.pushFailureHint(branch, result)) console.log(` ${line}`);
1466
+ console.log(` Then: ${pc.green("gh-stack restack --resume")}`);
1467
+ return "failed";
762
1468
  }