gh-stack 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,91 +7,132 @@ 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 restack, {
21
+ notePendingRun,
22
+ refuseRebaseInProgress,
23
+ resolvePush,
24
+ routePendingRun,
25
+ syncChain,
26
+ } from "./restack.ts";
20
27
 
21
28
  export default async function sync(args: string[]): Promise<void> {
22
29
  const dryRun = args.includes("--dry-run");
30
+ const fresh = args.includes("--fresh");
23
31
 
24
32
  if (args.includes("--help")) {
25
33
  console.log(`
26
34
  gh-stack sync — Sync base with main + restack all
27
35
 
28
36
  USAGE
29
- gh-stack sync [--dry-run]
37
+ gh-stack sync [--dry-run] [--no-push | --push] [--fresh]
30
38
 
31
39
  Fetches latest main, rebases the base branch onto main,
32
40
  then restacks all children. Equivalent to running restack
33
41
  with the base branch included.
42
+
43
+ OPTIONS
44
+ --dry-run Show what would happen without executing
45
+ --no-push Rebase locally only — never push or retarget PRs
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\`.
34
61
  `);
35
62
  return;
36
63
  }
37
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
+
38
76
  // Reject if a git rebase is already in progress. Letting sync run on top
39
77
  // of a half-finished rebase produces deeply confusing state — make the
40
78
  // user finish or abort it first.
41
79
  if (await git.isRebaseInProgress()) {
42
- p.cancel(
43
- `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")}.`,
44
- );
45
- process.exit(1);
80
+ await refuseRebaseInProgress("sync");
46
81
  }
47
82
 
48
- await ensureCleanWorkingTree();
49
-
50
- const meta = await ensureMetadata();
51
- const currentBranch = await git.currentBranch();
52
- const stackName = findStackForBranch(meta, currentBranch);
53
-
54
- if (!stackName) {
55
- p.cancel(
56
- `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")}`,
57
- );
58
- 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;
59
95
  }
60
96
 
97
+ const push = await resolvePush(args);
98
+ const noPush = !push.value;
99
+ const { meta, stackName } = await syncPreconditions(args);
61
100
  let stack = meta.stacks[stackName]!;
62
- await ensureValidStack(meta, stackName);
63
101
  let ordered = getOrderedBranches(stack);
64
102
 
65
- if (ordered.length === 0) {
66
- p.cancel("No branches in this stack");
67
- process.exit(1);
68
- }
69
-
70
103
  p.intro(pc.cyan("Git Stack Sync"));
71
104
 
72
105
  // Find the root branch (whose parent is the stack's base)
73
106
  const base = stackBase(stack);
74
- const baseIsTrunk = base === "main" || base === "master";
75
-
76
- // sync only makes sense for trunk-rooted stacks — it fetches main and
77
- // rebases onto it. A split stack is rooted on a branch in another stack, so
78
- // syncing it means restacking onto that base (handled by `restack`), or
79
- // re-rooting onto main once the parent stack has merged.
80
- if (!baseIsTrunk) {
81
- p.cancel(
82
- `${pc.yellow(stackName)} is based on ${pc.yellow(base)}, not main — sync doesn't apply.\n\n` +
83
- ` • Parent moved? Restack onto it: ${pc.green("gh-stack restack")}\n` +
84
- ` • Parent stack merged to main? ${pc.green("gh-stack restack --onto main")}`,
85
- );
86
- process.exit(1);
87
- }
88
107
 
89
108
  // Catch up to reality first: if the bottom PR(s) already landed on the trunk
90
109
  // outside gh-stack (e.g. the GitHub web UI), the stack metadata still lists
91
110
  // them. A plain rebase onto main would then replay their now-squashed commits
92
111
  // and conflict — so advance past them (re-rooting the survivor onto main with
93
112
  // `--onto`) before the normal sync runs. (#20)
94
- const advance = await advanceMergedBottoms(meta, stackName, base, dryRun);
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
+ );
95
136
  if (advance.drained) {
96
137
  p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
97
138
  return;
@@ -149,77 +190,55 @@ with the base branch included.
149
190
  // need to find when restacking children. (See resolveRestackBoundary.)
150
191
  await takeSnapshot(meta, stackName, "sync");
151
192
 
152
- // Step 1: Rebase base branch onto main
153
- console.log();
154
- console.log(pc.cyan("━".repeat(40)));
155
- console.log(`${pc.blue("Step 1:")} Rebase ${pc.yellow(baseBranch)} onto main`);
156
- console.log(pc.cyan("━".repeat(40)));
157
- console.log();
158
-
159
- // Check if already up to date
160
- if (await git.isAncestor("origin/main", baseBranch)) {
161
- p.log.success(`${baseBranch} is already up to date with main`);
162
- } else if (dryRun) {
163
- p.log.warn(`[DRY RUN] Would rebase ${baseBranch} onto origin/main`);
164
- } else {
165
- const confirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto origin/main?`);
166
- if (!confirmed) {
167
- p.cancel("Cancelled");
168
- process.exit(0);
169
- }
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
+ }
170
197
 
171
- p.log.info(`Checking out ${pc.yellow(baseBranch)}...`);
172
- await git.checkout(baseBranch);
173
-
174
- // Save restack state BEFORE rebasing so --resume can find it.
175
- // The chain is the full ordered list — base branch at index 0,
176
- // so resume knows where we were and can continue with children.
177
- await saveRestackState({
178
- current_index: 0,
179
- stack_name: stackName,
180
- chain: ordered,
181
- });
182
-
183
- p.log.info("Rebasing onto origin/main...");
184
- const success = await git.rebase("origin/main");
185
-
186
- if (!success) {
187
- console.log();
188
- p.log.error("Rebase conflict — resolve and run:");
189
- console.log(` ${pc.green("git rebase --continue")}`);
190
- console.log(` ${pc.green("gh-stack restack --resume")}`);
191
- process.exit(2);
192
- }
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();
193
209
 
194
- 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);
195
213
 
196
- // Prompt to push base
197
- await promptForcePush(baseBranch);
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);
198
219
  }
199
220
 
200
- // Step 2: Restack children
201
- const children = ordered.slice(1);
202
-
203
- if (children.length === 0) {
204
- p.outro(pc.green("Sync complete! (no children to restack)"));
205
- 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);
206
226
  }
207
227
 
208
- console.log();
209
- console.log(pc.cyan("━".repeat(40)));
210
- console.log(`${pc.blue("Step 2:")} Restack ${children.length} child branch(es)`);
211
- console.log(pc.cyan("━".repeat(40)));
212
- console.log();
213
-
214
- // Delegate to restack for the remaining branches
215
- // We import and run restack in --rebase mode effectively
216
- const { default: restack } = await import("./restack.ts");
217
-
218
- // Switch to first child and run restack
219
- if (children.length > 0) {
220
- await git.checkout(children[0]!);
221
- await restack(dryRun ? ["--dry-run"] : []);
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);
222
240
  }
241
+ return { meta, stackName };
223
242
  }
224
243
 
225
244
  /** Result of the merged-bottom catch-up pass. */
@@ -241,19 +260,15 @@ interface AdvanceOutcome {
241
260
  * Returns what happened so the caller can re-derive the root or short-circuit.
242
261
  * In dry-run it reports and makes no changes.
243
262
  */
244
- async function advanceMergedBottoms(
245
- meta: StackMetadata,
246
- stackName: string,
247
- trunk: string,
248
- dryRun: boolean,
249
- ): 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> {
250
269
  const stack = meta.stacks[stackName]!;
251
- const ordered = getOrderedBranches(stack);
252
-
253
- // Walk bottom-up gathering PR state; stop at the first branch that isn't a
254
- // merged PR (only contiguous merged bottoms can be advanced past).
255
270
  const reviews: BranchReview[] = [];
256
- for (const branch of ordered) {
271
+ for (const branch of getOrderedBranches(stack)) {
257
272
  const pr = stack.branches[branch]?.pr ?? null;
258
273
  let prState: string | null = null;
259
274
  if (pr != null) {
@@ -263,8 +278,21 @@ async function advanceMergedBottoms(
263
278
  reviews.push({ branch, pr, prState, reviewDecision: null });
264
279
  if (prState !== "MERGED") break;
265
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);
266
295
 
267
- const plan = planMerges(reviews, { approveAndMerge: false });
268
296
  if (plan.steps.length === 0) {
269
297
  return { detected: false, advanced: false, drained: false };
270
298
  }
@@ -302,6 +330,8 @@ async function advanceMergedBottoms(
302
330
  childPr,
303
331
  targetRef,
304
332
  recordedBase: trunk,
333
+ push: !noPush,
334
+ expect: verified.get(child),
305
335
  });
306
336
  if (!res.ok) {
307
337
  console.log();
@@ -344,33 +374,3 @@ async function advanceMergedBottoms(
344
374
 
345
375
  return { detected: true, advanced: true, drained: false };
346
376
  }
347
-
348
- async function promptForcePush(branch: string): Promise<void> {
349
- if (branch === "main" || branch === "master") return;
350
- if (!(await git.remoteBranchExists(branch))) return;
351
-
352
- const localSha = await git.revParse(branch);
353
- let remoteSha: string;
354
- try {
355
- remoteSha = await git.revParse(`origin/${branch}`);
356
- } catch {
357
- return;
358
- }
359
-
360
- if (localSha === remoteSha) return;
361
-
362
- console.log();
363
- p.log.info(`${pc.yellow("⚠")} ${branch} is out of sync with remote`);
364
-
365
- const confirmed = await confirmAction("Push with --force-with-lease?");
366
- if (confirmed) {
367
- const ok = await git.forcePushWithLease(branch);
368
- if (ok) {
369
- p.log.success("Pushed successfully");
370
- } else {
371
- p.log.error("Push failed");
372
- }
373
- } else {
374
- p.log.warn("Skipping push");
375
- }
376
- }
@@ -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
  }
@@ -17,6 +17,7 @@ import {
17
17
  } from "../lib/metadata.ts";
18
18
  import { ensureMetadata } from "../lib/safety.ts";
19
19
  import {
20
+ findOpenPrForBranch,
20
21
  getPrInfo,
21
22
  getPrBody,
22
23
  updatePrBody,
@@ -102,6 +103,32 @@ function vizHash(viz: string): string {
102
103
  return createHash("sha1").update(viz).digest("hex");
103
104
  }
104
105
 
106
+ /**
107
+ * Whether a live PR body already carries exactly this viz block. GitHub
108
+ * stores bodies edited in its web UI with CRLF line endings, so normalize
109
+ * before comparing — otherwise every such PR would look perpetually stale.
110
+ */
111
+ export function bodyHasViz(body: string, viz: string): boolean {
112
+ return body.replace(/\r\n/g, "\n").includes(viz);
113
+ }
114
+
115
+ /**
116
+ * Record PR numbers for tracked branches whose PR was opened outside gh-stack
117
+ * (e.g. `gh pr create`), by looking up the open PR for each PR-less branch.
118
+ * Only an unambiguous single match is adopted. Returns the branches adopted —
119
+ * the caller persists metadata when non-empty. (#35)
120
+ */
121
+ export async function discoverMissingPrs(stack: Stack, ordered: string[]): Promise<string[]> {
122
+ const missing = ordered.filter((b) => stack.branches[b]!.pr == null);
123
+ const found = await mapLimit(missing, 8, async (branch) => {
124
+ const pr = await findOpenPrForBranch(branch);
125
+ if (pr == null) return null;
126
+ stack.branches[branch]!.pr = pr;
127
+ return branch;
128
+ });
129
+ return found.filter((b): b is string => b != null);
130
+ }
131
+
105
132
  /** Splice the rendered viz block into a PR body, replacing any existing one. */
106
133
  function mergeViz(body: string, viz: string): string {
107
134
  let base = body;
@@ -135,6 +162,7 @@ export function numberedTitle(title: string, position: number, total: number): s
135
162
 
136
163
  export interface TitleResult {
137
164
  updated: number; // PR titles we edited on GitHub
165
+ updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
138
166
  dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
139
167
  }
140
168
 
@@ -184,23 +212,27 @@ export async function reconcilePrTitles(
184
212
 
185
213
  const items = ordered.map((name) => {
186
214
  const bm = stack.branches[name]!;
187
- return { pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
215
+ return { name, pr: bm.pr, prTitle: bm.prTitle, override: overrides[name], bm };
188
216
  });
189
217
 
190
- 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 }) => {
191
220
  const ok = await updatePrTitle(item.pr!, desired);
192
221
  if (ok) item.bm.prTitle = desired;
193
222
  return ok;
194
223
  });
195
224
 
196
- const updated = oks.filter(Boolean).length;
197
- 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 };
198
228
  }
199
229
 
200
230
  export interface VizResult {
201
231
  updated: number; // PRs whose description we PATCHed
232
+ updatedBranches: string[]; // …and their branches, for per-branch reporting (submit --json)
202
233
  unchanged: number; // PRs skipped because their rendered block was identical
203
234
  skipped: number; // branches with no PR
235
+ skippedBranches: string[]; // …and their names, so callers can say which
204
236
  dirty: boolean; // metadata mutated (caller should persist)
205
237
  }
206
238
 
@@ -213,12 +245,17 @@ export interface VizResult {
213
245
  * body: the override is used as the base instead of the current GitHub body,
214
246
  * the managed `📚 Stacked on` block is re-merged into it, and the PR is updated
215
247
  * even if the viz block itself didn't change.
248
+ *
249
+ * `verifyLive` checks each PR whose cached hash matches against its actual
250
+ * body, so a viz block removed by an external edit (`gh pr edit`, the web UI)
251
+ * is restored rather than reported "unchanged". It costs one read per PR, so
252
+ * `update-prs` opts in while `submit` stays on the cheap hash check. (#34)
216
253
  */
217
254
  export async function refreshStackViz(
218
255
  meta: StackMetadata,
219
256
  stack: Stack,
220
257
  ordered: string[],
221
- opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
258
+ opts: { force?: boolean; verifyLive?: boolean; bodyOverrides?: Record<string, string> } = {},
222
259
  ): Promise<VizResult> {
223
260
  const force = opts.force ?? false;
224
261
  const bodyOverrides = opts.bodyOverrides ?? {};
@@ -239,28 +276,45 @@ export async function refreshStackViz(
239
276
  const bm = stack.branches[info.branch]!;
240
277
  // A body override forces the PATCH even when the viz block is unchanged.
241
278
  const overridden = bodyOverrides[info.branch] != null;
242
- return { info, viz, hash, bm, needsUpdate: force || overridden || bm.vizHash !== hash };
279
+ const needsUpdate = force || overridden || bm.vizHash !== hash;
280
+ return { info, viz, hash, bm, needsUpdate, liveBody: null as string | null };
243
281
  });
244
282
 
283
+ if (opts.verifyLive) {
284
+ await mapLimit(
285
+ plans.filter((plan) => !plan.needsUpdate),
286
+ 8,
287
+ async (plan) => {
288
+ const body = await getPrBody(plan.info.prNumber!);
289
+ if (body !== null && !bodyHasViz(body, plan.viz)) {
290
+ plan.needsUpdate = true;
291
+ plan.liveBody = body;
292
+ }
293
+ },
294
+ );
295
+ }
296
+
245
297
  const toUpdate = plans.filter((plan) => plan.needsUpdate);
246
298
  const unchanged = plans.length - toUpdate.length;
247
- const skipped = branchInfos.length - plans.length;
299
+ const skippedBranches = branchInfos.filter((b) => b.prNumber == null).map((b) => b.branch);
300
+ const skipped = skippedBranches.length;
248
301
 
249
302
  const oks = await mapLimit(toUpdate, 8, async (plan) => {
250
303
  // `submit -b` replaces the body; otherwise keep the PR's current body and
251
304
  // just re-merge the viz block into it.
252
305
  const override = bodyOverrides[plan.info.branch];
253
- const body = override ?? (await getPrBody(plan.info.prNumber!));
306
+ const body = override ?? plan.liveBody ?? (await getPrBody(plan.info.prNumber!));
254
307
  if (body === null) return false;
255
308
  const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
256
309
  if (ok) plan.bm.vizHash = plan.hash;
257
310
  return ok;
258
311
  });
259
312
 
260
- const updated = oks.filter(Boolean).length;
313
+ const updatedBranches = toUpdate.filter((_, i) => oks[i]).map((plan) => plan.info.branch);
314
+ const updated = updatedBranches.length;
261
315
  if (updated > 0) dirty = true;
262
316
 
263
- return { updated, unchanged, skipped, dirty };
317
+ return { updated, updatedBranches, unchanged, skipped, skippedBranches, dirty };
264
318
  }
265
319
 
266
320
  export default async function updatePrs(args: string[]): Promise<void> {
@@ -272,8 +326,13 @@ USAGE
272
326
  gh-stack update-prs [--force]
273
327
 
274
328
  Updates all PRs in the current stack with a standardized stack section
275
- showing the tree structure, PR links, and per-PR stack index. By default
276
- PRs whose rendered block is unchanged are skipped; --force rewrites all.
329
+ showing the tree structure, PR links, and per-PR stack index. Each PR's
330
+ live description is checked, so a stack section removed by an outside
331
+ edit (gh pr edit, the GitHub UI) is restored. PRs that already show the
332
+ current section are left alone; --force rewrites all.
333
+
334
+ Tracked branches with no recorded PR are looked up on GitHub first, so a
335
+ PR opened outside gh-stack (e.g. gh pr create) is adopted automatically.
277
336
  `);
278
337
  return;
279
338
  }
