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.
package/README.md CHANGED
@@ -67,6 +67,10 @@ gh-stack status
67
67
  gh-stack split feat-12 # feat-12.. → new stack based on feat-11
68
68
  # ...later, after the original stack merges to main:
69
69
  gh-stack restack --onto main # re-root the split stack onto main
70
+
71
+ # Splice a branch (and its open PR) that was cut from main on its own into the stack
72
+ gh-stack join fix-auth --dry-run # preview the resulting stack + each PR's diff
73
+ gh-stack join fix-auth # make it the new bottom (or --above <branch>)
70
74
  ```
71
75
 
72
76
  ## Commands
@@ -83,24 +87,37 @@ create <branch-name> [--description <desc>]
83
87
  Create a new git branch off the current branch and add it to
84
88
  the stack. The current branch must already be tracked.
85
89
 
86
- add [branch]
90
+ add [branch] [--parent <branch>]
87
91
  Track an EXISTING local branch (default: the current branch) in a
88
92
  stack — without pushing or opening a PR. Walks local ancestry: if the
89
93
  branch sits on top of an existing stack it's adopted into it (parent
90
94
  auto-detected); otherwise a new stack is created from the chain.
91
95
 
96
+ --parent <branch>: stack it directly on <branch> (which it must
97
+ already be based on), tracking <branch> first if needed. Use this
98
+ for a branch with no commits of its own yet — it sits AT its
99
+ parent's tip, so ancestry can't tell which is the parent and it
100
+ would otherwise be rooted at trunk (init/add warn when this
101
+ happens). It also re-homes an already-tracked branch, but only
102
+ when it's alone in its stack and has no PR.
103
+
92
104
  Use this for a WIP branch you've created with `git checkout -b` but
93
105
  aren't ready to submit. It fills the gap between creating a branch and
94
106
  `submit`: until a branch is tracked, `sync`/`restack`/navigation don't
95
107
  know about it, and `add` is how you track it without publishing. (When
96
108
  you do run `submit`, it auto-tracks too — `add` is the no-push path.)
97
109
 
98
- submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
110
+ submit [-d|--draft|--no-draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--json] [--dry-run]
99
111
  Push all downstack branches to GitHub, create PRs for branches
100
112
  that don't have them, number each PR title with its stack position
101
113
  `(N/M)`, and update all PR descriptions with stack visualization.
102
114
  Idempotent — safe to run repeatedly.
103
115
 
116
+ --draft creates new PRs as drafts; --no-draft creates them ready for
117
+ review. Draft is create-time only: submit never changes the draft
118
+ state of an existing PR. A repo can make drafts the default with
119
+ `[submit] draft = true` in .gh-stack.toml (see Configuration).
120
+
104
121
  --restack: after submitting, restack the branches above the current
105
122
  one onto it. submit covers DOWNSTACK (trunk → current); restack covers
106
123
  UPSTACK (the children), so together they sync the whole stack from
@@ -123,6 +140,24 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
123
140
  let an agent or script regenerate a PR's title/description safely.
124
141
  --body-file reads the body from a file. In --yes mode without --title,
125
142
  titles are auto-generated from branch names.
143
+
144
+ --json prints the result as one JSON document on stdout, on every
145
+ exit, so a script or agent gets the PR it just created without a
146
+ follow-up `gh pr view`:
147
+ { stack, current_branch,
148
+ branches: [{ branch, parent, pr, url, title, action, pushed,
149
+ draft, error? }],
150
+ error: null | { message } }
151
+ branches is the submit scope (trunk → current) in stack order. action
152
+ is created (new PR), updated (pushed, or base/title/description
153
+ edited), unchanged, or failed (see the entry's error). draft is
154
+ true/false for PRs created this run, null otherwise. The top-level
155
+ error is set when the run stopped early (bad flags, nothing to
156
+ submit, a push failure): branches then lists what happened before
157
+ it, and stack/current_branch may be null. Exits 1 if error is set or
158
+ any branch failed. Logs go to stderr, plain and uncolored. Implies
159
+ --no-edit; can't be combined with --dry-run or --restack (run
160
+ `gh-stack restack` separately).
126
161
  ```
