gh-stack 0.10.0 → 0.11.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
@@ -163,11 +163,13 @@ merge [--dry-run] [-d|--delete-branch] [--collapse]
163
163
  `gh-stack merge` (without --collapse) to finish.
164
164
 
165
165
  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.
166
+ Cut a stack into two at <branch>. The cut branch and every branch
167
+ above it move into a NEW stack whose base is the cut branch's parent
168
+ (which stays in the original stack). The target stack is the one
169
+ containing <branch>, so this works from anywhere — not only while
170
+ standing on it. Purely a metadata operation — no git branches are
171
+ moved or rebased. Interactive selector (current stack) if no branch
172
+ is given; you can't split at the stack's root.
171
173
 
172
174
  Use it when a long chain is in review and can't merge yet, but new
173
175
  work is piling on top: split at the first "new work" branch so the
@@ -189,9 +191,11 @@ rename [<old-name>] <new-name>
189
191
  still tracks the old remote branch.
190
192
 
191
193
  delete [<branch>] [-k|--keep-branch] [--no-remote]
192
- Remove a branch from the stack and re-parent its children, then
194
+ Remove a branch from its stack and re-parent its children, then
193
195
  delete the underlying local git branch (and the remote branch if
194
- it was pushed). Interactive selector if no branch specified.
196
+ it was pushed). The <branch> argument names its own stack, so this
197
+ works from anywhere. Interactive selector (current stack) if none
198
+ specified.
195
199
  --keep-branch leaves the git branches untouched (metadata only);
196
200
  --no-remote deletes the local branch but keeps the remote.
197
201
  ```
@@ -203,6 +207,11 @@ log
203
207
  Display the current stack as a tree with branch numbers,
204
208
  PR info, and descriptions. This is the default command.
205
209
 
210
+ If the current branch isn't tracked yet, log inspects its ancestry
211
+ and points at the next step: if it sits on top of an existing stack,
212
+ `gh-stack submit` to add it; if it's stacked on untracked branches,
213
+ submit to create the stack and open PRs; if it's on trunk, `init`.
214
+
206
215
  update-prs [--force]
207
216
  Refresh the "📚 Stacked on" visualization block in every PR
208
217
  description for the current stack. PRs whose rendered block is
@@ -299,7 +308,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
299
308
 
300
309
  ```
301
310
  .git/.gh-stack/
302
- current hint: last-active stack name
311
+ current derived stack of the current branch (self-heals on read)
303
312
  active/<stack>.json topology of a live stack (ordered branches, base, description)
304
313
  archived/<stack>.json merged/closed stacks
305
314
  deleted/<stack>.json tombstones (recoverable)
@@ -337,8 +346,10 @@ any drift. Why this shape:
337
346
  - **A stack can't silently vanish** — lifecycle transitions are file moves
338
347
  (`active/ → archived/ → deleted/`), and a stale stack file is tombstoned, never
339
348
  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.
349
+ - **No `current_stack` drift** — the stack you're on is derived from the branch
350
+ you've checked out (its own membership, or the nearest tracked ancestor for a
351
+ new branch stacked on top), re-resolved and self-healed on every read, so a
352
+ written pointer can never contradict reality.
342
353
  - **No all-or-nothing blast radius** — one bad write can't corrupt other stacks.
343
354
  - **Renames heal themselves** — `git branch -m old new` moves the whole
344
355
  `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.11.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
  },
@@ -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
  */