@@ -298,17 +357,30 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
298
357
 
299
358
  const s = p.spinner();
300
359
  s.start("Updating PR titles & descriptions...");
360
+ const adopted = await discoverMissingPrs(stack, ordered);
301
361
  const titles = await reconcilePrTitles(stack, ordered);
302
- const result = await refreshStackViz(meta, stack, ordered, { force });
362
+ const result = await refreshStackViz(meta, stack, ordered, { force, verifyLive: true });
303
363
  s.stop("Done");
304
364
 
305
- if (titles.dirty || result.dirty) await writeMetadata(meta);
365
+ if (adopted.length > 0 || titles.dirty || result.dirty) await writeMetadata(meta);
366
+
367
+ for (const b of adopted) {
368
+ p.log.success(
369
+ `Adopted PR #${stack.branches[b]!.pr} for ${pc.blue(b)} (opened outside gh-stack)`,
370
+ );
371
+ }
372
+ if (result.skippedBranches.length > 0) {
373
+ p.log.warn(
374
+ `Skipped ${result.skippedBranches.length} branch(es) with no open PR: ${result.skippedBranches.map((b) => pc.blue(b)).join(", ")}\n` +
375
+ ` Open them with ${pc.green("gh-stack submit")}`,
376
+ );
377
+ }
306
378
 
307
379
  console.log();
308
380
  p.outro(
309
381
  pc.green(
310
382
  `Updated ${result.updated} description(s), ${titles.updated} title(s)` +
311
- (result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
383
+ (result.unchanged > 0 ? `, ${result.unchanged} already up to date` : "") +
312
384
  (result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
313
385
  ),
314
386
  );