127
162
 
128
163
  ### Stack Navigation
@@ -152,15 +187,69 @@ ls
152
187
  ### Stack Management
153
188
 
154
189
  ```
155
- restack [--resume] [--dry-run] [--verbose]
190
+ restack [--resume | --fresh] [--dry-run] [--no-push|--push] [--overwrite-remote] [--verbose]
156
191
  Rebase the current branch and all descendants onto their parents.
157
192
  Uses metadata snapshots to recover the correct rebase base even
158
193
  after a parent's history has been rewritten.
159
194
 
195
+ --no-push (or GH_STACK_NO_PUSH=1, or `[restack] push = false` in
196
+ config): rebase locally only, and list the refs left ahead of
197
+ origin. It overrides --yes for pushes, so an agent can restack
198
+ unattended without publishing anything. --push pushes even when the
199
+ env var or a config default says not to. --resume keeps the push
200
+ decision of the run it resumes (an interrupted local-only run stays
201
+ local) unless the resume itself passes --push or --no-push.
202
+
160
203
  On conflict:
161
204
  git rebase --continue
162
205
  gh-stack restack --resume
163
206
 
207
+ Interrupted runs: restack and sync save their progress (and a log
208
+ of every rebase and push) in .git/.gh-stack/restack-state.json
209
+ after each step, until the last push. `--resume` continues from
210
+ there: after a conflict, a kill, or failed pushes. If the run had
211
+ already finished, it prints the run's log and exits 0. A bare
212
+ `restack` or `sync` with an interrupted run pending asks whether to
213
+ resume it (with --yes it resumes) instead of starting a different
214
+ run on top of it. `--fresh` discards the interrupted run and starts
215
+ over. `git rebase --abort` ends the rebase, not the run: a resume
216
+ runs that rebase again (so does one killed before git started it),
217
+ and earlier branches' failed or held pushes stay queued. To abandon
218
+ the run, use `--fresh`. A state file from an older gh-stack is
219
+ refused with that same pointer, never upgraded. `merge` and `join`
220
+ refuse to run while a restack/sync is pending, and `undo` discards
221
+ the run it rolls back.
222
+
223
+ A branch is never pushed onto a parent that isn't on origin at the
224
+ commit the branch sits on (the parent's push failed or was declined,
225
+ or it has unpushed commits): its PR would be diffed against the
226
+ parent's old history. It's held back, the run exits 1 listing the
227
+ refs that aren't in sync, and `--resume` retries the pushes, parents
228
+ first.
229
+
230
+ Commits only on origin: before rewriting anything, restack and sync
231
+ check each branch's copy on origin. If it has commits the local
232
+ branch never had, e.g. the squash commit `merge --collapse` adds to
233
+ a parent PR's branch on GitHub, rewriting would drop them from the
234
+ PR, so the run stops, names them, and prints the pull or
235
+ cherry-pick that brings them in. Earlier versions of local commits
236
+ left on origin by a --no-push or interrupted run (found in the
237
+ branch's reflog or a snapshot, or patch-equivalent) don't count, and
238
+ neither do commits from origin's main (GitHub's "Update branch").
239
+ A branch whose origin copy couldn't be checked at all (not fetched,
240
+ or git failed) is treated the same way, never as clean. Interactive
241
+ runs ask; --yes stops. --overwrite-remote rewrites anyway, dropping
242
+ them. Sync skips bottom branches whose PRs already merged: it
243
+ advances past them rather than rewriting them. When an advance past
244
+ a merged bottom can't push the new bottom, its PR keeps its old base
245
+ until a later push of that branch lands, which retargets it.
246
+
247
+ Every gh-stack force-push (restack, sync, merge, submit, join) uses
248
+ --force-with-lease --force-if-includes: git refuses to overwrite an
249
+ origin tip you fetched but never brought into the local branch, and
250
+ gh-stack explains the refusal. A tip the check above cleared is
251
+ pushed with an explicit lease on exactly that commit instead.
252
+
164
253
  (alias: rebase)
165
254
 
166
255
  restack --onto <ref>
@@ -169,9 +258,9 @@ restack --onto <ref>
169
258
  onto main once the parent stack has merged — only the stack's own
170
259
  commits replay onto the new base.
171
260
 
172
- sync [--dry-run]
173
- Fetch main, rebase the base branch onto main, then restack all
174
- children. Snapshots every branch's pre-sync tip so children can be
261
+ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
262
+ Fetch main, rebase the base branch onto main, push it, then restack
263
+ all children (every branch above it, in one resumable run). Snapshots every branch's pre-sync tip so children can be
175
264
  correctly rebased even though their parent's history was rewritten.
176
265
  If the bottom PR(s) already merged on main outside gh-stack (e.g.
177
266
  the GitHub web UI), sync detects this and advances the stack past
@@ -179,7 +268,13 @@ sync [--dry-run]
179
268
  own commits replay — instead of replaying the now-squashed commits
180
269
  and conflicting.
181
270
 
182
- merge [--dry-run] [-d|--delete-branch] [--collapse]
271
+ --no-push / --push (and their defaults): as for restack. Also skips
272
+ retargeting a PR's base after advancing past a merged bottom;
273
+ the next `submit` pushes and retargets together. --resume and
274
+ --fresh: as for restack (`sync --resume` is `restack --resume`).
275
+ Commits only on origin / --overwrite-remote: as for restack.
276
+
277
+ merge [--dry-run] [-d|--delete-branch] [--collapse] [--skip-collapse-check]
183
278
  merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
184
279
  merge --base [--dry-run] [-d|--delete-branch]
185
280
  Default (top-down collapse): squash-merge the whole stack top-down
@@ -195,6 +290,26 @@ merge --base [--dry-run] [-d|--delete-branch]
195
290
  resolve with `gh-stack sync`, then re-run), while merely waiting on
196
291
  pending checks is fine (auto-merge gates on them).
197
292
 
293
+ Collapse check: before the base PR goes to main (and at the end of
294
+ --collapse), merge checks that each collapsed child's squash commit
295
+ is still in what it was squashed into: the base branch on origin,
296
+ or the parent PR's head when it merged. A rebased copy (same
297
+ patch-id) counts, and so does a base tree holding every file the
298
+ squash commit wrote. It fails closed:
299
+ - proven missing (the base has every file the squash commit
300
+ touched exactly as before it): merge stops (exit 2), names the
301
+ PR, and prints how to put the commit back (via its patch from
302
+ GitHub when the commit itself can't be fetched);
303
+ - anything it can't establish (a GitHub or fetch failure, a
304
+ shallow clone, content that matches neither version): the PR is
305
+ UNVERIFIED, not called missing. Interactive runs ask (default
306
+ stop, with a retry when the failure looks transient); --yes
307
+ stops (exit 2) with the checks to run by hand.
308
+ `merge --base` runs the same check when the base PR carries PRs
309
+ collapsed into it by an earlier `merge --collapse` (found from
310
+ GitHub: merged PRs whose base was their stack parent).
311
+ --skip-collapse-check turns the check off.
312
+
198
313
  --approved Collapse only the approved run. Walks approval from the
199
314
  BASE up and stops at the first PR that isn't approved
200
315
  (contiguous — an unapproved PR4 caps the run at PR3 even
@@ -229,6 +344,59 @@ split [<branch>] [--name <name>]
229
344
  tip. Once the original stack merges, re-root the new stack with
230
345
  `gh-stack restack --onto main`.
231
346
 
347
+ join <branch> [--above <branch>] [--dry-run] [--push|--no-push] [--verbose]
348
+ Splice an existing branch — usually one that already has a PR —
349
+ into a stack. Without --above it becomes the new bottom of the
350
+ current stack; with --above <branch> it goes between <branch> and
351
+ its children (--above main = the bottom). The <branch> named by
352
+ --above picks the stack, so that form works from anywhere.
353
+
354
+ The incoming branch is synced FIRST: only its own commits (those
355
+ above its fork point with main) replay onto the current tip of its
356
+ new parent. Otherwise a branch that's behind main would carry the
357
+ main commits it was missing into every child's diff once they're
358
+ restacked onto it. Then the branches above it are restacked onto it
359
+ (restack's own loop), each branch is pushed with --force-with-lease,
360
+ and the PR bases are retargeted on GitHub (the incoming PR onto its
361
+ new parent, each re-parented child PR onto the incoming branch).
362
+ Pushing follows the same setting as restack: --no-push,
363
+ GH_STACK_NO_PUSH, or `[restack] push = false` in .gh-stack.toml or
364
+ the user config keeps the join local; --push overrides them.
365
+ An open PR for the incoming branch is adopted into the metadata.
366
+
367
+ A stack named after its old bottom branch is renamed after the new
368
+ bottom (the old stack file is tombstoned under deleted/); a custom
369
+ name, or a name already used by another active or archived stack,
370
+ is kept. A branch alone in its own one-branch stack (what `submit`
371
+ makes of a standalone PR) is absorbed; a branch in a larger stack
372
+ is refused.
373
+
374
+ Without --no-push the incoming branch must already be on origin
375
+ (`git push -u origin <branch>` first), and if its push is declined
376
+ or rejected, join stops before touching anything above it. A branch
377
+ above the insertion point that was cut from an older, since-
378
+ rewritten version of the incoming branch is refused, with the
379
+ `git rebase --onto` that drops its stale copies. (A child cut from
380
+ an earlier tip that was only appended to is handled automatically.)
381
+ Unknown options are an error.
382
+
383
+ --dry-run Show the resulting stack and, for every affected PR,
384
+ its projected commits / files changed vs its new parent,
385
+ plus how far the incoming branch is behind main. Changes
386
+ nothing (uses the last fetch of origin/main).
387
+ --no-push As for restack: rebase and update metadata locally, and
388
+ leave origin and the PR bases alone. The next `submit`
389
+ pushes and retargets together.
390
+
391
+ Takes a snapshot first (`gh-stack undo` restores every branch,
392
+ including the incoming one). Refuses on a dirty tree, and when the
393
+ branches above the insertion point need a restack first. On a
394
+ conflict syncing the incoming branch the stack is left unchanged:
395
+ resolve, `git rebase --continue`, and re-run the same join (or
396
+ `git rebase --abort`). A conflict restacking the branches above it
397
+ resumes like restack: `git rebase --continue`, then
398
+ `gh-stack restack --resume`.
399
+
232
400
  rename [<old-name>] <new-name>
233
401
  Rename a tracked branch (runs `git branch -m`) and re-key the stack
234
402
  topology so it stays tracked, re-parenting any children that pointed
@@ -266,9 +434,13 @@ log
266
434
 
267
435
  update-prs [--force]
268
436
  Refresh the "📚 Stacked on" visualization block in every PR
269
- description for the current stack. PRs whose rendered block is
270
- unchanged are skipped; --force rewrites all of them. (submit does
271
- this automatically — use this to refresh without pushing.)
437
+ description for the current stack. Each PR's live description is
438
+ checked, so a block removed by an outside edit (`gh pr edit`, the
439
+ GitHub UI) is restored; PRs already showing the current block are
440
+ skipped, and --force rewrites all of them. A tracked branch with no
441
+ recorded PR is looked up on GitHub first, so a PR opened with
442
+ `gh pr create` is adopted. (submit refreshes the block too, but
443
+ compares against what it last wrote — use update-prs to repair.)
272
444
 
273
445
  stacks [--current] [--json]
274
446
  List all stacks and their topology. Read-only and network-free —
@@ -292,6 +464,13 @@ undo
292
464
  archive [--restore <name>]
293
465
  List archived stacks by default, or restore one by name.
294
466
 
467
+ config [--json]
468
+ Show every configurable flag default, its resolved value, and where
469
+ it came from (flag/env/repo config/user config/built-in), plus the
470
+ config file paths. Answers "why did this PR open as a draft?" in
471
+ one command. Works outside a git repo too (user and env layers
472
+ only). Read-only; edit the TOML files directly.
473
+
295
474
  doctor
296
475
  Migrate old (v2) metadata to the v3 layout, reconcile git branch
297
476
  config against the topology files, and flag stacks whose base
@@ -306,9 +485,21 @@ learn [--skill] [--harness <name>] [--global] [--force]
306
485
  Claude Code <root>/.claude/skills/using-gh-stack/SKILL.md
307
486
  Codex <root>/.codex/skills/using-gh-stack/SKILL.md
308
487
  Cursor <root>/.cursor/skills/using-gh-stack/SKILL.md
309
- --harness claude | codex | cursor (skips the interactive prompt)
488
+ --harness claude | codex | cursor | all, or a comma list such as
489
+ claude,codex (skips the interactive prompt). Each target
490
+ is checked and installed on its own.
310
491
  --global Install under ~/.<harness>/ instead of the project root
311
- --force Overwrite an existing skill file without confirming
492
+ --force Overwrite an existing skill file without confirming, and
493
+ write through a symlinked skill dir or SKILL.md
494
+ If the skill dir or SKILL.md is a symlink (say, into a skills repo
495
+ you keep under version control), the install refuses and names the
496
+ link target. Only --force writes through it; --yes / GH_STACK_YES
497
+ does not. A dangling link is never written through: remove it to
498
+ install there. Harnesses that resolve to the same SKILL.md (a linked
499
+ skill dir or a shared skills/ dir) are written once and reported as
500
+ installed via the first. Cancelling an overwrite prompt lists what
501
+ was installed and what was not attempted, and exits non-zero. The
502
+ closing refresh hint repeats the --global and --harness you used.
312
503
  ```
