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.
@@ -7,28 +7,34 @@ import {
7
7
  findStackForBranch,
8
8
  getOrderedBranches,
9
9
  removeBranchFromStack,
10
- saveRestackState,
11
10
  stackBase,
12
11
  writeMetadata,
13
12
  } from "../lib/metadata.ts";
14
13
  import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
14
+ import { resolveFlag } from "../lib/config.ts";
15
15
  import { takeSnapshot } from "../lib/snapshot.ts";
16
- import { confirmAction } from "../lib/ui.ts";
16
+ import { guardRemoteContent } from "../lib/remote-guard.ts";
17
17
  import { getPrInfo } from "../lib/github.ts";
18
- import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
18
+ import { planMerges, advanceNewBottom, type BranchReview, type MergePlan } from "../lib/advance.ts";
19
19
  import type { StackMetadata } from "../types.ts";
20
- import { wantsNoPush } from "./restack.ts";
20
+ import restack, {
21
+ notePendingRun,
22
+ refuseRebaseInProgress,
23
+ resolvePush,
24
+ routePendingRun,
25
+ syncChain,
26
+ } from "./restack.ts";
21
27
 
22
28
  export default async function sync(args: string[]): Promise<void> {
23
29
  const dryRun = args.includes("--dry-run");
24
- const noPush = wantsNoPush(args);
30
+ const fresh = args.includes("--fresh");
25
31
 
26
32
  if (args.includes("--help")) {
27
33
  console.log(`
28
34
  gh-stack sync — Sync base with main + restack all
29
35
 
30
36
  USAGE
31
- gh-stack sync [--dry-run] [--no-push]
37
+ gh-stack sync [--dry-run] [--no-push | --push] [--fresh]
32
38
 
33
39
  Fetches latest main, rebases the base branch onto main,
34
40
  then restacks all children. Equivalent to running restack
@@ -37,68 +43,96 @@ with the base branch included.
37
43
  OPTIONS
38
44
  --dry-run Show what would happen without executing
39
45
  --no-push Rebase locally only — never push or retarget PRs
40
- (also GH_STACK_NO_PUSH=1). Wins over --yes for pushes
46
+ (also GH_STACK_NO_PUSH=1, or \`[restack] push = false\` in
47
+ config; see gh-stack config). Wins over --yes for pushes
48
+ --push Push even when a config default or env var says not to
49
+ --resume Same as \`gh-stack restack --resume\`
50
+ --fresh Discard an interrupted restack/sync instead of resuming it
51
+ --overwrite-remote
52
+ Rewrite branches even though origin has commits they lack.
53
+ Those commits are dropped from origin
54
+
55
+ If an interrupted restack or sync is pending, sync asks whether to resume it
56
+ (with --yes it resumes) rather than starting over on top of it.
57
+
58
+ Before rewriting anything, sync stops if a branch's copy on origin has
59
+ commits the local branch never had (e.g. the squash commit \`merge --collapse\`
60
+ adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`.
41
61
  `);
42
62
  return;
43
63
  }
44
64
 
65
+ // Only explicit push flags (and --overwrite-remote) carry over to a resume:
66
+ // it otherwise keeps the push decision of the run it resumes.
67
+ const pushFlags = args.filter(
68
+ (a) => a === "--push" || a === "--no-push" || a === "--overwrite-remote",
69
+ );
70
+ const resumeRun = () => restack(["--resume", ...pushFlags]);
71
+ if (args.includes("--resume")) {
72
+ await resumeRun();
73
+ return;
74
+ }
75
+
45
76
  // Reject if a git rebase is already in progress. Letting sync run on top
46
77
  // of a half-finished rebase produces deeply confusing state — make the
47
78
  // user finish or abort it first.
48
79
  if (await git.isRebaseInProgress()) {
49
- p.cancel(
50
- `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 sync")}.`,
51
- );
52
- process.exit(1);
80
+ await refuseRebaseInProgress("sync");
53
81
  }
54
82
 
55
- await ensureCleanWorkingTree();
56
-
57
- const meta = await ensureMetadata();
58
- const currentBranch = await git.currentBranch();
59
- const stackName = findStackForBranch(meta, currentBranch);
60
-
61
- if (!stackName) {
62
- p.cancel(
63
- `Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}\n\n Or open its PR with:\n ${pc.green("gh-stack submit")}`,
64
- );
65
- process.exit(1);
83
+ // An interrupted restack/sync is pending: resume it rather than starting a
84
+ // new sync on top of it (#29).
85
+ if (dryRun) {
86
+ await notePendingRun();
87
+ } else if (
88
+ (await routePendingRun("sync", {
89
+ fresh,
90
+ preflight: async () => void (await syncPreconditions(args)),
91
+ })) === "resume"
92
+ ) {
93
+ await resumeRun();
94
+ return;
66
95
  }
67
96
 
97
+ const push = await resolvePush(args);
98
+ const noPush = !push.value;
99
+ const { meta, stackName } = await syncPreconditions(args);
68
100
  let stack = meta.stacks[stackName]!;
69
- await ensureValidStack(meta, stackName);
70
101
  let ordered = getOrderedBranches(stack);
71
102
 
72
- if (ordered.length === 0) {
73
- p.cancel("No branches in this stack");
74
- process.exit(1);
75
- }
76
-
77
103
  p.intro(pc.cyan("Git Stack Sync"));
78
104
 
79
105
  // Find the root branch (whose parent is the stack's base)
80
106
  const base = stackBase(stack);
81
- const baseIsTrunk = base === "main" || base === "master";
82
-
83
- // sync only makes sense for trunk-rooted stacks — it fetches main and
84
- // rebases onto it. A split stack is rooted on a branch in another stack, so
85
- // syncing it means restacking onto that base (handled by `restack`), or
86
- // re-rooting onto main once the parent stack has merged.
87
- if (!baseIsTrunk) {
88
- p.cancel(
89
- `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
90
- ` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
91
- ` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
92
- );
93
- process.exit(1);
94
- }
95
107
 
96
108
  // Catch up to reality first: if the bottom PR(s) already landed on the trunk
97
109
  // outside gh-stack (e.g. the GitHub web UI), the stack metadata still lists
98
110
  // them. A plain rebase onto main would then replay their now-squashed commits
99
111
  // and conflict — so advance past them (re-rooting the survivor onto main with
100
112
  // `--onto`) before the normal sync runs. (#20)
101
- const advance = await advanceMergedBottoms(meta, stackName, base, dryRun, noPush);
113
+ const merged = await findMergedBottoms(meta, stackName);
114
+
115
+ // Nothing is rewritten yet: stop here if origin holds commits the local
116
+ // branches lack, e.g. a collapse's squash commit on the base branch (#28).
117
+ // Bottoms whose PRs already merged are skipped: the advance drops them
118
+ // rather than rewriting them, and after `merge --approved` they're exactly
119
+ // where the collapse's squash commits live.
120
+ const mergedBranches = new Set(merged.steps.map((step) => step.branch));
121
+ const verified = await guardRemoteContent(
122
+ meta,
123
+ ordered.filter((b) => !mergedBranches.has(b)),
124
+ { command: "sync", overwrite: args.includes("--overwrite-remote"), dryRun },
125
+ );
126
+
127
+ const advance = await advanceMergedBottoms(
128
+ meta,
129
+ stackName,
130
+ base,
131
+ dryRun,
132
+ noPush,
133
+ merged,
134
+ verified,
135
+ );
102
136
  if (advance.drained) {
103
137
  p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
104
138
  return;
@@ -156,82 +190,55 @@ OPTIONS
156
190
  // need to find when restacking children. (See resolveRestackBoundary.)
157
191
  await takeSnapshot(meta, stackName, "sync");
158
192
 
159
- // Step 1: Rebase base branch onto main
160
- console.log();
161
- console.log(pc.cyan("━".repeat(40)));
162
- console.log(`${pc.blue("Step 1:")} Rebase ${pc.yellow(baseBranch)} onto main`);
163
- console.log(pc.cyan("━".repeat(40)));
164
- console.log();
165
-
166
- // Check if already up to date
167
- if (await git.isAncestor("origin/main", baseBranch)) {
168
- p.log.success(`${baseBranch} is already up to date with main`);
169
- } else if (dryRun) {
170
- p.log.warn(`[DRY RUN] Would rebase ${baseBranch} onto origin/main`);
171
- } else {
172
- const confirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto origin/main?`);
173
- if (!confirmed) {
174
- p.cancel("Cancelled");
175
- process.exit(0);
176
- }
193
+ // Rebase the root onto main, push it, then restack every branch above it —
194
+ // one run, saved after every step, so `restack --resume` can finish it.
195
+ await syncChain(meta, stackName, ordered, { trunkRef: "origin/main", push, verified });
196
+ }
177
197
 
178
- p.log.info(`Checking out ${pc.yellow(baseBranch)}...`);
179
- await git.checkout(baseBranch);
180
-
181
- // Save restack state BEFORE rebasing so --resume can find it.
182
- // The chain is the full ordered list — base branch at index 0,
183
- // so resume knows where we were and can continue with children.
184
- await saveRestackState({
185
- current_index: 0,
186
- stack_name: stackName,
187
- chain: ordered,
188
- ...(noPush && { no_push: true }),
189
- });
190
-
191
- p.log.info("Rebasing onto origin/main...");
192
- const success = await git.rebase("origin/main");
193
-
194
- if (!success) {
195
- console.log();
196
- p.log.error("Rebase conflict — resolve and run:");
197
- console.log(` ${pc.green("git rebase --continue")}`);
198
- console.log(` ${pc.green("gh-stack restack --resume")}`);
199
- process.exit(2);
200
- }
198
+ /**
199
+ * What a new sync needs before it changes anything: a clean tree, a valid push
200
+ * setting, and the current branch in a trunk-rooted stack. Also the preflight
201
+ * for discarding an interrupted run, so a sync that would refuse leaves that
202
+ * run intact.
203
+ */
204
+ async function syncPreconditions(
205
+ args: string[],
206
+ ): Promise<{ meta: StackMetadata; stackName: string }> {
207
+ await resolveFlag("restack.push", args); // a bad setting stops here
208
+ await ensureCleanWorkingTree();
201
209
 
202
- p.log.success("Base branch rebased onto main");
210
+ const meta = await ensureMetadata();
211
+ const currentBranch = await git.currentBranch();
212
+ const stackName = findStackForBranch(meta, currentBranch);
203
213
 
204
- // Prompt to push base
205
- if (noPush) {
206
- p.log.info(`Not pushing ${pc.yellow(baseBranch)} (--no-push) — it's now ahead of origin`);
207
- } else {
208
- await promptForcePush(baseBranch);
209
- }
214
+ if (!stackName) {
215
+ p.cancel(
216
+ `Branch ${pc.blue(currentBranch)} is not in any stack.\n\n Track it first (no push/PR):\n ${pc.green("gh-stack add")}\n\n Or open its PR with:\n ${pc.green("gh-stack submit")}`,
217
+ );
218
+ process.exit(1);
210
219
  }
211
220
 
212
- // Step 2: Restack children
213
- const children = ordered.slice(1);
214
-
215
- if (children.length === 0) {
216
- p.outro(pc.green("Sync complete! (no children to restack)"));
217
- return;
221
+ const stack = meta.stacks[stackName]!;
222
+ await ensureValidStack(meta, stackName);
223
+ if (getOrderedBranches(stack).length === 0) {
224
+ p.cancel("No branches in this stack");
225
+ process.exit(1);
218
226
  }
219
227
 
220
- console.log();
221
- console.log(pc.cyan("━".repeat(40)));
222
- console.log(`${pc.blue("Step 2:")} Restack ${children.length} child branch(es)`);
223
- console.log(pc.cyan("━".repeat(40)));
224
- console.log();
225
-
226
- // Delegate to restack for the remaining branches
227
- // We import and run restack in --rebase mode effectively
228
- const { default: restack } = await import("./restack.ts");
229
-
230
- // Switch to first child and run restack
231
- if (children.length > 0) {
232
- await git.checkout(children[0]!);
233
- await restack([...(dryRun ? ["--dry-run"] : []), ...(noPush ? ["--no-push"] : [])]);
228
+ // sync only makes sense for trunk-rooted stacks — it fetches main and
229
+ // rebases onto it. A split stack is rooted on a branch in another stack, so
230
+ // syncing it means restacking onto that base (handled by `restack`), or
231
+ // re-rooting onto main once the parent stack has merged.
232
+ const base = stackBase(stack);
233
+ if (base !== "main" && base !== "master") {
234
+ p.cancel(
235
+ `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
236
+ ` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
237
+ ` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
238
+ );
239
+ process.exit(1);
234
240
  }
241
+ return { meta, stackName };
235
242
  }
236
243
 
237
244
  /** Result of the merged-bottom catch-up pass. */
@@ -253,20 +260,15 @@ interface AdvanceOutcome {
253
260
  * Returns what happened so the caller can re-derive the root or short-circuit.
254
261
  * In dry-run it reports and makes no changes.
255
262
  */
256
- async function advanceMergedBottoms(
257
- meta: StackMetadata,
258
- stackName: string,
259
- trunk: string,
260
- dryRun: boolean,
261
- noPush: boolean,
262
- ): Promise<AdvanceOutcome> {
263
+ /**
264
+ * The contiguous bottom branches whose PRs already merged (outside gh-stack,
265
+ * or the base of a `merge --approved`), walking bottom-up and stopping at the
266
+ * first that isn't.
267
+ */
268
+ async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<MergePlan> {
263
269
  const stack = meta.stacks[stackName]!;
264
- const ordered = getOrderedBranches(stack);
265
-
266
- // Walk bottom-up gathering PR state; stop at the first branch that isn't a
267
- // merged PR (only contiguous merged bottoms can be advanced past).
268
270
  const reviews: BranchReview[] = [];
269
- for (const branch of ordered) {
271
+ for (const branch of getOrderedBranches(stack)) {
270
272
  const pr = stack.branches[branch]?.pr ?? null;
271
273
  let prState: string | null = null;
272
274
  if (pr != null) {
@@ -276,8 +278,21 @@ async function advanceMergedBottoms(
276
278
  reviews.push({ branch, pr, prState, reviewDecision: null });
277
279
  if (prState !== "MERGED") break;
278
280
  }
281
+ return planMerges(reviews, { approveAndMerge: false });
282
+ }
283
+
284
+ async function advanceMergedBottoms(
285
+ meta: StackMetadata,
286
+ stackName: string,
287
+ trunk: string,
288
+ dryRun: boolean,
289
+ noPush: boolean,
290
+ plan: MergePlan,
291
+ verified: Map<string, string>,
292
+ ): Promise<AdvanceOutcome> {
293
+ const stack = meta.stacks[stackName]!;
294
+ const ordered = getOrderedBranches(stack);
279
295
 
280
- const plan = planMerges(reviews, { approveAndMerge: false });
281
296
  if (plan.steps.length === 0) {
282
297
  return { detected: false, advanced: false, drained: false };
283
298
  }
@@ -316,14 +331,17 @@ async function advanceMergedBottoms(
316
331
  targetRef,
317
332
  recordedBase: trunk,
318
333
  push: !noPush,
334
+ expect: verified.get(child),
319
335
  });
320
336
  if (!res.ok) {
321
337
  console.log();
322
338
  if (res.reason === "conflict") {
323
339
  p.log.error(`Rebase conflict re-rooting ${pc.yellow(child)} onto ${trunk}.`);
324
- console.log(` Resolve it, then finish the restack:`);
340
+ console.log(` Resolve it, then finish the sync:`);
325
341
  console.log(` ${pc.green("git rebase --continue")}`);
326
- console.log(` ${pc.green("gh-stack restack")}`);
342
+ console.log(
343
+ ` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${child}; not restack, which would put it back on ${mergedBranch})`)}`,
344
+ );
327
345
  } else {
328
346
  p.log.error(
329
347
  `Could not determine a safe rebase base for ${pc.yellow(child)} (${res.reason}).`,
@@ -358,33 +376,3 @@ async function advanceMergedBottoms(
358
376
 
359
377
  return { detected: true, advanced: true, drained: false };
360
378
  }
361
-
362
- async function promptForcePush(branch: string): Promise<void> {
363
- if (branch === "main" || branch === "master") return;
364
- if (!(await git.remoteBranchExists(branch))) return;
365
-
366
- const localSha = await git.revParse(branch);
367
- let remoteSha: string;
368
- try {
369
- remoteSha = await git.revParse(`origin/${branch}`);
370
- } catch {
371
- return;
372
- }
373
-
374
- if (localSha === remoteSha) return;
375
-
376
- console.log();
377
- p.log.info(`${pc.yellow("⚠")} ${branch} is out of sync with remote`);
378
-
379
- const confirmed = await confirmAction("Push with --force-with-lease?");
380
- if (confirmed) {
381
- const ok = await git.forcePushWithLease(branch);
382
- if (ok) {
383
- p.log.success("Pushed successfully");
384
- } else {
385
- p.log.error("Push failed");
386
- }
387
- } else {
388
- p.log.warn("Skipping push");
389
- }
390
- }
@@ -4,6 +4,7 @@ import * as git from "../lib/git.ts";
4
4
  import pc from "picocolors";
5
5
  import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
6
6
  import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
7
+ import { clearRestackState, loadRestackState } from "../lib/metadata.ts";
7
8
  import { confirmAction } from "../lib/ui.ts";
8
9
  import { $ } from "bun";
9
10
 
@@ -72,5 +73,15 @@ a destructive operation (restack, merge, sync, remove).
72
73
  // Pop the snapshot
73
74
  await popSnapshot(meta);
74
75
 
76
+ // An unfinished run on this stack was rewriting the branches just restored;
77
+ // resuming it would push the restored tips as if they were its results.
78
+ const run = await loadRestackState().catch(() => null); // an unusable state is left for --fresh
79
+ if (run && !run.finished_at && run.stack_name === snapshot.stack) {
80
+ await clearRestackState();
81
+ p.log.info(
82
+ `Discarded the interrupted ${run.command}'s saved state (its branches were restored).`,
83
+ );
84
+ }
85
+
75
86
  p.outro(pc.green("Undo complete!"));
76
87
  }
@@ -162,6 +162,7 @@ export function numberedTitle(title: string, position: number, total: number): s
162
162
 
163
163
  export interface TitleResult {
164
164
  updated: number; // PR titles we edited on GitHub
165
+ updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
165
166
  dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
166
167
  }
167
168
 
@@ -211,21 +212,24 @@ export async function reconcilePrTitles(
211
212
 
212
213
  const items = ordered.map((name) => {
213
214
  const bm = stack.branches[name]!;
214
- return { pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
215
+ return { name, pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
215
216
  });
216
217
 
217
- const oks = await mapLimit(planTitleEdits(items), 8, async ({ item, desired }) => {
218
+ const edits = planTitleEdits(items);
219
+ const oks = await mapLimit(edits, 8, async ({ item, desired }) => {
218
220
  const ok = await updatePrTitle(item.pr!, desired);
219
221
  if (ok) item.bm.prTitle = desired;
220
222
  return ok;
221
223
  });
222
224
 
223
- const updated = oks.filter(Boolean).length;
224
- return { updated, dirty: dirtyBackfill || updated > 0 };
225
+ const updatedBranches = edits.filter((_, i) => oks[i]).map(({ item }) => item.name);
226
+ const updated = updatedBranches.length;
227
+ return { updated, updatedBranches, dirty: dirtyBackfill || updated > 0 };
225
228
  }
226
229
 
227
230
  export interface VizResult {
228
231
  updated: number; // PRs whose description we PATCHed
232
+ updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
229
233
  unchanged: number; // PRs skipped because their rendered block was identical
230
234
  skipped: number; // branches with no PR
231
235
  skippedBranches: string[]; // …and their names, so callers can say which
@@ -306,10 +310,11 @@ export async function refreshStackViz(
306
310
  return ok;
307
311
  });
308
312
 
309
- const updated = oks.filter(Boolean).length;
313
+ const updatedBranches = toUpdate.filter((_, i) => oks[i]).map((plan) => plan.info.branch);
314
+ const updated = updatedBranches.length;
310
315
  if (updated > 0) dirty = true;
311
316
 
312
- return { updated, unchanged, skipped, skippedBranches, dirty };
317
+ return { updated, updatedBranches, unchanged, skipped, skippedBranches, dirty };
313
318
  }
314
319
 
315
320
  export default async function updatePrs(args: string[]): Promise<void> {