gh-stack 0.10.0 → 0.12.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.
package/README.md CHANGED
@@ -85,8 +85,9 @@ create <branch-name> [--description <desc>]
85
85
 
86
86
  submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
87
87
  Push all downstack branches to GitHub, create PRs for branches
88
- that don't have them, and update all PR descriptions with stack
89
- visualization. Idempotent — safe to run repeatedly.
88
+ that don't have them, number each PR title with its stack position
89
+ `(N/M)`, and update all PR descriptions with stack visualization.
90
+ Idempotent — safe to run repeatedly.
90
91
 
91
92
  Self-healing: if the current branch isn't tracked in a stack yet,
92
93
  submit auto-detects the chain from trunk → current, registers (or
@@ -163,11 +164,13 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
163
164
  `gh-stack merge` (without --collapse) to finish.
164
165
 
165
166
  split [<branch>] [--name <name>]
166
- Cut the current stack into two at <branch>. The cut branch and every
167
- branch above it move into a NEW stack whose base is the cut branch's
168
- parent (which stays in the original stack). Purely a metadata
169
- operation — no git branches are moved or rebased. Interactive
170
- selector if no branch is given; you can't split at the stack's root.
167
+ Cut a stack into two at <branch>. The cut branch and every branch
168
+ above it move into a NEW stack whose base is the cut branch's parent
169
+ (which stays in the original stack). The target stack is the one
170
+ containing <branch>, so this works from anywhere — not only while
171
+ standing on it. Purely a metadata operation — no git branches are
172
+ moved or rebased. Interactive selector (current stack) if no branch
173
+ is given; you can't split at the stack's root.
171
174
 
172
175
  Use it when a long chain is in review and can't merge yet, but new
173
176
  work is piling on top: split at the first "new work" branch so the
@@ -189,9 +192,11 @@ rename [<old-name>] <new-name>
189
192
  still tracks the old remote branch.
190
193
 
191
194
  delete [<branch>] [-k|--keep-branch] [--no-remote]
192
- Remove a branch from the stack and re-parent its children, then
195
+ Remove a branch from its stack and re-parent its children, then
193
196
  delete the underlying local git branch (and the remote branch if
194
- it was pushed). Interactive selector if no branch specified.
197
+ it was pushed). The <branch> argument names its own stack, so this
198
+ works from anywhere. Interactive selector (current stack) if none
199
+ specified.
195
200
  --keep-branch leaves the git branches untouched (metadata only);
196
201
  --no-remote deletes the local branch but keeps the remote.
197
202
  ```
@@ -203,6 +208,11 @@ log
203
208
  Display the current stack as a tree with branch numbers,
204
209
  PR info, and descriptions. This is the default command.
205
210
 
211
+ If the current branch isn't tracked yet, log inspects its ancestry
212
+ and points at the next step: if it sits on top of an existing stack,
213
+ `gh-stack submit` to add it; if it's stacked on untracked branches,
214
+ submit to create the stack and open PRs; if it's on trunk, `init`.
215
+
206
216
  update-prs [--force]
207
217
  Refresh the "📚 Stacked on" visualization block in every PR
208
218
  description for the current stack. PRs whose rendered block is
@@ -270,19 +280,36 @@ gh-stack solves this by snapshotting every branch's tip **before** any destructi
270
280
 
271
281
  Snapshots also power `gh-stack undo`, so the same data structure does double duty.
272
282
 
283
+ ### Stack Position in PR Titles
284
+
285
+ `submit` keeps each PR's **title** suffixed with its stack position as `(N/M)`,
286
+ so you can see the order from a bare "needs your review" list — where only
287
+ titles show, not descriptions — and review bottom-up:
288
+
289
+ ```
290
+ feat(paid-subs): tiers overview list [BEE-20531] (1/4)
291
+ feat(paid-subs): tier detail page [BEE-20550] (2/4)
292
+ feat(paid-subs): tier create wizard [BEE-20579] (3/4)
293
+ feat(paid-subs): advanced settings step [BEE-20587] (4/4)
294
+ ```
295
+
296
+ Parentheses (not brackets) avoid colliding with `[BEE-1234]` ticket tags. It's
297
+ self-healing and idempotent: re-running `submit` after adding or reordering a
298
+ branch renumbers the whole stack, and a no-op re-submit changes nothing. The
299
+ position is the only managed part of the title — your text (and ticket tags)
300
+ are preserved. Single-PR stacks get no suffix.
301
+
273
302
  ### Stack Visualization
274
303
 
275
- `submit` automatically adds a stack section to all PR descriptions, with each
276
- branch numbered by its position in the stack (handy for spotting "which one is
277
- PR 6 of 12"):
304
+ `submit` also adds a stack section to every PR description:
278
305
 
279
306
  ```