313
504
 
314
505
  ## Global Options
@@ -322,14 +513,58 @@ learn [--skill] [--harness <name>] [--global] [--force]
322
513
 
323
514
  `--plain` is auto-enabled when `--yes` / `GH_STACK_YES=1` is set, so agents get clean, easily-filtered output by default. Use `--plain` alone if you want plain output but still interactive prompts.
324
515
 
516
+ If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt it would have shown, instead of waiting for an answer that can't arrive. Set `GH_STACK_YES=1` (or pass `--yes`) in that context.
517
+
325
518
  ## Environment Variables
326
519
 
327
520
  ```
328
521
  GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
329
522
  GH_STACK_PLAIN=1 Plain output (same as --plain)
523
+ GH_STACK_NO_PUSH=1 restack/sync never push (same as --no-push; wins over --yes; 0 = push)
524
+ GH_STACK_DRAFT=1 submit creates new PRs as drafts (same as --draft; 0 = ready)
525
+ (both accept 1/0, true/false, yes/no; anything else is an error)
330
526
  GH_STACK_NO_COLOR=1 Disable colored output
331
527
  ```
332
528
 
529
+ ## Configuration
530
+
531
+ Flags that encode a repo's conventions can get a default, so nobody has to
532
+ remember to type them:
533
+
534
+ ```toml
535
+ # .gh-stack.toml at the repo root (commit it), or
536
+ # ~/.config/gh-stack/config.toml for personal defaults (honors XDG_CONFIG_HOME)
537
+ [submit]
538
+ draft = true # new PRs open as drafts; --no-draft overrides for one PR
539
+
540
+ [restack]
541
+ push = false # restack and sync stay local; --push overrides
542
+ ```
543
+
544
+ Precedence, highest first: **CLI flag > env var > repo config > user config >
545
+ built-in default**. `gh-stack config` prints each resolved value and its
546
+ source. When a default (rather than a flag) decides draft or push, the command
547
+ says so in its output.
548
+
549
+ | Key | Flags | Env | Built-in |
550
+ | ---------------- | ------------------------ | ----------------------------- | -------- |
551
+ | `[submit] draft` | `--draft` / `--no-draft` | `GH_STACK_DRAFT` | `false` |
552
+ | `[restack] push` | `--push` / `--no-push` | `GH_STACK_NO_PUSH` (inverted) | `true` |
553
+
554
+ `[restack] push` governs `restack` and `sync` only. `merge` and
555
+ `submit --restack` always push the branches they restack: publishing is their
556
+ job, and child PRs left stale on GitHub would be worse than any default. A
557
+ `restack --resume` keeps the push decision of the run it resumes unless you
558
+ pass `--push` or `--no-push`.
559
+
560
+ Unknown keys print a warning and are ignored. Malformed TOML, an unreadable
561
+ config file, a non-boolean value for a known key, or an env value other than
562
+ `1/0`, `true/false`, `yes/no` is an error naming the file or variable. It is
563
+ reported before the command changes anything, and a command given an explicit
564
+ flag (`--draft`, `--push`, ...) never reads the config for that setting. `--yes` can't be set in a config file:
565
+ auto-confirming destructive operations has to be asked for per run, with
566
+ `--yes` or `GH_STACK_YES=1`.
567
+
333
568
  ## How It Works
