gh-stack 0.15.0 → 0.17.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/src/index.ts CHANGED
@@ -46,8 +46,12 @@ if (parsed.plain) {
46
46
  setPlain(true);
47
47
  }
48
48
 
49
- // Ensure we're in a git repo for all commands
50
- await ensureGitRepo();
49
+ // Ensure we're in a git repo for all commands — except `learn`, which just
50
+ // emits/installs the agent skill and is useful outside any repo (e.g. an agent
51
+ // onboarding before cloning, or `learn --skill --global`).
52
+ if (command !== "learn") {
53
+ await ensureGitRepo();
54
+ }
51
55
 
52
56
  // Route to subcommand — strip global flags from command args
53
57
  const commandArgs = parsed.commandArgs;
@@ -65,6 +69,10 @@ switch (command) {
65
69
  await (await import("./commands/create.ts")).default(commandArgs);
66
70
  break;
67
71
 
72
+ case "add":
73
+ await (await import("./commands/add.ts")).default(commandArgs);
74
+ break;
75
+
68
76
  case "delete":
69
77
  await (await import("./commands/delete.ts")).default(commandArgs);
70
78
  break;
@@ -143,6 +151,10 @@ switch (command) {
143
151
  await (await import("./commands/bottom.ts")).default(commandArgs);
144
152
  break;
145
153
 
154
+ case "learn":
155
+ await (await import("./commands/learn.ts")).default(commandArgs);
156
+ break;
157
+
146
158
  case "--help":
147
159
  case "help":
148
160
  printHelp();
@@ -170,6 +182,7 @@ ${bold("TERMS")}
170
182
  ${bold("CORE WORKFLOW")}
171
183
  ${green("init")} Create a new stack from the current branch
172
184
  ${green("create")} Create a new branch and add to the stack
185
+ ${green("add")} Track the current local branch in a stack ${dim("(no push/PR)")}
173
186
  ${green("submit")} Push branches and create/update PRs
174
187
  ${green("log")} Display current stack tree ${dim("(default)")}
175
188
 
@@ -196,6 +209,7 @@ ${bold("INFO & MAINTENANCE")}
196
209
  ${green("archive")} Manage archived stacks
197
210
  ${green("update-prs")} Refresh stack visualization in PR descriptions
198
211
  ${green("doctor")} Migrate & repair stack metadata
212
+ ${green("learn")} Print the agent skill (--skill installs it)
199
213
 
200
214
  ${bold("GLOBAL OPTIONS")}
201
215
  --yes, -y Skip confirmations ${dim("(for agents/CI)")}
package/src/lib/git.ts CHANGED
@@ -38,6 +38,14 @@ export async function gitDir(): Promise<string> {
38
38
  return result.trim();
39
39
  }
40
40
 
41
+ /**
42
+ * Get the absolute path to the repository's working-tree root.
43
+ */
44
+ export async function repoRoot(): Promise<string> {
45
+ const result = await $`git rev-parse --show-toplevel`.text();
46
+ return result.trim();
47
+ }
48
+
41
49
  /**
42
50
  * Check if working tree is clean (no uncommitted changes).
43
51
  * Refreshes the index first to avoid false positives from stale stat info.
@@ -78,12 +86,25 @@ export async function isRebaseInProgress(): Promise<boolean> {
78
86
  }
79
87
 
80
88
  /**
81
- * Checkout a branch.
89
+ * Checkout a branch. Throws a raw ShellError on failure — only use this where a
90
+ * failure is unexpected (e.g. after ensureCleanWorkingTree). For user-facing
91
+ * branch switching, prefer tryCheckout so a dirty-tree refusal can be rendered
92
+ * as a clean message instead of a stack trace.
82
93
  */
83
94
  export async function checkout(branch: string): Promise<void> {
84
95
  await $`git checkout ${branch}`.quiet();
85
96
  }
86
97
 
98
+ /**
99
+ * Attempt to checkout a branch without throwing. Returns whether it succeeded
100
+ * and git's stderr (which carries the "local changes would be overwritten"
101
+ * message and the offending file list when a dirty tree blocks the switch).
102
+ */
103
+ export async function tryCheckout(branch: string): Promise<{ ok: boolean; stderr: string }> {
104
+ const { exitCode, stderr } = await $`git checkout ${branch}`.quiet().nothrow();
105
+ return { ok: exitCode === 0, stderr: stderr.toString() };
106
+ }
107
+
87
108
  /**
88
109
  * Create and checkout a new branch from the current HEAD.
89
110
  */
@@ -149,6 +170,29 @@ export async function pushStackedBranch(branch: string): Promise<boolean> {
149
170
  return exitCode === 0;
150
171
  }
151
172
 
173
+ /**
174
+ * Read the actual tip sha of every branch on origin in a single `ls-remote`
175
+ * call. This queries the remote directly, so it is **authoritative** — unlike
176
+ * `rev-parse origin/<branch>`, which reads the local remote-tracking ref that a
177
+ * force-push can leave stale (a fresh fetch would correct it). Use this when a
178
+ * correct answer about the remote's real state matters (e.g. the post-restack
179
+ * sync verification — #23). Returns a map of branch name → sha.
180
+ */
181
+ export async function remoteHeads(): Promise<Map<string, string>> {
182
+ const heads = new Map<string, string>();
183
+ const { exitCode, stdout } = await $`git ls-remote --heads origin`.nothrow().quiet();
184
+ if (exitCode !== 0) return heads;
185
+ const prefix = "refs/heads/";
186
+ for (const line of stdout.toString().split("\n")) {
187
+ const tab = line.indexOf("\t");
188
+ if (tab === -1) continue;
189
+ const sha = line.slice(0, tab).trim();
190
+ const ref = line.slice(tab + 1).trim();
191
+ if (sha && ref.startsWith(prefix)) heads.set(ref.slice(prefix.length), sha);
192
+ }
193
+ return heads;
194
+ }
195
+
152
196
  /**
153
197
  * Check if a branch exists on the remote.
154
198
  */
@@ -0,0 +1,7 @@
1
+ // Bun bundles `*.md` files imported with `{ type: "text" }` into the binary as
2
+ // their raw string contents (survives `bun build --compile`). This declaration
3
+ // lets TypeScript type those imports.
4
+ declare module "*.md" {
5
+ const content: string;
6
+ export default content;
7
+ }
package/src/lib/safety.ts CHANGED
@@ -115,6 +115,37 @@ export async function ensureCleanWorkingTree(): Promise<void> {
115
115
  }
116
116
  }
117
117
 
118
+ /**
119
+ * Switch branches for a user-facing navigation command, rendering a clean
120
+ * message (and exiting 1) when git refuses — most commonly because local
121
+ * changes would be overwritten. Avoids leaking a raw ShellError stack trace.
122
+ *
123
+ * Note: this deliberately does NOT pre-check for a clean tree. git happily
124
+ * carries uncommitted changes across a switch when they don't conflict with the
125
+ * target branch, and stack navigation often wants to bring WIP along — so we
126
+ * let git decide and only intervene on an actual refusal.
127
+ */
128
+ export async function checkoutOrExit(branch: string): Promise<void> {
129
+ const { ok, stderr } = await git.tryCheckout(branch);
130
+ if (ok) return;
131
+
132
+ if (stderr.includes("would be overwritten by checkout")) {
133
+ const files = stderr
134
+ .split("\n")
135
+ .filter((line) => line.startsWith("\t"))
136
+ .map((line) => ` ${pc.yellow(line.trim())}`)
137
+ .join("\n");
138
+ p.cancel(
139
+ `Can't switch to ${pc.yellow(branch)} — local changes would be overwritten:\n\n${files}\n\n Commit or stash them first:\n ${pc.green("git stash")} ${pc.dim("(or git commit)")}`,
140
+ );
141
+ } else {
142
+ p.cancel(
143
+ `Can't switch to ${pc.yellow(branch)}:\n\n ${stderr.trim() || "git checkout failed"}`,
144
+ );
145
+ }
146
+ process.exit(1);
147
+ }
148
+
118
149
  /**
119
150
  * Ensure we're not on main.
120
151
  */
@@ -0,0 +1,22 @@
1
+ // Canonical, version-stamped agent skill for gh-stack.
2
+ //
3
+ // The skill text is a plain Markdown file — `using-gh-stack.md` — bundled into
4
+ // the binary at build time via Bun's `type: "text"` import (it survives
5
+ // `bun build --compile`). Editing the skill means editing that .md file; this
6
+ // module only stamps the running version into the `{{VERSION}}` placeholder.
7
+ //
8
+ // It is the SINGLE SOURCE OF TRUTH for "how an agent should drive gh-stack":
9
+ // `gh-stack learn` prints it, `gh-stack learn --skill` installs it. Update the
10
+ // .md whenever a command or flag changes and the next release ships it.
11
+ import skillTemplate from "./using-gh-stack.md" with { type: "text" };
12
+
13
+ export const SKILL_NAME = "using-gh-stack";
14
+
15
+ /**
16
+ * Build the full skill document, stamped with the running gh-stack version so
17
+ * an installed copy always records which release generated it (and can be
18
+ * refreshed with `gh-stack learn --skill`).
19
+ */
20
+ export function skillContent(version: string): string {
21
+ return skillTemplate.replaceAll("{{VERSION}}", version);
22
+ }
@@ -0,0 +1,260 @@
1
+ ---
2
+ name: using-gh-stack
3
+ description: "MUST be used when working with stacked PRs. gh-stack is a CLI tool for managing stacked PR workflows in squash-merge repos (a Graphite alternative). Covers creating stacks, pushing/creating PRs with submit, navigating (up/down/top/bottom), syncing with main, restacking, merging, and agent/CI usage. Triggers on: stacked PR, gh-stack, create stack, submit stack, push stack, restack, sync stack, stack navigation, up down top bottom, PR dependencies, stack visualization, manage PRs, stacked branches."
4
+ ---
5
+
6
+ # Using gh-stack
7
+
8
+ > Generated by `gh-stack learn` from **gh-stack v{{VERSION}}**.
9
+ > This file is produced by the binary — don't hand-edit it. Refresh after
10
+ > upgrading with `gh-stack learn --skill`.
11
+
12
+ `gh-stack` is a CLI for managing **stacked PRs** in squash-merge repositories
13
+ (a Graphite/`gt` alternative). A _stack_ is a chain of dependent branches, each
14
+ its own PR, sitting on top of the trunk (usually `main`).
15
+
16
+ **Install:** `npm install -g gh-stack` (or `bun install -g gh-stack`)
17
+
18
+ ## Terms
19
+
20
+ | Term | Meaning |
21
+ | ------------- | ------------------------------------ |
22
+ | **trunk** | Base branch (usually `main`) |
23
+ | **stack** | A chain of dependent branches |
24
+ | **upstack** | Child branches (depend on current) |
25
+ | **downstack** | Parent branches (current depends on) |
26
+
27
+ ## The current stack follows your current branch
28
+
29
+ There is no "switch stack" state to manage — **gh-stack always operates on the
30
+ stack that contains the branch you're on.** It resolves this from your current
31
+ git branch: the branch's own stack, or (for a new branch built on top of one)
32
+ the nearest tracked ancestor's stack. Every command (`log`, `submit`, `status`,
33
+ `restack`, …) acts on that stack automatically.
34
+
35
+ So to switch stacks, just switch branches — there's nothing gh-stack-specific to
36
+ run:
37
+
38
+ ```bash
39
+ git switch other-feature-branch # any branch on the other stack
40
+ gh-stack log # now shows that stack
41
+ ```
42
+
43
+ Or use the interactive picker when you don't remember the branch names:
44
+
45
+ ```bash
46
+ gh-stack checkout --stack # pick a stack, jump to it (alias: co --stack)
47
+ ```
48
+
49
+ (Plain `gh-stack checkout` picks a _branch within the current stack_; add
50
+ `--stack` to pick a _different stack_.)
51
+
52
+ ## Agent/CI mode — read this first
53
+
54
+ Run non-interactively with `--yes`/`-y` or `GH_STACK_YES=1` (also enables
55
+ `--plain`: no spinners/colors/box-drawing — clean for log capture).
56
+
57
+ ```bash
58
+ export GH_STACK_YES=1 # skip every confirmation, plain output
59
+ ```
60
+
61
+ - **Discover topology without parsing internals:** `gh-stack stacks --json`
62
+ (network-free) or `gh-stack status --json` (adds PR/CI/review state).
63
+ - **Always pass `-t`/`-b` to `submit`** so PRs get real titles/bodies instead
64
+ of generic auto-generated ones (see below).
65
+ - Commands that rebase (`restack`, `sync`, `merge`) **refuse to run on a dirty
66
+ tree** — commit or stash first.
67
+
68
+ ## Core workflow
69
+
70
+ ### 1. Start a stack
71
+
72
+ **From existing branches** (e.g. `main → feat-1 → feat-2`) — go to the TOP;
73
+ `init` walks down and reconstructs the chain automatically:
74
+
75
+ ```bash
76
+ git checkout feat-2
77
+ gh-stack init # detects main → feat-1 → feat-2, adopts existing PRs
78
+ ```
79
+
80
+ **From scratch:**
81
+
82
+ ```bash
83
+ git checkout -b kiliman/first-WEB-1234
84
+ gh-stack init # create stack, add current branch
85
+ gh-stack create kiliman/second-WEB-5678 # new branch stacked on top
86
+ # ...work, commit...
87
+ gh-stack create kiliman/third-WEB-9012 # stack another
88
+ ```
89
+
90
+ `init` options: `--name <stack>`, `--parent <trunk>`, `--description <text>`.
91
+
92
+ **Track a local WIP branch without publishing it — `gh-stack add`.** If you
93
+ created a branch with `git checkout -b` and want to `sync`/`restack` it _before_
94
+ opening a PR, `add` registers it in a stack (network-free — no push, no PR):
95
+
96
+ ```bash
97
+ gh-stack add # track the current branch
98
+ gh-stack add <branch> # track a named branch
99
+ ```
100
+
101
+ It walks local ancestry: a branch sitting on top of an existing stack is adopted
102
+ into it (parent auto-detected); otherwise a new stack is created from the chain.
103
+ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
104
+ tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
105
+ no-publish path.)
106
+
107
+ ### 2. Push & create PRs — `submit`
108
+
109
+ `submit` is the workhorse: **idempotent, scope-aware, self-healing**. It pushes
110
+ changed branches with `--force-with-lease` (skips up-to-date ones), creates
111
+ missing PRs with the correct base, fixes PR bases, and refreshes the stack
112
+ visualization in every PR body.
113
+
114
+ ```bash
115
+ gh-stack submit # push downstack + create/update PRs
116
+ gh-stack submit -d # create new PRs as drafts
117
+ gh-stack submit --dry-run # preview, no side effects
118
+ gh-stack submit --restack # then propagate up to children (see below)
119
+ gh-stack submit -t "<title>" -b "<body>" # explicit title/body for new PRs
120
+ gh-stack submit -t "<title>" --body-file /tmp/body.md
121
+ gh-stack submit -n # auto-generate titles (generic — avoid)
122
+ ```
123
+
124
+ **Agents: provide title + body explicitly.** `--title` implies `--no-edit`
125
+ (no prompts); title/body apply to all new PRs created in scope:
126
+
127
+ ```bash
128
+ gh-stack submit \
129
+ -t "Store raw transcription JSON and WebVTT [WEB-7409]" \
130
+ -b "$(cat <<'EOF'
131
+ ## Summary
132
+ - Store raw transcription JSON alongside pre-rendered WebVTT
133
+
134
+ ## Test plan
135
+ - [ ] Verify both formats persist
136
+ EOF
137
+ )"
138
+ ```
139
+
140
+ **`submit -t`/`-b` also updates an existing PR in place** — pass them while on a
141
+ branch that already has a PR to change just that one (visualization-safe).
142
+
143
+ **`--restack`** chains a restack after submit. `submit` owns DOWNSTACK (trunk →
144
+ current); `restack` owns UPSTACK (the children). Together they're the everyday
145
+ downstack-fix loop in one command: _drop downstack → fix → commit →
146
+ `gh-stack submit --restack`_ pushes the fix AND rebases the children onto it.
147
+
148
+ ### 3. Navigate
149
+
150
+ ```bash
151
+ gh-stack log # stack tree (default command)
152
+ gh-stack ls # numbered branch list
153
+ gh-stack up [n] # toward children (upstack)
154
+ gh-stack down [n] # toward parents (downstack)
155
+ gh-stack top # tip (leaf)
156
+ gh-stack bottom # base (first branch above trunk)
157
+ gh-stack checkout # interactive picker (alias: co)
158
+ ```
159
+
160
+ ### 4. Sync & restack
161
+
162
+ ```bash
163
+ gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
164
+ gh-stack restack # rebase current branch + descendants onto their parents
165
+ gh-stack restack --dry-run
166
+ ```
167
+
168
+ **⚠️ Restack propagates UPWARD from the current branch.** Run it from the
169
+ **lowest branch you changed**, never from the top:
170
+
171
+ ```bash
172
+ gh-stack bottom # go to the changed branch
173
+ # ...edit, commit...
174
+ gh-stack submit # push it
175
+ gh-stack restack # propagates the change UP through children
176
+ ```
177
+
178
+ **Changing multiple branches?** Finish each one fully before moving up —
179
+ `edit → test → commit → push → restack`, then `gh-stack up` and repeat. Don't
180
+ edit several branches then restack once at the end (only the last branch ends
181
+ up pushed).
182
+
183
+ **On rebase conflict:** resolve, `git add <files>`, `git rebase --continue`,
184
+ then `gh-stack restack --resume` to finish the remaining branches.
185
+
186
+ **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
187
+ `sync` detects it and advances the stack past the merged branch (re-rooting onto
188
+ trunk) instead of replaying squashed commits and conflicting.
189
+
190
+ ### 5. Status
191
+
192
+ ```bash
193
+ gh-stack status # PR dashboard: CI, reviews, merge readiness
194
+ gh-stack status --current # current stack only
195
+ gh-stack status --json # structured, for agents
196
+ ```
197
+
198
+ ### 6. Merge & ship
199
+
200
+ ```bash
201
+ gh-stack merge # collapse the WHOLE stack into one squash commit → trunk
202
+ gh-stack merge --approved # collapse only the approved run (base..highest-approved)
203
+ # into one commit; leave the unapproved tail in place
204
+ gh-stack merge --base # land ONLY the bottom PR → trunk, re-root the next branch
205
+ gh-stack merge --dry-run
206
+ ```
207
+
208
+ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, then base → trunk):
209
+
210
+ - **`merge`**: the whole stack is approved → lands as a single squash commit.
211
+ - **`merge --approved`**: collapse the **contiguous-from-base approved run** into
212
+ one commit on trunk. Approval is read from the base up — the first un-approved
213
+ PR caps the run (unapproved PR4 stops at PR3 even if PR5 is approved). The
214
+ unapproved tail is left in place; after the base lands, run `gh-stack sync` to
215
+ re-root the tail onto trunk (it does not block on CI).
216
+ - **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
217
+ the next branch onto trunk — one PR per run, re-run for the next. Use this to
218
+ land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
219
+ outside gh-stack.
220
+ - **`--collapse`** (with `merge` or `merge --approved`): stop after collapsing
221
+ into the base PR — review on GitHub, then re-run `merge` to finish.
222
+
223
+ ### 7. Maintenance
224
+
225
+ ```bash
226
+ gh-stack delete # remove a branch from the stack (re-parents children)
227
+ gh-stack split # cut a stack into two at a branch
228
+ gh-stack rename # rename a tracked branch (keeps metadata in sync)
229
+ gh-stack undo # restore from the last snapshot
230
+ gh-stack archive # manage archived stacks
231
+ gh-stack doctor # migrate & repair stack metadata
232
+ gh-stack update-prs # refresh stack visualization in PR descriptions
233
+ ```
234
+
235
+ ## Stack visualization
236
+
237
+ `submit` auto-maintains a `### 📚 Stacked on` footer in every PR body. **Do not
238
+ hand-write your own stack list** — it's regenerated each run, so a manual copy
239
+ just goes stale and contradicts the auto footer. Describe _this_ PR in its body;
240
+ let `submit` describe the stack. Link a specific sibling inline in prose if
241
+ needed.
242
+
243
+ ## Safety model
244
+
245
+ - **Snapshots** before every destructive op (restack/sync/merge/remove); roll
246
+ back with `gh-stack undo`. Think of them as a reflog for stack operations.
247
+ - **Squash-merge safe** — uses temporary tags for stable rebasing (handles the
248
+ merge-base problem squash-merge creates).
249
+ - **Never force-pushes trunk.**
250
+ - **Metadata is local and never committed:** per-stack files under
251
+ `.git/.gh-stack/` plus git branch config. Inspect it via `gh-stack stacks
252
+ --json`, not by reading the store. (`gh-stack doctor` migrates the old v2
253
+ `.git/gh-stack-metadata.json` monolith.)
254
+
255
+ ## Don'ts
256
+
257
+ - **Don't use `gt`/Graphite commands** — this is `gh-stack`.
258
+ - **Don't restack from the top** of the stack — restack flows upward.
259
+ - **Don't edit multiple branches then restack once** — finish each branch first.
260
+ - **Don't hand-write a stack section** in PR bodies — `submit` owns it.