gh-stack 0.14.1 → 0.16.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
@@ -83,12 +83,19 @@ create <branch-name> [--description <desc>]
83
83
  Create a new git branch off the current branch and add it to
84
84
  the stack. The current branch must already be tracked.
85
85
 
86
- submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--dry-run]
86
+ submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
87
87
  Push all downstack branches to GitHub, create PRs for branches
88
88
  that don't have them, number each PR title with its stack position
89
89
  `(N/M)`, and update all PR descriptions with stack visualization.
90
90
  Idempotent — safe to run repeatedly.
91
91
 
92
+ --restack: after submitting, restack the branches above the current
93
+ one onto it. submit covers DOWNSTACK (trunk → current); restack covers
94
+ UPSTACK (the children), so together they sync the whole stack from
95
+ wherever you're standing. The everyday loop: drop downstack, fix an
96
+ issue, commit, then `submit --restack` to push the fix and propagate
97
+ it up in one command. Skips cleanly when nothing sits above you.
98
+
92
99
  Self-healing: if the current branch isn't tracked in a stack yet,
93
100
  submit auto-detects the chain from trunk → current, registers (or
94
101
  reconciles into) a stack, then pushes + creates PRs for the whole
@@ -271,6 +278,19 @@ doctor
271
278
  Migrate old (v2) metadata to the v3 layout, reconcile git branch
272
279
  config against the topology files, and flag stacks whose base
273
280
  stack appears already-merged into main. Safe to run repeatedly.
281
+
282
+ learn [--skill] [--harness <name>] [--global] [--force]
283
+ Teach a coding agent how to drive gh-stack. By default prints the
284
+ canonical skill (Markdown, stamped with the running version) to
285
+ stdout — the text is compiled into the binary, so it never drifts
286
+ out of sync with the installed version. --skill installs it as a
287
+ skill file instead, choosing the path from the harness:
288
+ Claude Code <root>/.claude/skills/using-gh-stack/SKILL.md
289
+ Codex <root>/.codex/skills/using-gh-stack/SKILL.md
290
+ Cursor <root>/.cursor/skills/using-gh-stack/SKILL.md
291
+ --harness claude | codex | cursor (skips the interactive prompt)
292
+ --global Install under ~/.<harness>/ instead of the project root
293
+ --force Overwrite an existing skill file without confirming
274
294
  ```
275
295
 
276
296
  ## Global Options
@@ -453,7 +473,16 @@ gh-stack merge # finishes base PR → main + archives the stack
453
473
 
454
474
  ## Agent/CI Usage
455
475
 
456
- gh-stack is designed to be used by AI agents and CI pipelines:
476
+ gh-stack is designed to be used by AI agents and CI pipelines. The fastest way
477
+ to onboard an agent is `gh-stack learn` — it prints a version-matched skill (or
478
+ installs one with `--skill`) so the agent learns the current commands, flags,
479
+ and gotchas straight from the binary:
480
+
481
+ ```bash
482
+ # Teach an agent the current API (always in sync with the installed version)
483
+ gh-stack learn # print the skill to stdout
484
+ gh-stack learn --skill --harness claude # install it for Claude Code
485
+ ```
457
486
 
458
487
  ```bash