334
569
 
335
570
  ### Smart Init
@@ -397,7 +632,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
397
632
  archived/<stack>.json merged/closed stacks
398
633
  deleted/<stack>.json tombstones (recoverable)
399
634
  snapshots/<ts>__<stack>.json append-only, retained per-stack
400
- restack-state.json in-flight restack/sync resume state
635
+ restack-state.json the last restack/sync/join run: resume point + action log
401
636
  ```
402
637
 
403
638
  A single `active/<stack>.json` looks like:
@@ -506,6 +741,7 @@ and gotchas straight from the binary:
506
741
  # Teach an agent the current API (always in sync with the installed version)
507
742
  gh-stack learn # print the skill to stdout
508
743
  gh-stack learn --skill --harness claude # install it for Claude Code
744
+ gh-stack learn --skill -g --harness all # every harness, in your home dir
509
745
  ```
510
746
 
511
747
  ```bash
@@ -518,7 +754,14 @@ gh-stack submit -n # Or auto-generate titles
518
754
  gh-stack sync
519
755
  gh-stack restack
520
756
 
757
+ # Rebase without publishing (e.g. before a human approves the push)
758
+ GH_STACK_NO_PUSH=1 gh-stack restack
759
+
760
+ # Check which defaults apply here (.gh-stack.toml, user config, env)
761
+ gh-stack config
762
+
521
763
  # Structured output
764
+ gh-stack submit --json -t "Title" --body-file body.md # PR number + URL per branch
522
765
  gh-stack status --json
523
766
  gh-stack status --current --json
524
767
  gh-stack ls
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.17.2",
3
+ "version": "0.19.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -33,7 +33,8 @@
33
33
  },
34
34
  "scripts": {
35
35
  "dev": "bun run src/index.ts",
36
- "build": "bun build --compile src/index.ts --outfile dist/gh-stack",
36
+ "build": "bun build --compile src/index.ts --outfile dist/gh-stack && bun run sign",
37
+ "sign": "[ \"$(uname)\" != Darwin ] || codesign --force --sign - dist/gh-stack",
37
38
  "test": "bun test",
38
39
  "lint": "oxlint src/",
39
40
  "format": "oxfmt --write src/",
@@ -50,17 +51,17 @@
50
51
  ]
51
52
  },
52
53
  "devDependencies": {
53
- "@types/bun": "latest",
54
+ "@types/bun": "1.3.10",
54
55
  "husky": "^9.1.7",
55
- "lint-staged": "^16.3.3",
56
- "oxfmt": "^0.40.0",
57
- "oxlint": "^1.55.0"
56
+ "lint-staged": "^17.6.0",
57
+ "oxfmt": "^0.74.0",
58
+ "oxlint": "^1.89.0"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "typescript": "^5"
61
62
  },
62
63
  "dependencies": {
63
- "@clack/prompts": "^1.1.0",
64
+ "@clack/prompts": "^1.8.1",
64
65
  "picocolors": "^1.1.1"
65
66
  }
66
- }
67
+ }
@@ -13,7 +13,7 @@ import pc from "picocolors";
13
13
  import * as git from "../lib/git.ts";
14
14
  import { gateLegacyMetadata } from "../lib/safety.ts";
15
15
  import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
16
- import { resolveOrCreateStack } from "../lib/chain.ts";
16
+ import { colocatedBranches, resolveOrCreateStack } from "../lib/chain.ts";
17
17
  import type { StackMetadata } from "../types.ts";
18
18
 
19
19
  export default async function add(args: string[]): Promise<void> {
@@ -22,26 +22,40 @@ export default async function add(args: string[]): Promise<void> {
22
22
  gh-stack add — track an existing local branch in a stack (no push, no PR)
23
23
 
24
24
  USAGE
25
- gh-stack add [branch]
25
+ gh-stack add [branch] [--parent <branch>]
26
26
 
27
27
  Registers the branch (default: the current branch) into a stack so sync,
28
28
  restack, and navigation work before you've submitted it. Walks local ancestry:
29
29
  if the branch sits on top of an existing stack it's adopted into that stack;
30
30
  otherwise a new stack is created from the chain, rooted at the trunk.
31
31
 
32
+ OPTIONS
33
+ --parent <branch> Stack the branch directly on <branch> instead of detecting
34
+ it (which must already be based on it). Needed when the
35
+ branch has no commits of its own yet, since it then sits AT
36
+ its parent's tip and ancestry can't tell them apart.
37
+ Also re-homes an already-tracked branch, when it's alone in
38
+ its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
39
+
32
40
  Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
33
41
  you're ready to publish.
34
42
 
35
43
  EXAMPLES
36
44
  gh-stack add Track the current branch
37
45
  gh-stack add my-wip-branch Track a specific local branch
46
+ gh-stack add --parent feat-1 Track the current branch on top of feat-1
38
47
  `);
