gh-stack 0.18.0 → 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 +232 -16
- package/package.json +9 -8
- package/src/commands/config.ts +133 -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 +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +140 -154
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +29 -4
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- 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 +124 -4
- package/src/types.ts +62 -4
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
|
|
@@ -103,12 +107,17 @@ add [branch] [--parent <branch>]
|
|
|
103
107
|
know about it, and `add` is how you track it without publishing. (When
|
|
104
108
|
you do run `submit`, it auto-tracks too — `add` is the no-push path.)
|
|
105
109
|
|
|
106
|
-
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]
|
|
107
111
|
Push all downstack branches to GitHub, create PRs for branches
|
|
108
112
|
that don't have them, number each PR title with its stack position
|
|
109
113
|
`(N/M)`, and update all PR descriptions with stack visualization.
|
|
110
114
|
Idempotent — safe to run repeatedly.
|
|
111
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
|
+
|
|
112
121
|
--restack: after submitting, restack the branches above the current
|
|
113
122
|
one onto it. submit covers DOWNSTACK (trunk → current); restack covers
|
|
114
123
|
UPSTACK (the children), so together they sync the whole stack from
|
|
@@ -131,6 +140,24 @@ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file
|
|
|
131
140
|
let an agent or script regenerate a PR's title/description safely.
|
|
132
141
|
--body-file reads the body from a file. In --yes mode without --title,
|
|
133
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).
|
|
134
161
|
```
|
|
135
162
|
|
|
136
163
|
### Stack Navigation
|
|
@@ -160,20 +187,69 @@ ls
|
|
|
160
187
|
### Stack Management
|
|
161
188
|
|
|
162
189
|
```
|
|
163
|
-
restack [--resume] [--dry-run] [--no-push] [--verbose]
|
|
190
|
+
restack [--resume | --fresh] [--dry-run] [--no-push|--push] [--overwrite-remote] [--verbose]
|
|
164
191
|
Rebase the current branch and all descendants onto their parents.
|
|
165
192
|
Uses metadata snapshots to recover the correct rebase base even
|
|
166
193
|
after a parent's history has been rewritten.
|
|
167
194
|
|
|
168
|
-
--no-push (or GH_STACK_NO_PUSH=1
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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.
|
|
172
202
|
|
|
173
203
|
On conflict:
|
|
174
204
|
git rebase --continue
|
|
175
205
|
gh-stack restack --resume
|
|
176
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
|
+
|
|
177
253
|
(alias: rebase)
|
|
178
254
|
|
|
179
255
|
restack --onto <ref>
|
|
@@ -182,9 +258,9 @@ restack --onto <ref>
|
|
|
182
258
|
onto main once the parent stack has merged — only the stack's own
|
|
183
259
|
commits replay onto the new base.
|
|
184
260
|
|
|
185
|
-
sync [--dry-run] [--no-push]
|
|
186
|
-
Fetch main, rebase the base branch onto main, then restack
|
|
187
|
-
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
|
|
188
264
|
correctly rebased even though their parent's history was rewritten.
|
|
189
265
|
If the bottom PR(s) already merged on main outside gh-stack (e.g.
|
|
190
266
|
the GitHub web UI), sync detects this and advances the stack past
|
|
@@ -192,11 +268,13 @@ sync [--dry-run] [--no-push]
|
|
|
192
268
|
own commits replay — instead of replaying the now-squashed commits
|
|
193
269
|
and conflicting.
|
|
194
270
|
|
|
195
|
-
--no-push (
|
|
271
|
+
--no-push / --push (and their defaults): as for restack. Also skips
|
|
196
272
|
retargeting a PR's base after advancing past a merged bottom;
|
|
197
|
-
the next `submit` pushes and retargets together.
|
|
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.
|
|
198
276
|
|
|
199
|
-
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
277
|
+
merge [--dry-run] [-d|--delete-branch] [--collapse] [--skip-collapse-check]
|
|
200
278
|
merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
201
279
|
merge --base [--dry-run] [-d|--delete-branch]
|
|
202
280
|
Default (top-down collapse): squash-merge the whole stack top-down
|
|
@@ -212,6 +290,26 @@ merge --base [--dry-run] [-d|--delete-branch]
|
|
|
212
290
|
resolve with `gh-stack sync`, then re-run), while merely waiting on
|
|
213
291
|
pending checks is fine (auto-merge gates on them).
|
|
214
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
|
+
|
|
215
313
|
--approved Collapse only the approved run. Walks approval from the
|
|
216
314
|
BASE up and stops at the first PR that isn't approved
|
|
217
315
|
(contiguous — an unapproved PR4 caps the run at PR3 even
|
|
@@ -246,6 +344,59 @@ split [<branch>] [--name <name>]
|
|
|
246
344
|
tip. Once the original stack merges, re-root the new stack with
|
|
247
345
|
`gh-stack restack --onto main`.
|
|
248
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
|
+
|
|
249
400
|
rename [<old-name>] <new-name>
|
|
250
401
|
Rename a tracked branch (runs `git branch -m`) and re-key the stack
|
|
251
402
|
topology so it stays tracked, re-parenting any children that pointed
|
|
@@ -313,6 +464,13 @@ undo
|
|
|
313
464
|
archive [--restore <name>]
|
|
314
465
|
List archived stacks by default, or restore one by name.
|
|
315
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
|
+
|
|
316
474
|
doctor
|
|
317
475
|
Migrate old (v2) metadata to the v3 layout, reconcile git branch
|
|
318
476
|
config against the topology files, and flag stacks whose base
|
|
@@ -327,9 +485,21 @@ learn [--skill] [--harness <name>] [--global] [--force]
|
|
|
327
485
|
Claude Code <root>/.claude/skills/using-gh-stack/SKILL.md
|
|
328
486
|
Codex <root>/.codex/skills/using-gh-stack/SKILL.md
|
|
329
487
|
Cursor <root>/.cursor/skills/using-gh-stack/SKILL.md
|
|
330
|
-
--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.
|
|
331
491
|
--global Install under ~/.<harness>/ instead of the project root
|
|
332
|
-
--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.
|
|
333
503
|
```
|
|
334
504
|
|
|
335
505
|
## Global Options
|
|
@@ -350,10 +520,51 @@ If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt i
|
|
|
350
520
|
```
|
|
351
521
|
GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
|
|
352
522
|
GH_STACK_PLAIN=1 Plain output (same as --plain)
|
|
353
|
-
GH_STACK_NO_PUSH=1 restack/sync never push (same as --no-push; wins over --yes)
|
|
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)
|
|
354
526
|
GH_STACK_NO_COLOR=1 Disable colored output
|
|
355
527
|
```
|
|
356
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
|
+
|
|
357
568
|
## How It Works
|
|
358
569
|
|
|
359
570
|
### Smart Init
|
|
@@ -421,7 +632,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
|
421
632
|
archived/<stack>.json merged/closed stacks
|
|
422
633
|
deleted/<stack>.json tombstones (recoverable)
|
|
423
634
|
snapshots/<ts>__<stack>.json append-only, retained per-stack
|
|
424
|
-
restack-state.json
|
|
635
|
+
restack-state.json the last restack/sync/join run: resume point + action log
|
|
425
636
|
```
|
|
426
637
|
|
|
427
638
|
A single `active/<stack>.json` looks like:
|
|
@@ -530,6 +741,7 @@ and gotchas straight from the binary:
|
|
|
530
741
|
# Teach an agent the current API (always in sync with the installed version)
|
|
531
742
|
gh-stack learn # print the skill to stdout
|
|
532
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
|
|
533
745
|
```
|
|
534
746
|
|
|
535
747
|
```bash
|
|
@@ -545,7 +757,11 @@ gh-stack restack
|
|
|
545
757
|
# Rebase without publishing (e.g. before a human approves the push)
|
|
546
758
|
GH_STACK_NO_PUSH=1 gh-stack restack
|
|
547
759
|
|
|
760
|
+
# Check which defaults apply here (.gh-stack.toml, user config, env)
|
|
761
|
+
gh-stack config
|
|
762
|
+
|
|
548
763
|
# Structured output
|
|
764
|
+
gh-stack submit --json -t "Title" --body-file body.md # PR number + URL per branch
|
|
549
765
|
gh-stack status --json
|
|
550
766
|
gh-stack status --current --json
|
|
551
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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// gh-stack config — Show the resolved flag defaults and where each came from.
|
|
2
|
+
//
|
|
3
|
+
// Answers "why did this open as a draft?" in one command. Read-only: it never
|
|
4
|
+
// writes a config file (edit the TOML directly).
|
|
5
|
+
import pc from "picocolors";
|
|
6
|
+
import {
|
|
7
|
+
SETTINGS,
|
|
8
|
+
describeSource,
|
|
9
|
+
loadConfigLayers,
|
|
10
|
+
resolveSetting,
|
|
11
|
+
type ConfigFile,
|
|
12
|
+
type ConfigLayers,
|
|
13
|
+
type ConfigSource,
|
|
14
|
+
type SettingName,
|
|
15
|
+
ConfigError,
|
|
16
|
+
} from "../lib/config.ts";
|
|
17
|
+
|
|
18
|
+
const HELP = `
|
|
19
|
+
gh-stack config — Show flag defaults and where each value comes from
|
|
20
|
+
|
|
21
|
+
USAGE
|
|
22
|
+
gh-stack config [--json]
|
|
23
|
+
|
|
24
|
+
Prints every configurable default with its resolved value and source. Values
|
|
25
|
+
resolve, highest first: CLI flag > env var > repo config > user config >
|
|
26
|
+
built-in default. Outside a git repo it shows the user and env layers only.
|
|
27
|
+
|
|
28
|
+
FILES
|
|
29
|
+
.gh-stack.toml Repo root (commit it to share conventions)
|
|
30
|
+
~/.config/gh-stack/config.toml Personal defaults (honors XDG_CONFIG_HOME)
|
|
31
|
+
|
|
32
|
+
[submit]
|
|
33
|
+
draft = true # new PRs open as drafts (--draft / --no-draft)
|
|
34
|
+
|
|
35
|
+
[restack]
|
|
36
|
+
push = false # restack and sync stay local (--push / --no-push)
|
|
37
|
+
# (merge and submit --restack always push)
|
|
38
|
+
|
|
39
|
+
ENVIRONMENT (1/0, true/false, or yes/no)
|
|
40
|
+
GH_STACK_DRAFT submit draft default
|
|
41
|
+
GH_STACK_NO_PUSH restack/sync push default (1 = don't push)
|
|
42
|
+
|
|
43
|
+
--yes is not configurable from a file: auto-confirming destructive operations
|
|
44
|
+
must be asked for per run (--yes or GH_STACK_YES=1).
|
|
45
|
+
|
|
46
|
+
OPTIONS
|
|
47
|
+
--json Output as JSON
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
export interface ConfigReport {
|
|
51
|
+
settings: {
|
|
52
|
+
key: string;
|
|
53
|
+
value: boolean;
|
|
54
|
+
source: ConfigSource;
|
|
55
|
+
origin: string | null;
|
|
56
|
+
flags: string[];
|
|
57
|
+
env: string;
|
|
58
|
+
}[];
|
|
59
|
+
files: { repo: FileInfo | null; user: FileInfo | null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface FileInfo {
|
|
63
|
+
path: string;
|
|
64
|
+
exists: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fileInfo(f: ConfigFile | null): FileInfo | null {
|
|
68
|
+
return f ? { path: f.path, exists: f.exists } : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fileLine(label: string, f: FileInfo | null): string {
|
|
72
|
+
return f
|
|
73
|
+
? ` ${label} ${f.path} ${f.exists ? "" : pc.dim("(not found)")}`.trimEnd()
|
|
74
|
+
: ` ${label} ${pc.dim("(not in a git work tree)")}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build the report from loaded layers. Pure, so it's unit-testable. */
|
|
78
|
+
export function buildConfigReport(layers: ConfigLayers): ConfigReport {
|
|
79
|
+
return {
|
|
80
|
+
settings: (Object.keys(SETTINGS) as SettingName[]).map((name) => {
|
|
81
|
+
const spec = SETTINGS[name];
|
|
82
|
+
const r = resolveSetting(name, [], layers);
|
|
83
|
+
return {
|
|
84
|
+
key: name,
|
|
85
|
+
value: r.value,
|
|
86
|
+
source: r.source,
|
|
87
|
+
origin: r.origin ?? null,
|
|
88
|
+
flags: [...spec.onFlags, ...spec.offFlags],
|
|
89
|
+
env: spec.env.name,
|
|
90
|
+
};
|
|
91
|
+
}),
|
|
92
|
+
files: { repo: fileInfo(layers.repo), user: fileInfo(layers.user) },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default async function config(args: string[]): Promise<void> {
|
|
97
|
+
if (args.includes("--help")) {
|
|
98
|
+
console.log(HELP);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Both steps can throw ConfigError: loading (bad TOML, wrong-typed value)
|
|
103
|
+
// and resolving (a non-boolean env var).
|
|
104
|
+
let report: ConfigReport;
|
|
105
|
+
try {
|
|
106
|
+
const loaded = await loadConfigLayers();
|
|
107
|
+
for (const w of loaded.warnings) console.error(`gh-stack: warning: ${w}`);
|
|
108
|
+
report = buildConfigReport(loaded.layers);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (!(err instanceof ConfigError)) throw err;
|
|
111
|
+
console.error(`gh-stack: ${err.message}`);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.includes("--json")) {
|
|
116
|
+
console.log(JSON.stringify(report, null, 2));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log();
|
|
121
|
+
const width = Math.max(...report.settings.map((s) => s.key.length));
|
|
122
|
+
for (const s of report.settings) {
|
|
123
|
+
const value = s.value ? pc.green("true ") : pc.yellow("false");
|
|
124
|
+
const source = describeSource({ ...s, origin: s.origin ?? undefined });
|
|
125
|
+
console.log(` ${s.key.padEnd(width)} ${value} ${pc.dim(source)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log();
|
|
129
|
+
console.log(pc.dim("Config files:"));
|
|
130
|
+
console.log(fileLine("repo", report.files.repo));
|
|
131
|
+
console.log(fileLine("user", report.files.user));
|
|
132
|
+
console.log();
|
|
133
|
+
}
|