459
488
  # Non-interactive mode — all prompts auto-resolved
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,152 @@
1
+ // gh-stack learn — teach agents how to drive gh-stack.
2
+ //
3
+ // gh-stack learn Print the canonical, version-stamped skill to stdout.
4
+ // gh-stack learn --skill Install it as a skill file for a coding harness.
5
+ //
6
+ // The skill text is compiled into the binary (src/lib/skill.ts), so it's always
7
+ // in lockstep with the installed version — no hand-maintained skill to drift.
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import pc from "picocolors";
11
+ import * as p from "../lib/output.ts";
12
+ import * as git from "../lib/git.ts";
13
+ import { isAutoYes } from "../lib/ui.ts";
14
+ import { SKILL_NAME, skillContent } from "../lib/skill.ts";
15
+
16
+ const { version: VERSION } = await import("../../package.json");
17
+
18
+ interface Harness {
19
+ key: string;
20
+ label: string;
21
+ dir: string; // the per-harness config dir, e.g. ".claude"
22
+ }
23
+
24
+ // Project-root install locations per harness. Global installs swap the repo
25
+ // root for the home directory (same `<dir>/skills/<name>/SKILL.md` shape).
26
+ const HARNESSES: Harness[] = [
27
+ { key: "claude", label: "Claude Code", dir: ".claude" },
28
+ { key: "codex", label: "Codex", dir: ".codex" },
29
+ { key: "cursor", label: "Cursor", dir: ".cursor" },
30
+ ];
31
+
32
+ function flagValue(args: string[], name: string): string | undefined {
33
+ const eq = args.find((a) => a.startsWith(`${name}=`));
34
+ if (eq) return eq.slice(name.length + 1);
35
+ const i = args.indexOf(name);
36
+ if (i !== -1 && args[i + 1] && !args[i + 1]!.startsWith("-")) return args[i + 1];
37
+ return undefined;
38
+ }
39
+
40
+ export default async function learn(args: string[]): Promise<void> {
41
+ if (args.includes("--help")) {
42
+ console.log(`
43
+ gh-stack learn — teach an agent how to use gh-stack
44
+
45
+ USAGE
46
+ gh-stack learn [--skill] [options]
47
+
48
+ By default, prints the canonical gh-stack skill (Markdown, stamped with the
49
+ current version) to stdout. The text is compiled into the binary, so it never
50
+ drifts out of sync with the installed version.
51
+
52
+ OPTIONS
53
+ --skill Install the skill into a project (or --global) instead
54
+ of printing it
55
+ --harness <name> Target harness: claude | codex | cursor
56
+ (skips the interactive prompt)
57
+ --global, -g Install under your home dir (~/.<harness>/skills/...)
58
+ instead of the project root
59
+ --force Overwrite an existing skill file without confirming
60
+ --help Show this help
61
+
62
+ INSTALL PATHS
63
+ Claude Code <root>/.claude/skills/${SKILL_NAME}/SKILL.md
64
+ Codex <root>/.codex/skills/${SKILL_NAME}/SKILL.md
65
+ Cursor <root>/.cursor/skills/${SKILL_NAME}/SKILL.md
66
+
67
+ EXAMPLES
68
+ gh-stack learn # print the skill to stdout
69
+ gh-stack learn > AGENTS.md # capture it however you like
70
+ gh-stack learn --skill # interactive install (pick harness)
71
+ gh-stack learn --skill --harness claude # install for Claude Code, no prompt
72
+ gh-stack learn --skill --global -g # install into your home dir
73
+ `);
74
+ return;
75
+ }
76
+
77
+ const content = skillContent(VERSION);
78
+
79
+ // Default: just print the skill. No chrome — pipe-friendly for agents.
80
+ if (!args.includes("--skill")) {
81
+ console.log(content);
82
+ return;
83
+ }
84
+
85
+ // ── Install mode ──
86
+ const global = args.includes("--global") || args.includes("-g");
87
+ const force = args.includes("--force");
88
+ const autoYes = isAutoYes();
89
+
90
+ // Resolve harness: explicit flag, else interactive prompt.
91
+ let harness: Harness | undefined;
92
+ const harnessFlag = flagValue(args, "--harness");
93
+ if (harnessFlag) {
94
+ harness = HARNESSES.find((h) => h.key === harnessFlag.toLowerCase());
95
+ if (!harness) {
96
+ p.log.error(
97
+ `Unknown harness "${harnessFlag}". Choose one of: ${HARNESSES.map((h) => h.key).join(", ")}.`,
98
+ );
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ } else if (autoYes) {
103
+ p.log.error("Non-interactive mode needs an explicit harness: --harness <claude|codex|cursor>.");
104
+ process.exitCode = 1;
105
+ return;
106
+ } else {
107
+ const selected = await p.select({
108
+ message: "Which harness is this skill for?",
109
+ options: HARNESSES.map((h) => ({
110
+ value: h.key,
111
+ label: h.label,
112
+ hint: `${global ? "~" : "."}/${h.dir}/skills/${SKILL_NAME}/`,
113
+ })),
114
+ });
115
+ if (p.isCancel(selected)) {
116
+ p.cancel("Cancelled.");
117
+ return;
118
+ }
119
+ harness = HARNESSES.find((h) => h.key === selected)!;
120
+ }
121
+
122
+ // Resolve install root: home dir (--global) or repo working-tree root.
123
+ let root: string;
124
+ if (global) {
125
+ root = homedir();
126
+ } else if (await git.isGitRepo()) {
127
+ root = await git.repoRoot();
128
+ } else {
129
+ p.log.error("Not inside a git repository. Run from a project, or use --global.");
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const target = join(root, harness.dir, "skills", SKILL_NAME, "SKILL.md");
134
+
135
+ // Confirm overwrite unless --force / auto-yes.
136
+ if ((await Bun.file(target).exists()) && !force && !autoYes) {
137
+ const ok = await p.confirm({
138
+ message: `${pc.yellow(target)} already exists. Overwrite?`,
139
+ });
140
+ if (p.isCancel(ok) || !ok) {
141
+ p.cancel("Left existing skill in place.");
142
+ return;
143
+ }
144
+ }
145
+
146
+ await Bun.write(target, content); // creates parent dirs as needed
147
+
148
+ p.log.success(`Installed ${harness.label} skill → ${pc.cyan(target)}`);
149
+ p.log.info(
150
+ `Stamped from gh-stack v${VERSION}. Refresh after upgrading with ${pc.dim("gh-stack learn --skill")}.`,
151
+ );
152
+ }
@@ -132,24 +132,32 @@ ALIASES
132
132
  * For each branch, compare its local tip against origin. Returns the ones that
133
133
  * differ (or whose remote ref is missing), so restack can report exactly which
134
134
  * refs didn't land instead of falsely claiming success. (#12)
135
+ *
136
+ * The remote sha comes from a single authoritative `git ls-remote` (see
137
+ * git.remoteHeads), NOT from `rev-parse origin/<branch>`. A force-push can leave
138
+ * the local remote-tracking ref stale even though the push landed, which made
139
+ * this pass report a false "un-pushed ref" and exit non-zero after a fully
140
+ * successful run (#23). Reading the real remote state makes the signal
141
+ * trustworthy in both directions: a genuinely un-pushed ref is still caught.
135
142
  */
136
- async function findStaleRefs(
143
+ export async function findStaleRefs(
137
144
  branches: string[],
138
145
  ): Promise<{ branch: string; local: string; remote: string }[]> {
139
146
  const stale: { branch: string; local: string; remote: string }[] = [];
140
147
  const seen = new Set<string>();
148
+ const remoteHeads = await git.remoteHeads();
141
149
  for (const branch of branches) {
142
150
  if (seen.has(branch) || branch === "main" || branch === "master") continue;
143
151
  seen.add(branch);
144
152
  if (!(await git.localBranchExists(branch))) continue;
145
153
  const local = await git.revParse(branch);
146
- if (!(await git.remoteBranchExists(branch))) {
154
+ const remote = remoteHeads.get(branch);
155
+ if (!remote) {
147
156
  stale.push({ branch, local, remote: "(missing)" });
148
157
  continue;
149
158
  }
150
- const remote = await git.revParse(`origin/${branch}`).catch(() => "");
151
159
  if (remote !== local) {
152
- stale.push({ branch, local, remote: remote ? remote.slice(0, 8) : "(unknown)" });
160
+ stale.push({ branch, local, remote: remote.slice(0, 8) });
153
161
  }
154
162
  }
155
163
  return stale;
@@ -4,13 +4,14 @@ import pc from "picocolors";
4
4
  import { $ } from "bun";
5
5
  import * as git from "../lib/git.ts";
6
6
  import {
7
+ buildRebaseChain,
7
8
  findStackForBranch,
8
9
  getOrderedBranches,
9
10
  readMetadata,
10
11
  writeMetadata,
11
12
  } from "../lib/metadata.ts";
12
13
  import { isAutoYes } from "../lib/ui.ts";
13
- import type { StackMetadata } from "../types.ts";
14
+ import type { Stack, StackMetadata } from "../types.ts";
14
15
  import { getPrNumber } from "../lib/github.ts";
15
16
  import { resolveOrCreateStack } from "../lib/chain.ts";
16
17
  import { reconcilePrTitles, refreshStackViz, branchToTitle } from "./update-prs.ts";
@@ -36,6 +37,8 @@ OPTIONS
36
37
  -t, --title <title> Set the PR title (current branch) — creates or updates
37
38
  -b, --body <body> Set the PR body (current branch) — creates or updates
38
39
  --body-file <f> Read PR body from a file
40
+ --restack After submitting, restack the branches above the current
41
+ one onto it (propagate a downstack fix up to children)
39
42
  --dry-run Show what would happen without doing anything
40
43
 
41
44
  Updating an existing PR: -t / -b also update the PR for the branch you're on
@@ -43,10 +46,17 @@ if it already exists — the title keeps its (N/M) stack position, and the body
43
46
  is replaced with the "Stacked on" block re-merged in. Use this instead of
44
47
  gh pr edit so the stack visualization is never clobbered.
45
48
 
49
+ Restack after submit (--restack): submit covers DOWNSTACK (trunk → current);
50
+ restack covers UPSTACK (the children above current). Together they sync the
51
+ whole stack from wherever you're standing. The common loop: drop downstack,
52
+ fix an issue, commit, then \`submit --restack\` to push the fix and propagate
53
+ it up — one command instead of two. Skips cleanly if nothing sits above you.
54
+
46
55
  EXAMPLES
47
56
  gh-stack submit # Push + create PRs interactively
48
57
  gh-stack submit -n # Push + create PRs with auto-titles
49
58
  gh-stack submit -t "My PR" -b "Description" # Explicit title and body
59
+ gh-stack submit --restack # Submit, then restack children onto it
50
60
  gh-stack submit --draft # Create new PRs as drafts
51
61
  gh-stack submit --dry-run # Preview what would happen
52
62
  `;
@@ -90,6 +100,7 @@ export default async function submit(args: string[]): Promise<void> {
90
100
 
91
101
  const draftFlag = args.includes("--draft") || args.includes("-d");
92
102
  const dryRun = args.includes("--dry-run");
103
+ const restackFlag = args.includes("--restack");
93
104
 
94
105
  // Parse value flags
95
106
  let titleFlag: string | undefined;
@@ -355,6 +366,7 @@ export default async function submit(args: string[]): Promise<void> {
355
366
  `Dry run complete: would push ${scope.length}, create ${created}, update ${existing} PR(s)`,
356
367
  ),
357
368
  );
369
+ if (restackFlag) await maybeRestack(stack, branch, true);
358
370
  return;
359
371
  }
360
372
 
@@ -388,4 +400,28 @@ export default async function submit(args: string[]): Promise<void> {
388
400
  (viz.unchanged > 0 ? `, ${viz.unchanged} unchanged` : ""),
389
401
  ),
390
402
  );
403
+
404
+ // ── --restack: propagate a downstack fix up to the children ──
405
+ // submit owns DOWNSTACK (trunk → current); restack owns UPSTACK (the branches
406
+ // above current). Running both from one branch syncs the whole stack.
407
+ if (restackFlag) await maybeRestack(stack, branch, false);
408
+ }
409
+
410
+ /**
411
+ * Chain into `restack` from the current branch after a submit, so a downstack
412
+ * fix propagates up to its children in one command. No-op (with a friendly
413
+ * note) when nothing sits above the current branch — there's nothing to rebase.
414
+ */
415
+ async function maybeRestack(stack: Stack, branch: string, dryRun: boolean): Promise<void> {
416
+ // buildRebaseChain returns the current branch + all its descendants; a length
417
+ // of 1 means it's the top of its line, so there's nothing above to restack.
418
+ if (buildRebaseChain(stack, branch).length <= 1) {
419
+ console.log();
420
+ p.log.info(pc.dim(`--restack: nothing sits above ${pc.yellow(branch)} — nothing to restack.`));
421
+ return;
422
+ }
423
+
424
+ console.log();
425
+ const { default: restack } = await import("./restack.ts");
426
+ await restack(dryRun ? ["--dry-run"] : []);
391
427
  }
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;
@@ -143,6 +147,10 @@ switch (command) {
143
147
  await (await import("./commands/bottom.ts")).default(commandArgs);
144
148
  break;
145
149
 
150
+ case "learn":
151
+ await (await import("./commands/learn.ts")).default(commandArgs);
152
+ break;
153
+
146
154
  case "--help":
147
155
  case "help":
148
156
  printHelp();
@@ -196,6 +204,7 @@ ${bold("INFO & MAINTENANCE")}
196
204
  ${green("archive")} Manage archived stacks
197
205
  ${green("update-prs")} Refresh stack visualization in PR descriptions
198
206
  ${green("doctor")} Migrate & repair stack metadata
207
+ ${green("learn")} Print the agent skill (--skill installs it)
199
208
 
200
209
  ${bold("GLOBAL OPTIONS")}
201
210
  --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.
@@ -149,6 +157,29 @@ export async function pushStackedBranch(branch: string): Promise<boolean> {
149
157
  return exitCode === 0;
150
158
  }
151
159
 
160
+ /**
161
+ * Read the actual tip sha of every branch on origin in a single `ls-remote`
162
+ * call. This queries the remote directly, so it is **authoritative** — unlike
163
+ * `rev-parse origin/<branch>`, which reads the local remote-tracking ref that a
164
+ * force-push can leave stale (a fresh fetch would correct it). Use this when a
165
+ * correct answer about the remote's real state matters (e.g. the post-restack
166
+ * sync verification — #23). Returns a map of branch name → sha.
167
+ */
168
+ export async function remoteHeads(): Promise<Map<string, string>> {
169
+ const heads = new Map<string, string>();
170
+ const { exitCode, stdout } = await $`git ls-remote --heads origin`.nothrow().quiet();
171
+ if (exitCode !== 0) return heads;
172
+ const prefix = "refs/heads/";
173
+ for (const line of stdout.toString().split("\n")) {
174
+ const tab = line.indexOf("\t");
175
+ if (tab === -1) continue;
176
+ const sha = line.slice(0, tab).trim();
177
+ const ref = line.slice(tab + 1).trim();
178
+ if (sha && ref.startsWith(prefix)) heads.set(ref.slice(prefix.length), sha);
179
+ }
180
+ return heads;
181
+ }
182
+
152
183
  /**
153
184
  * Check if a branch exists on the remote.
154
185
  */
@@ -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
+ }
@@ -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,234 @@
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
+ ### 2. Push & create PRs — `submit`
93
+
94
+ `submit` is the workhorse: **idempotent, scope-aware, self-healing**. It pushes
95
+ changed branches with `--force-with-lease` (skips up-to-date ones), creates
96
+ missing PRs with the correct base, fixes PR bases, and refreshes the stack
97
+ visualization in every PR body.
98
+
99
+ ```bash
100
+ gh-stack submit # push downstack + create/update PRs
101
+ gh-stack submit -d # create new PRs as drafts
102
+ gh-stack submit --dry-run # preview, no side effects
103
+ gh-stack submit --restack # then propagate up to children (see below)
104
+ gh-stack submit -t "<title>" -b "<body>" # explicit title/body for new PRs
105
+ gh-stack submit -t "<title>" --body-file /tmp/body.md
106
+ gh-stack submit -n # auto-generate titles (generic — avoid)
107
+ ```
108
+
109
+ **Agents: provide title + body explicitly.** `--title` implies `--no-edit`
110
+ (no prompts); title/body apply to all new PRs created in scope:
111
+
112
+ ```bash
113
+ gh-stack submit \
114
+ -t "Store raw transcription JSON and WebVTT [WEB-7409]" \
115
+ -b "$(cat <<'EOF'
116
+ ## Summary
117
+ - Store raw transcription JSON alongside pre-rendered WebVTT
118
+
119
+ ## Test plan
120
+ - [ ] Verify both formats persist
121
+ EOF
122
+ )"
123
+ ```
124
+
125
+ **`submit -t`/`-b` also updates an existing PR in place** — pass them while on a
126
+ branch that already has a PR to change just that one (visualization-safe).
127
+
128
+ **`--restack`** chains a restack after submit. `submit` owns DOWNSTACK (trunk →
129
+ current); `restack` owns UPSTACK (the children). Together they're the everyday
130
+ downstack-fix loop in one command: _drop downstack → fix → commit →
131
+ `gh-stack submit --restack`_ pushes the fix AND rebases the children onto it.
132
+
133
+ ### 3. Navigate
134
+
135
+ ```bash
136
+ gh-stack log # stack tree (default command)
137
+ gh-stack ls # numbered branch list
138
+ gh-stack up [n] # toward children (upstack)
139
+ gh-stack down [n] # toward parents (downstack)
140
+ gh-stack top # tip (leaf)
141
+ gh-stack bottom # base (first branch above trunk)
142
+ gh-stack checkout # interactive picker (alias: co)
143
+ ```
144
+
145
+ ### 4. Sync & restack
146
+
147
+ ```bash
148
+ gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
149
+ gh-stack restack # rebase current branch + descendants onto their parents
150
+ gh-stack restack --dry-run
151
+ ```
152
+
153
+ **⚠️ Restack propagates UPWARD from the current branch.** Run it from the
154
+ **lowest branch you changed**, never from the top:
155
+
156
+ ```bash
157
+ gh-stack bottom # go to the changed branch
158
+ # ...edit, commit...
159
+ gh-stack submit # push it
160
+ gh-stack restack # propagates the change UP through children
161
+ ```
162
+
163
+ **Changing multiple branches?** Finish each one fully before moving up —
164
+ `edit → test → commit → push → restack`, then `gh-stack up` and repeat. Don't
165
+ edit several branches then restack once at the end (only the last branch ends
166
+ up pushed).
167
+
168
+ **On rebase conflict:** resolve, `git add <files>`, `git rebase --continue`,
169
+ then `gh-stack restack --resume` to finish the remaining branches.
170
+
171
+ **`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
172
+ `sync` detects it and advances the stack past the merged branch (re-rooting onto
173
+ trunk) instead of replaying squashed commits and conflicting.
174
+
175
+ ### 5. Status
176
+
177
+ ```bash
178
+ gh-stack status # PR dashboard: CI, reviews, merge readiness
179
+ gh-stack status --current # current stack only
180
+ gh-stack status --json # structured, for agents
181
+ ```
182
+
183
+ ### 6. Merge & ship
184
+
185
+ ```bash
186
+ gh-stack merge # top-down: collapse stack into one squash commit → trunk
187
+ gh-stack merge --approved # bottom-up: merge approved PRs (each its own commit),
188
+ # re-root the unreviewed tail, leave it as a clean stack
189
+ gh-stack merge --dry-run
190
+ ```
191
+
192
+ - **`merge`** (top-down): the whole stack is approved → lands as a unit.
193
+ - **`merge --approved`** (bottom-up): merge PRs as they're reviewed, keep
194
+ stacking the rest. Stops at the first un-approved PR; auto-advances past any
195
+ bottom PR already merged outside gh-stack.
196
+
197
+ ### 7. Maintenance
198
+
199
+ ```bash
200
+ gh-stack delete # remove a branch from the stack (re-parents children)
201
+ gh-stack split # cut a stack into two at a branch
202
+ gh-stack rename # rename a tracked branch (keeps metadata in sync)
203
+ gh-stack undo # restore from the last snapshot
204
+ gh-stack archive # manage archived stacks
205
+ gh-stack doctor # migrate & repair stack metadata
206
+ gh-stack update-prs # refresh stack visualization in PR descriptions
207
+ ```
208
+
209
+ ## Stack visualization
210
+
211
+ `submit` auto-maintains a `### 📚 Stacked on` footer in every PR body. **Do not
212
+ hand-write your own stack list** — it's regenerated each run, so a manual copy
213
+ just goes stale and contradicts the auto footer. Describe _this_ PR in its body;
214
+ let `submit` describe the stack. Link a specific sibling inline in prose if
215
+ needed.
216
+
217
+ ## Safety model
218
+
219
+ - **Snapshots** before every destructive op (restack/sync/merge/remove); roll
220
+ back with `gh-stack undo`. Think of them as a reflog for stack operations.
221
+ - **Squash-merge safe** — uses temporary tags for stable rebasing (handles the
222
+ merge-base problem squash-merge creates).
223
+ - **Never force-pushes trunk.**
224
+ - **Metadata is local and never committed:** per-stack files under
225
+ `.git/.gh-stack/` plus git branch config. Inspect it via `gh-stack stacks
226
+ --json`, not by reading the store. (`gh-stack doctor` migrates the old v2
227
+ `.git/gh-stack-metadata.json` monolith.)
228
+
229
+ ## Don'ts
230
+
231
+ - **Don't use `gt`/Graphite commands** — this is `gh-stack`.
232
+ - **Don't restack from the top** of the stack — restack flows upward.
233
+ - **Don't edit multiple branches then restack once** — finish each branch first.
234
+ - **Don't hand-write a stack section** in PR bodies — `submit` owns it.