280
307
  ### 📚 Stacked on
281
308
  ⚫ main
282
309
  ┃
283
- ┣━ 1. #123 Backend models
310
+ ┣━ #123 feat: backend models (1/2)
284
311
  ┃
285
- ┗━ 2. #124 Frontend UI 👈
312
+ ┗━ #124 feat: frontend UI (2/2) 👈
286
313
  ```
287
314
 
288
315
  The block is rendered entirely from local metadata plus one repo-identity
@@ -299,7 +326,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
299
326
 
300
327
  ```
301
328
  .git/.gh-stack/
302
- current hint: last-active stack name
329
+ current derived stack of the current branch (self-heals on read)
303
330
  active/<stack>.json topology of a live stack (ordered branches, base, description)
304
331
  archived/<stack>.json merged/closed stacks
305
332
  deleted/<stack>.json tombstones (recoverable)
@@ -337,8 +364,10 @@ any drift. Why this shape:
337
364
  - **A stack can't silently vanish** — lifecycle transitions are file moves
338
365
  (`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
339
366
  just unlinked.
340
- - **No `current_stack` drift** — the stack you're on is derivable from the
341
- branch's own config, so a written pointer can't contradict reality.
367
+ - **No `current_stack` drift** — the stack you're on is derived from the branch
368
+ you've checked out (its own membership, or the nearest tracked ancestor for a
369
+ new branch stacked on top), re-resolved and self-healed on every read, so a
370
+ written pointer can never contradict reality.
342
371
  - **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
343
372
  - **Renames heal themselves** — `git branch -m old new` moves the whole
344
373
  `branch.<old>` config section to `branch.<new>` (id and all), but the JSON is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -2,7 +2,12 @@
2
2
  import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
5
- import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
5
+ import {
6
+ ensureMetadata,
7
+ ensureCurrentStack,
8
+ ensureValidStack,
9
+ resolveStackForBranchArg,
10
+ } from "../lib/safety.ts";
6
11
  import { takeSnapshot } from "../lib/snapshot.ts";
7
12
  import { selectBranch, confirmAction } from "../lib/ui.ts";
8
13
  import * as git from "../lib/git.ts";
@@ -34,7 +39,12 @@ OPTIONS
34
39
  const positional = args.filter((a) => !a.startsWith("-"));
35
40
 
36
41
  const meta = await ensureMetadata();
37
- const stackName = ensureCurrentStack(meta);
42
+ // The branch to delete names its own stack — resolve from it so
43
+ // `delete <branch>` works from anywhere. Only fall back to the current stack
44
+ // (the branch you're on) in interactive mode, when no branch was given.
45
+ const stackName = positional[0]
46
+ ? resolveStackForBranchArg(meta, positional[0]!)
47
+ : ensureCurrentStack(meta);
38
48
  await ensureValidStack(meta, stackName);
39
49
  const stack = meta.stacks[stackName]!;
40
50
  const currentBranch = await git.currentBranch();
@@ -25,24 +25,22 @@ ALIASES
25
25
  const meta = await ensureMetadata();
26
26
  const currentBranch = await git.currentBranch();
27
27
 
28
- // Find which stack contains the current branch
29
- const branchStackName = findStackForBranch(meta, currentBranch);
30
- let stackName = branchStackName;
31
- if (!stackName) stackName = meta.current_stack;
28
+ // The current stack is whichever stack the checked-out branch belongs to —
29
+ // never the persisted `current_stack` hint. If this branch is in no stack,
30
+ // there is no current stack; don't resurface a previously-used one.
31
+ const stackName = findStackForBranch(meta, currentBranch);
32
32
 
33
33
  if (!stackName || !meta.stacks[stackName]) {
34
- console.log("No active stack found.");
34
+ console.log("Branch is not in any stack.");
35
35
  process.exit(1);
36
36
  }
37
37
 
38
38
  const stack = meta.stacks[stackName]!;
39
39
  const ordered = getOrderedBranches(stack);
40
40
 
41
- // Update tracking
41
+ // We're on a branch in this stack — record it as current + its last_branch.
42
42
  meta.current_stack = stackName;
43
- if (branchStackName === stackName) {
44
- stack.last_branch = currentBranch;
45
- }
43
+ stack.last_branch = currentBranch;
46
44
  await writeMetadata(meta);
47
45
 
48
46
  // Header
@@ -10,6 +10,8 @@ import {
10
10
  } from "../lib/metadata.ts";
11
11
  import { renderStackTree } from "../lib/ui.ts";
12
12
  import { gateLegacyMetadata } from "../lib/safety.ts";
13
+ import { detectBranchChain } from "../lib/chain.ts";
14
+ import type { StackMetadata } from "../types.ts";
13
15
 
14
16
  export default async function log(_args: string[]): Promise<void> {
15
17
  // Check if metadata exists
@@ -30,33 +32,21 @@ export default async function log(_args: string[]): Promise<void> {
30
32
 
31
33
  const branch = await git.currentBranch();
32
34
 
33
- // Find which stack contains the current branch
34
- const branchStackName = findStackForBranch(meta, branch);
35
- let stackName = branchStackName;
36
-
37
- if (!stackName) {
38
- // Fall back to current_stack
39
- stackName = meta.current_stack;
40
- }
35
+ // The current stack is whichever stack the checked-out branch belongs to —
36
+ // never the persisted `current_stack` hint. If this branch is in no stack,
37
+ // there is no current stack; don't resurface a previously-used one.
38
+ const stackName = findStackForBranch(meta, branch);
41
39
 
42
40
  if (!stackName || !meta.stacks[stackName]) {
43
- p.log.warn(`Branch ${pc.blue(branch)} is not in any stack`);
44
- console.log();
45
- console.log(` Add it to a stack with:`);
46
- console.log(` ${pc.green("gh-stack add")}`);
47
- console.log();
48
- console.log(` Or create a new stack:`);
49
- console.log(` ${pc.green("gh-stack init")}`);
41
+ await suggestNextStep(meta, branch);
50
42
  process.exit(1);
51
43
  }
52
44
 
53
45
  const stack = meta.stacks[stackName]!;
54
46
 
55
- // Update current_stack and last_branch
47
+ // We're on a branch in this stack — record it as current + its last_branch.
56
48
  meta.current_stack = stackName;
57
- if (branchStackName === stackName) {
58
- stack.last_branch = branch;
59
- }
49
+ stack.last_branch = branch;
60
50
  await writeMetadata(meta);
61
51
 
62
52
  // Header
@@ -76,3 +66,93 @@ export default async function log(_args: string[]): Promise<void> {
76
66
  console.log(pc.dim(`Tip: Switch stacks with 'gh-stack checkout --stack'`));
77
67
  console.log();
78
68
  }
69
+
70
+ /**
71
+ * The current branch isn't a tracked stack member. Inspect its local ancestry —
72
+ * the same chain `submit` would self-heal — and point at the lowest-friction
73
+ * next step:
74
+ * - sits on top of an existing stack → `submit` to ADD it to that stack;
75
+ * - stacked on untracked branches only → `submit` to CREATE a new stack;
76
+ * - based directly on trunk → `submit` to push + open a PR;
77
+ * - on trunk → `init`.
78
+ */
79
+ async function suggestNextStep(meta: StackMetadata, branch: string): Promise<void> {
80
+ const trunk = await git.trunkBranch();
81
+
82
+ if (branch === trunk || branch === "main" || branch === "master") {
83
+ p.log.warn(`You're on ${pc.blue(branch)} — not a stack branch`);
84
+ console.log();
85
+ console.log(` Checkout a feature branch, then start a stack with:`);
86
+ console.log(` ${pc.green("gh-stack init")}`);
87
+ console.log();
88
+ return;
89
+ }
90
+
91
+ // Reconstruct trunk→current the way `submit` does (local branches whose tips
92
+ // are ancestors of HEAD). [current] alone means it's based right off trunk.
93
+ let chain: string[] = [branch];
94
+ try {
95
+ chain = await detectBranchChain(branch, trunk);
96
+ } catch {
97
+ // Fall back to treating it as a single branch off trunk.
98
+ }
99
+
100
+ // Is this chain sitting on top of an existing tracked stack? Find the nearest
101
+ // ancestor in the chain that's already a stack member.
102
+ let attachStack: string | null = null;
103
+ for (let i = chain.length - 2; i >= 0; i--) {
104
+ const owner = findStackForBranch(meta, chain[i]!);
105
+ if (owner) {
106
+ attachStack = owner;
107
+ break;
108
+ }
109
+ }
110
+
111
+ p.log.warn(`Branch ${pc.blue(branch)} isn't tracked in a stack yet`);
112
+ console.log();
113
+
114
+ if (attachStack) {
115
+ // Extends an existing stack — submit ADDS the untracked tail to it.
116
+ console.log(` It sits on top of stack ${pc.yellow(attachStack)}:`);
117
+ console.log();
118
+ console.log(` ${pc.dim(trunk)}`);
119
+ for (let i = 0; i < chain.length; i++) {
120
+ const b = chain[i]!;
121
+ const isLast = i === chain.length - 1;
122
+ const tree = isLast ? " ┗━" : " ┣━";
123
+ const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
124
+ const tag = findStackForBranch(meta, b) ? pc.dim(" (in stack)") : pc.green(" +");
125
+ console.log(`${tree} ${label}${tag}`);
126
+ if (!isLast) console.log(" ┃");
127
+ }
128
+ console.log();
129
+ console.log(` Add it to the stack:`);
130
+ console.log(` ${pc.green("gh-stack submit")}`);
131
+ console.log();
132
+ return;
133
+ }
134
+
135
+ if (chain.length > 1) {
136
+ // Stacked on untracked branches only — submit CREATES a new stack.
137
+ console.log(` It's stacked on ${chain.length - 1} other local branch(es):`);
138
+ console.log();
139
+ console.log(` ${pc.dim(trunk)}`);
140
+ for (let i = 0; i < chain.length; i++) {
141
+ const b = chain[i]!;
142
+ const isLast = i === chain.length - 1;
143
+ const tree = isLast ? " ┗━" : " ┣━";
144
+ const label = b === branch ? pc.yellow(b) + pc.dim(" (current)") : b;
145
+ console.log(`${tree} ${label}`);
146
+ if (!isLast) console.log(" ┃");
147
+ }
148
+ console.log();
149
+ console.log(` Create the stack and open PRs for the whole chain:`);
150
+ } else {
151
+ // A single branch based directly on trunk.
152
+ console.log(` It's based directly on ${pc.dim(trunk)}.`);
153
+ console.log();
154
+ console.log(` Push it and open a PR — this also starts the stack:`);
155
+ }
156
+ console.log(` ${pc.green("gh-stack submit")}`);
157
+ console.log();
158
+ }
@@ -513,8 +513,10 @@ OPTIONS
513
513
  meta.archive[stackName] = { ...stack };