39
48
  return;
40
49
  }
41
50
 
42
51
  await gateLegacyMetadata();
43
52
 
44
- const positional = args.find((a) => !a.startsWith("-"));
53
+ let parentFlag: string | undefined;
54
+ let positional: string | undefined;
55
+ for (let i = 0; i < args.length; i++) {
56
+ if (args[i] === "--parent" && args[i + 1]) parentFlag = args[++i];
57
+ else if (!args[i]!.startsWith("-")) positional ??= args[i];
58
+ }
45
59
  const branch = positional ?? (await git.currentBranch());
46
60
 
47
61
  if (!(await git.localBranchExists(branch))) {
@@ -65,9 +79,15 @@ EXAMPLES
65
79
  stacks: {},
66
80
  };
67
81
 
82
+ if (parentFlag) {
83
+ await addUnderParent(meta, branch, parentFlag, trunk);
84
+ return;
85
+ }
86
+
68
87
  const already = findStackForBranch(meta, branch);
69
88
  if (already) {
70
89
  p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
90
+ await warnIfColocated(branch, trunk);
71
91
  return;
72
92
  }
73
93
 
@@ -100,7 +120,115 @@ EXAMPLES
100
120
  for (const b of resolution.addedBranches) {
101
121
  console.log(` ${pc.green("+")} ${b}`);
102
122
  }
