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 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): rebase locally only, and list
169
- the refs left ahead of origin. It overrides --yes for pushes, so
170
- an agent can restack unattended without publishing anything. An
171
- interrupted --no-push run stays local-only on --resume.
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 all
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 (or GH_STACK_NO_PUSH=1): as for restack. Also skips
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 (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.
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 in-flight restack/sync resume state
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.18.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": "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
+ }
@@ -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
+ }