514
514
  delete meta.stacks[stackName];
515
515
  if (meta.current_stack === stackName) {
516
- const remaining = Object.keys(meta.stacks);
517
- meta.current_stack = remaining.length > 0 ? remaining[0]! : null;
516
+ // Don't auto-pick a random leftover stack as "current" — that switches you
517
+ // to a stack you never checked out. The current stack is derived from the
518
+ // branch you're on; after a merge you're on main, so default to none.
519
+ meta.current_stack = null;
518
520
  }
519
521
  await writeMetadata(meta);
520
522
 
@@ -12,7 +12,12 @@
12
12
  import * as p from "../lib/output.ts";
13
13
  import pc from "picocolors";
14
14
  import { buildRebaseChain, getOrderedBranches, stackBase, writeMetadata } from "../lib/metadata.ts";
15
- import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
15
+ import {
16
+ ensureMetadata,
17
+ ensureCurrentStack,
18
+ ensureValidStack,
19
+ resolveStackForBranchArg,
20
+ } from "../lib/safety.ts";
16
21
  import { selectBranch, confirmAction } from "../lib/ui.ts";
17
22
  import * as git from "../lib/git.ts";
18
23
  import type { Stack } from "../types.ts";
@@ -55,7 +60,12 @@ export default async function split(args: string[]): Promise<void> {
55
60
  const positional = args.filter((a) => !a.startsWith("-"));
56
61
 
57
62
  const meta = await ensureMetadata();
58
- const stackName = ensureCurrentStack(meta);
63
+ // The cut branch names its own stack — resolve from it so `split <branch>`
64
+ // works from anywhere. Only fall back to the current stack (the branch you're
65
+ // on) in interactive mode, when no branch was given.
66
+ const stackName = positional[0]
67
+ ? resolveStackForBranchArg(meta, positional[0]!)
68
+ : ensureCurrentStack(meta);
59
69
  await ensureValidStack(meta, stackName);
60
70
  const stack = meta.stacks[stackName]!;
61
71
  const currentBranch = await git.currentBranch();
@@ -11,7 +11,6 @@ import {
11
11
  metadataExists,
12
12
  legacyMetadataExists,
13
13
  getOrderedBranches,
14
- findStackForBranch,
15
14
  stackBase,
16
15
  } from "../lib/metadata.ts";
17
16
 
@@ -72,7 +71,10 @@ metadata. Use --json as a stable interface so scripts don't parse the
72
71
  // Detached HEAD or not on a branch — fine, just no "current" context.
73
72
  }
