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 +256 -13
- package/package.json +9 -8
- package/src/commands/add.ts +131 -3
- package/src/commands/config.ts +133 -0
- package/src/commands/init.ts +4 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +263 -5
- package/src/commands/restack.ts +954 -211
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +145 -145
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +87 -15
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +45 -9
- package/src/lib/chain.ts +20 -0
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +98 -22
- package/src/lib/github.ts +83 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +64 -14
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/using-gh-stack.md +140 -5
- package/src/types.ts +62 -2
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
|
|
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
|
-
|
|
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.
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
54
|
+
"@types/bun": "1.3.10",
|
|
54
55
|
"husky": "^9.1.7",
|
|
55
|
-
"lint-staged": "^
|
|
56
|
-
"oxfmt": "^0.
|
|
57
|
-
"oxlint": "^1.
|
|
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
|
|
64
|
+
"@clack/prompts": "^1.8.1",
|
|
64
65
|
"picocolors": "^1.1.1"
|
|
65
66
|
}
|
|
66
|
-
}
|
|
67
|
+
}
|
package/src/commands/add.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|