123
+ if (resolution.created && resolution.chain.length === 1) await warnIfColocated(branch, trunk);
124
+
125
+ p.outro(
126
+ `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
127
+ );
128
+ }
103
129
 
130
+ /**
131
+ * A branch with no commits of its own sits AT its parent's tip, so ancestry
132
+ * can't find the parent and it gets rooted at trunk. Say so, and how to fix it,
133
+ * rather than leaving a stack that `sync` would later rebase onto trunk —
134
+ * replaying the parent's commits. (#37)
135
+ */
136
+ export async function warnIfColocated(branch: string, trunk: string): Promise<void> {
137
+ const same = await colocatedBranches(branch, trunk);
138
+ if (same.length === 0) return;
139
+ p.log.warn(
140
+ `${pc.yellow(branch)} has no commits of its own — it shares its tip with ${same.map((b) => pc.yellow(b)).join(", ")}, so it was rooted at ${trunk}.\n` +
141
+ ` If it belongs on one of those, re-home it:\n` +
142
+ same.map((b) => ` ${pc.green(`gh-stack add ${branch} --parent ${b}`)}`).join("\n"),
143
+ );
144
+ }
145
+
146
+ /**
147
+ * `add --parent`: stack `branch` directly on `parent`, tracking `parent` first
148
+ * (from its own ancestry) if needed. An already-tracked branch is re-homed only
149
+ * when that can't lose anything: it's alone in its stack and has no PR — the
150
+ * shape a mistaken `init` at trunk leaves behind. Metadata-only; the branch
151
+ * must already be based on `parent`. (#37)
152
+ */
153
+ async function addUnderParent(
154
+ meta: StackMetadata,
155
+ branch: string,
156
+ parent: string,
157
+ trunk: string,
158
+ ): Promise<void> {
159
+ if (parent === branch) {
160
+ p.cancel(`A branch can't be its own parent.`);
161
+ process.exit(1);
162
+ }
163
+ if (parent === trunk || parent === "main" || parent === "master") {
164
+ p.cancel(
165
+ `--parent is for stacking on a feature branch. To root ${pc.yellow(branch)} at ${trunk}, run ${pc.green("gh-stack add")} without --parent.`,
166
+ );
167
+ process.exit(1);
168
+ }
169
+ if (!(await git.localBranchExists(parent))) {
170
+ p.cancel(`Branch ${pc.yellow(parent)} doesn't exist locally.`);
171
+ process.exit(1);
172
+ }
173
+ if (!(await git.isAncestor(parent, branch))) {
174
+ p.cancel(
175
+ `${pc.yellow(branch)} isn't based on ${pc.yellow(parent)} — rebase it first:\n\n ${pc.green(`git rebase ${parent} ${branch}`)}`,
176
+ );
177
+ process.exit(1);
178
+ }
179
+
180
+ const oldStackName = findStackForBranch(meta, branch);
181
+ const oldBranch = oldStackName ? meta.stacks[oldStackName]!.branches[branch]! : null;
182
+
183
+ if (oldStackName && oldBranch) {
184
+ if (oldBranch.parent === parent) {
185
+ p.log.info(`${pc.yellow(branch)} is already stacked on ${pc.yellow(parent)}.`);
186
+ return;
187
+ }
188
+ const oldStack = meta.stacks[oldStackName]!;
189
+ const alone = Object.keys(oldStack.branches).length === 1;
190
+ if (!alone || oldBranch.pr != null) {
191
+ p.cancel(
192
+ `${pc.yellow(branch)} is already tracked in stack ${pc.yellow(oldStackName)}` +
193
+ (oldBranch.pr != null ? ` with PR #${oldBranch.pr}` : " alongside other branches") +
194
+ ` — re-homing it could strand them, so add --parent won't move it.`,
195
+ );
196
+ process.exit(1);
197
+ }
198
+ }
199
+
200
+ // Track the parent first, from its own ancestry, if it isn't yet. Resolved
201
+ // before dropping the branch's old stack so a failure leaves nothing changed.
202
+ let targetStackName = findStackForBranch(meta, parent);
203
+ const adoptedParents: string[] = [];
204
+ if (!targetStackName) {
205
+ try {
206
+ const res = await resolveOrCreateStack(meta, parent, trunk);
207
+ targetStackName = res.stackName;
208
+ adoptedParents.push(...res.addedBranches);
209
+ } catch (err) {
210
+ p.cancel((err as Error).message);
211
+ process.exit(1);
212
+ }
213
+ }
214
+
215
+ // Drop the throwaway one-branch stack. Deleted, not archived: restoring it
216
+ // would track the branch in two stacks at once.
217
+ if (oldStackName && oldStackName !== targetStackName) delete meta.stacks[oldStackName];
218
+
219
+ const target = meta.stacks[targetStackName]!;
220
+ target.branches[branch] = { ...oldBranch, parent };
221
+ target.last_branch = branch;
222
+ meta.current_stack = targetStackName;
223
+ await writeMetadata(meta);
224
+
225
+ p.intro(pc.cyan("Add to Stack"));
226
+ for (const b of adoptedParents) console.log(` ${pc.green("+")} ${b}`);
227
+ p.log.success(
228
+ oldStackName
229
+ ? `Re-homed ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)} ${pc.dim(`(removed one-branch stack ${oldStackName})`)}`
230
+ : `Added ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)}`,
231
+ );
104
232
  p.outro(
105
233
  `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
106
234
  );