74
73
 
75
- const currentStackName = currentBranch ? findStackForBranch(meta, currentBranch) : null;
74
+ // readMetadata already resolved the current stack authoritatively: the
75
+ // branch's own stack, or — for a new branch built on top of one — the nearest
76
+ // tracked ancestor's stack, or null. Trust it rather than re-deriving.
77
+ const currentStackName = meta.current_stack;
76
78
 
77
79
  const entries = Object.entries(meta.stacks)
78
80
  .filter(([name]) => !currentOnly || name === currentStackName)
@@ -99,7 +101,7 @@ metadata. Use --json as a stable interface so scripts don't parse the
99
101
  console.log(
100
102
  JSON.stringify(
101
103
  {
102
- current_stack: currentStackName ?? meta.current_stack ?? null,
104
+ current_stack: currentStackName,
103
105
  current_branch: currentBranch,
104
106
  stacks: entries,
105
107
  },
@@ -13,7 +13,7 @@ import { isAutoYes } from "../lib/ui.ts";
13
13
  import type { StackMetadata } from "../types.ts";
14
14
  import { getPrNumber } from "../lib/github.ts";
15
15
  import { resolveOrCreateStack } from "../lib/chain.ts";
16
- import { refreshStackViz, branchToTitle } from "./update-prs.ts";
16
+ import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
17
17
 
18
18
  const HELP = `
19
19
  gh-stack submit — Push branches and create/update PRs
@@ -353,14 +353,17 @@ export default async function submit(args: string[]): Promise<void> {
353
353
  return;
354
354
  }
355
355
 
356
- // ── Phase 2: Refresh the stack visualization (renders locally, PATCHes only
357
- // the PRs whose block actually changed; see issue #16). ──
358
- p.log.info(pc.cyan("Updating stack visualization..."));
356
+ // ── Phase 2: Reconcile PR titles (stack position) then refresh the stack
357
+ // visualization. Titles first so the viz renders the numbered titles. Both
358
+ // render locally and only hit the network for PRs that actually changed
359
+ // (see issue #16), so a no-op re-submit is free. ──
360
+ p.log.info(pc.cyan("Updating PR titles & stack visualization..."));
361
+ const titles = await reconcilePrTitles(stack, ordered);
359
362
  const viz = await refreshStackViz(meta, stack, ordered);
360
363
 
361
364
  // One persist for everything we cached this run (discovered PRs, base edits,
362
- // backfilled titles, viz hashes).
363
- if (metadataChanged || viz.dirty) {
365
+ // backfilled/renumbered titles, viz hashes).
366
+ if (metadataChanged || titles.dirty || viz.dirty) {
364
367
  await writeMetadata(meta);
365
368
  }
366
369
 
@@ -368,7 +371,7 @@ export default async function submit(args: string[]): Promise<void> {
368
371
  console.log();
369
372
  p.outro(
370
373
  pc.green(
371
- `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s)` +
374
+ `Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${viz.updated} description(s), ${titles.updated} title(s)` +
372
375
  (viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
373
376
  ),
374
377
  );
@@ -16,8 +16,15 @@ import {
16
16
  writeMetadata,
17
17
  } from "../lib/metadata.ts";
18
18
  import { ensureMetadata } from "../lib/safety.ts";
19
- import { getPrInfo, getPrBody, updatePrBody, getRepoNwo, prUrlFor } from "../lib/github.ts";
20
- import type { StackMetadata, Stack } from "../types.ts";
19
+ import {
20
+ getPrInfo,
21
+ getPrBody,
22
+ updatePrBody,
23
+ updatePrTitle,
24
+ getRepoNwo,
25
+ prUrlFor,
26
+ } from "../lib/github.ts";
27
+ import type { StackMetadata, Stack, Branch } from "../types.ts";
21
28
 
22
29
  /** The ref a stack is rooted on, for rendering the base node of the viz. */
23
30
  export interface BaseRef {
@@ -103,6 +110,66 @@ function mergeViz(body: string, viz: string): string {
103
110
  return base ? `${base}\n\n${viz}` : viz;
104
111
  }
105
112
 
113
+ // ── PR-title stack numbering ──
114
+ //
115
+ // The stack position lives in the PR title as a ` (N/M)` suffix so reviewers
116
+ // can see order from a bare "needs review" list (parens, not brackets, to avoid
117
+ // colliding with `[BEE-1234]` ticket tags). The cached `prTitle` tracks exactly
118
+ // what's on GitHub; we recover the base title by stripping the suffix and
119
+ // re-applying the current position, so reordering/adding a branch renumbers the
120
+ // whole stack and a no-op re-submit touches nothing.
121
+
122
+ /** Matches a trailing ` (N/M)` stack-position suffix, e.g. " (2/4)". */
123
+ const SEQ_SUFFIX = /\s*\(\d+\/\d+\)\s*$/;
124
+
125
+ /** Strip a trailing ` (N/M)` suffix to recover the base PR title. */
126
+ export function stripSeqSuffix(title: string): string {
127
+ return title.replace(SEQ_SUFFIX, "").trimEnd();
128
+ }
129
+
130
+ /** The title a branch at `position` of `total` should have. Idempotent. */
131
+ export function numberedTitle(title: string, position: number, total: number): string {
132
+ const base = stripSeqSuffix(title);
133
+ return total >= 2 ? `${base} (${position}/${total})` : base;
134
+ }
135
+
136
+ export interface TitleResult {
137
+ updated: number; // PR titles we edited on GitHub
138
+ dirty: boolean; // metadata mutated (cached prTitle changed) — caller persists
139
+ }
140
+
141
+ /**
142
+ * Reconcile every PR title in the stack to carry its `(N/M)` stack position.
143
+ * Backfills any missing cached titles first (so we never clobber a real GitHub
144
+ * title with a branch-name guess), renders the desired title from the cached
145
+ * one, and only calls `gh pr edit --title` when it actually changed — a no-op
146
+ * re-submit makes zero edits, while adding/reordering a branch renumbers the
147
+ * whole stack. Run this BEFORE refreshStackViz so the viz shows numbered titles.
148
+ */
149
+ export async function reconcilePrTitles(stack: Stack, ordered: string[]): Promise<TitleResult> {
150
+ const dirtyBackfill = await backfillTitles(stack, ordered);
151
+ const total = ordered.length;
152
+
153
+ const plans: { pr: number; desired: string; bm: Branch }[] = [];
154
+ for (let i = 0; i < ordered.length; i++) {
155
+ const bm = stack.branches[ordered[i]!]!;
156
+ // Need a known base title — never guess from the branch name for an
157
+ // existing PR (backfill couldn't fetch it → skip rather than clobber).
158
+ if (bm.pr == null || !bm.prTitle) continue;
159
+ const desired = numberedTitle(bm.prTitle, i + 1, total);
160
+ if (desired !== bm.prTitle) plans.push({ pr: bm.pr, desired, bm });
161
+ }
162
+
163
+ const oks = await mapLimit(plans, 8, async (plan) => {
164
+ const ok = await updatePrTitle(plan.pr, plan.desired);
165
+ if (ok) plan.bm.prTitle = plan.desired;
166
+ return ok;
167
+ });
168
+
169
+ const updated = oks.filter(Boolean).length;
170
+ return { updated, dirty: dirtyBackfill || updated > 0 };
171
+ }
172
+
106
173
  export interface VizResult {
107
174
  updated: number; // PRs whose description we PATCHed
108
175
  unchanged: number; // PRs skipped because their rendered block was identical
@@ -192,16 +259,17 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
192
259
  console.log();
193
260
 
194
261
  const s = p.spinner();
195
- s.start("Updating PR descriptions...");
262
+ s.start("Updating PR titles & descriptions...");
263
+ const titles = await reconcilePrTitles(stack, ordered);
196
264
  const result = await refreshStackViz(meta, stack, ordered, { force });
197
265
  s.stop("Done");
198
266
 
199
- if (result.dirty) await writeMetadata(meta);
267
+ if (titles.dirty || result.dirty) await writeMetadata(meta);
200
268
 
201
269
  console.log();
202
270
  p.outro(
203
271
  pc.green(
204
- `Updated ${result.updated} PR(s)` +
272
+ `Updated ${result.updated} description(s), ${titles.updated} title(s)` +
205
273
  (result.unchanged > 0 ? `, ${result.unchanged} unchanged` : "") +
206
274
  (result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
207
275
  ),
@@ -249,9 +317,9 @@ export function buildStackViz(
249
317
  // Tree character
250
318
  const tree = isLast ? "┗━" : "┣━";
251
319
 
252
- // Leading `N.` is the branch's 1-based position in the stack — makes it
253
- // easy to tell which PR is #6 in a 12-PR stack at a glance.
254
- lines.push(`${tree} ${i + 1}. ${prLink} ${info.prTitle}${marker}`);
320
+ // No leading `N.` — the stack position now lives in the PR title itself as
321
+ // a `(N/M)` suffix (see reconcilePrTitles), so the tree just shows titles.
322
+ lines.push(`${tree} ${prLink} ${info.prTitle}${marker}`);
255
323
 
256
324
  if (!isLast) {
257
325
  lines.push("┃");
package/src/lib/github.ts CHANGED
@@ -136,6 +136,18 @@ export async function updatePrBody(prNumber: number, body: string): Promise<bool
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * Update a PR's title.
141
+ */
142
+ export async function updatePrTitle(prNumber: number, title: string): Promise<boolean> {
143
+ try {
144
+ await $`gh pr edit ${prNumber} --title ${title}`.quiet();
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
150
+
139
151
  /**
140
152
  * Get PR body text.
141
153
  */
@@ -31,6 +31,7 @@ import {
31
31
  type BranchMembership,
32
32
  } from "./branch-config.ts";
33
33
  import { loadSnapshots } from "./snapshot.ts";
34
+ import * as git from "./git.ts";
34
35
 
35
36
  async function pathExists(p: string): Promise<boolean> {
36
37
  try {
@@ -136,9 +137,91 @@ export async function readMetadata(): Promise<StackMetadata | null> {
136
137
  // (which also repoints renamed branches' child config). Keeping reads
137
138
  // side-effect-free avoids surprising writes from `status`/`--dry-run`.
138
139
  await reconcileRenames(meta);
140
+ // The current stack is *derived* from the branch you're on, never trusted
141
+ // from the stored hint. Every read re-verifies it (and corrects the on-disk
142
+ // pointer) so no command can act on a stack you've already left.
143
+ await reconcileCurrentStack(meta);
139
144
  return meta;
140
145
  }
141
146
 
147
+ /**
148
+ * Resolve and pin `meta.current_stack` to whichever active stack owns the
149
+ * checked-out branch — the single source of truth for "what stack am I on."
150
+ *
151
+ * Resolution, mirroring the on-disk authority order:
152
+ * 1. The branch's git config (`branch.<name>.ghstack-stack`) — rename-proof
153
+ * membership — *if* the named stack is active and still lists the branch.
154
+ * 2. Otherwise scan the assembled active stacks for the branch by name.
155
+ * 3. Otherwise walk up the branch's ancestry: a new branch built on top of a
156
+ * stack belongs to that stack (until submitted), so the nearest *tracked*
157
+ * ancestor's stack is the current one. Only tracked branches are probed,
158
+ * so this stays cheap even with many local branches.
159
+ * 4. Otherwise (ancestry reaches trunk with nothing tracked, or HEAD
160
+ * detached) there is no current stack → null.
161
+ *
162
+ * The resolved value is also written back to the `current` pointer file when it
163
+ * drifts, so a stale hint left by a prior session, a merge, or a plain
164
+ * `git checkout` self-heals on the very next command. Only the pointer file is
165
+ * touched — stack topology, refs, and snapshots are never written here, so this
166
+ * stays safe for read-only / `--dry-run` commands.
167
+ */
168
+ async function reconcileCurrentStack(meta: StackMetadata): Promise<void> {
169
+ let branch: string | null = null;
170
+ try {
171
+ branch = await git.currentBranch();
172
+ } catch {
173
+ // Detached HEAD / not on a branch → no current stack.
174
+ }
175
+
176
+ let resolved: string | null = null;
177
+ if (branch) {
178
+ // 1. Trust branch config only if the topology still backs it up.
179
+ const config = await allBranchMemberships();
180
+ const configStack = config.get(branch)?.stack;
181
+ if (configStack && meta.stacks[configStack]?.branches[branch]) {
182
+ resolved = configStack;
183
+ } else {
184
+ // 2. Fall back to scanning the active stacks for direct membership.
185
+ resolved = findStackForBranch(meta, branch);
186
+ }
187
+ // 3. Untracked branch — inherit the stack of its nearest tracked ancestor.
188
+ if (!resolved) {
189
+ resolved = await nearestTrackedAncestorStack(meta, branch);
190
+ }
191
+ }
192
+
193
+ if (meta.current_stack !== resolved) {
194
+ meta.current_stack = resolved;
195
+ await Bun.write(await currentFile(), (resolved ?? "") + "\n");
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Walk up `branch`'s ancestry and return the stack of the nearest branch that's
201
+ * already a tracked member of an active stack — i.e. the stack this branch sits
202
+ * on top of. Probes only tracked branches (small N), picking the closest one by
203
+ * commit distance, so a chain that spans two stacks resolves to the nearer one.
204
+ * Returns null when no tracked branch is an ancestor (the branch traces back to
205
+ * trunk without crossing a stack).
206
+ */
207
+ export async function nearestTrackedAncestorStack(
208
+ meta: StackMetadata,
209
+ branch: string,
210
+ ): Promise<string | null> {
211
+ let best: { stack: string; distance: number } | null = null;
212
+ for (const [stackName, stack] of Object.entries(meta.stacks)) {
213
+ for (const member of Object.keys(stack.branches)) {
214
+ if (member === branch) continue;
215
+ if (!(await git.isAncestor(member, branch))) continue;
216
+ const distance = await git.commitCount(member, branch);
217
+ if (distance > 0 && (!best || distance < best.distance)) {
218
+ best = { stack: stackName, distance };
219
+ }
220
+ }
221
+ }
222
+ return best?.stack ?? null;
223
+ }
224
+
142
225
  /**
143
226
  * The ref a stack is rooted on. Defaults to "main" for stacks created before
144
227
  * the `base` field existed.
package/src/lib/safety.ts CHANGED
@@ -71,6 +71,23 @@ export function ensureCurrentStack(meta: StackMetadata): string {
71
71
  return stack;
72
72
  }
73
73
 
74
+ /**
75
+ * Resolve the stack to operate on from a branch argument. The branch names its
76
+ * own stack unambiguously, so commands that take a branch (split, delete) use
77
+ * this instead of trusting the current stack — letting `<cmd> <branch>` work
78
+ * from anywhere, not only while standing on that stack.
79
+ */
80
+ export function resolveStackForBranchArg(meta: StackMetadata, branch: string): string {
81
+ const stackName = findStackForBranch(meta, branch);
82
+ if (!stackName) {
83
+ p.cancel(
84
+ `Branch ${pc.yellow(branch)} is not in any stack.\n\n See your stacks with:\n ${pc.green("gh-stack stacks")}`,
85
+ );
86
+ process.exit(1);
87
+ }
88
+ return stackName;
89
+ }
90
+
74
91
  /**
75
92
  * Ensure current branch is in a stack. Returns the stack name.
76
93
  */