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/README.md CHANGED
@@ -83,6 +83,18 @@ 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
+ add [branch]
87
+ Track an EXISTING local branch (default: the current branch) in a
88
+ stack — without pushing or opening a PR. Walks local ancestry: if the
89
+ branch sits on top of an existing stack it's adopted into it (parent
90
+ auto-detected); otherwise a new stack is created from the chain.
91
+
92
+ Use this for a WIP branch you've created with `git checkout -b` but
93
+ aren't ready to submit. It fills the gap between creating a branch and
94
+ `submit`: until a branch is tracked, `sync`/`restack`/navigation don't
95
+ know about it, and `add` is how you track it without publishing. (When
96
+ you do run `submit`, it auto-tracks too — `add` is the no-push path.)
97
+
86
98
  submit [-d|--draft] [-n|--no-edit] [-t|--title <t>] [-b|--body <b>] [--body-file <f>] [--restack] [--dry-run]
87
99
  Push all downstack branches to GitHub, create PRs for branches
88
100
  that don't have them, number each PR title with its stack position
@@ -168,9 +180,10 @@ sync [--dry-run]
168
180
  and conflicting.
169
181
 
170
182
  merge [--dry-run] [-d|--delete-branch] [--collapse]
171
- merge --approved [--dry-run] [-d|--delete-branch]
172
- Default (top-down collapse): squash-merge the stack top-down via
173
- GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
183
+ merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
184
+ merge --base [--dry-run] [-d|--delete-branch]
185
+ Default (top-down collapse): squash-merge the whole stack top-down
186
+ via GitHub (PR3 → PR2 → PR1), then enables auto-merge for the base PR
174
187
  into main. The whole stack lands on main as a single squash commit.
175
188
  All merges happen on GitHub so PRs show as "Merged", Linear tickets
176
189
  close automatically, and GitHub Actions fire normally. Skips already-
@@ -181,20 +194,25 @@ merge --approved [--dry-run] [-d|--delete-branch]
181
194
  real conflict with main stops the merge (the stack is left intact —
182
195
  resolve with `gh-stack sync`, then re-run), while merely waiting on
183
196
  pending checks is fine (auto-merge gates on them).
184
- --collapse Stop after collapsing the stack into the base PR;
185
- leaves the base PR open against main so you can
186
- review the cumulative diff on GitHub. Re-run
187
- `gh-stack merge` (without --collapse) to finish.
188
-
189
- --approved (bottom-up, merge what's ready): merge approved PRs from
190
- the BOTTOM up, each as its own squash commit on main, and leave the
191
- unreviewed tail as a clean stack re-rooted on main. Stops at the
192
- first PR that isn't approved. For each approved bottom PR it squash-
193
- merges to main, re-roots the next branch onto main (replaying only
194
- its own commits), pushes it, and repoints its PR base to main. A PR
195
- already merged outside gh-stack is detected and advanced past, not
196
- re-merged. For the "merge PRs as they get reviewed, keep stacking
197
- the rest" workflow.
197
+
198
+ --approved Collapse only the approved run. Walks approval from the
199
+ BASE up and stops at the first PR that isn't approved
200
+ (contiguous — an unapproved PR4 caps the run at PR3 even
201
+ if PR5 is approved). Collapses base..highest-approved into
202
+ a single squash commit on main and leaves the unapproved
203
+ tail in place; after the base PR lands, run `gh-stack sync`
204
+ to re-root the tail onto main. All approved ⇒ same as a
205
+ full merge.
206
+ --base Squash-merge ONLY the bottom PR into main as its own
207
+ commit, then re-root the next branch onto main (replaying
208
+ just its own commits). One PR per run — run it again to
209
+ land the next. Approval is enforced by GitHub branch
210
+ protection, not gh-stack. A base PR already merged outside
211
+ gh-stack is detected and advanced past.
212
+ --collapse With the default or --approved, stop after collapsing into
213
+ the base PR WITHOUT merging it to main, so you can review
214
+ the cumulative diff on GitHub. Re-run `gh-stack merge` to
215
+ finish. Alias: --stop-at-base.
198
216
 
199
217
  split [<branch>] [--name <name>]
200
218
  Cut a stack into two at <branch>. The cut branch and every branch
@@ -278,6 +296,19 @@ doctor
278
296
  Migrate old (v2) metadata to the v3 layout, reconcile git branch
279
297
  config against the topology files, and flag stacks whose base
280
298
  stack appears already-merged into main. Safe to run repeatedly.
299
+
300
+ learn [--skill] [--harness <name>] [--global] [--force]
301
+ Teach a coding agent how to drive gh-stack. By default prints the
302
+ canonical skill (Markdown, stamped with the running version) to
303
+ stdout — the text is compiled into the binary, so it never drifts
304
+ out of sync with the installed version. --skill installs it as a
305
+ skill file instead, choosing the path from the harness:
306
+ Claude Code <root>/.claude/skills/using-gh-stack/SKILL.md
307
+ Codex <root>/.codex/skills/using-gh-stack/SKILL.md
308
+ Cursor <root>/.cursor/skills/using-gh-stack/SKILL.md
309
+ --harness claude | codex | cursor (skips the interactive prompt)
310
+ --global Install under ~/.<harness>/ instead of the project root
311
+ --force Overwrite an existing skill file without confirming
281
312
  ```
282
313
 
283
314
  ## Global Options
@@ -449,8 +480,12 @@ gh-stack status
449
480
  # When the whole stack is approved, merge it down as one squash commit
450
481
  gh-stack merge # squash-merges down, pushes, enables auto-merge
451
482
 
452
- # Or: merge PRs as they get approved, bottom-up, keep stacking the rest
453
- gh-stack merge --approved # merges approved bottom PRs, re-roots the tail on main
483
+ # Or: only the approved part is ready — collapse the approved run into one
484
+ # commit and leave the rest stacked (re-root the tail later with `sync`)
485
+ gh-stack merge --approved # collapses base..highest-approved → main, keeps the tail
486
+
487
+ # Or: land just the bottom PR, one at a time
488
+ gh-stack merge --base # merges the bottom PR → main, re-roots the next
454
489
 
455
490
  # Or: collapse first to review the cumulative diff before shipping
456
491
  gh-stack merge --collapse # squash-merges PRn..PR2 into PR1, stops there
@@ -460,7 +495,16 @@ gh-stack merge # finishes base PR → main + archives the stack
460
495
 
461
496
  ## Agent/CI Usage
462
497
 
463
- gh-stack is designed to be used by AI agents and CI pipelines:
498
+ gh-stack is designed to be used by AI agents and CI pipelines. The fastest way
499
+ to onboard an agent is `gh-stack learn` — it prints a version-matched skill (or
500
+ installs one with `--skill`) so the agent learns the current commands, flags,
501
+ and gotchas straight from the binary:
502
+
503
+ ```bash
504
+ # Teach an agent the current API (always in sync with the installed version)
505
+ gh-stack learn # print the skill to stdout
506
+ gh-stack learn --skill --harness claude # install it for Claude Code
507
+ ```
464
508
 
465
509
  ```bash
466
510
  # 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.15.0",
3
+ "version": "0.17.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,107 @@
1
+ // gh-stack add — register an existing local branch into a stack (no push, no PR).
2
+ //
3
+ // Fills the gap between `git checkout -b <wip>` and `submit`: it tracks the
4
+ // branch (and any untracked ancestors) in the right stack by walking local
5
+ // ancestry, so `sync` / `restack` / navigation work BEFORE you're ready to open
6
+ // a PR. Network-free — nothing is pushed and no PR is created. That's the whole
7
+ // point: `submit` is the command that publishes; `add` only updates metadata.
8
+ //
9
+ // Resolves the "catch-22" where a local WIP tip couldn't be synced because it
10
+ // wasn't in a stack, and the only way into a stack was `submit` (which pushes).
11
+ import * as p from "../lib/output.ts";
12
+ import pc from "picocolors";
13
+ import * as git from "../lib/git.ts";
14
+ import { gateLegacyMetadata } from "../lib/safety.ts";
15
+ import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
16
+ import { resolveOrCreateStack } from "../lib/chain.ts";
17
+ import type { StackMetadata } from "../types.ts";
18
+
19
+ export default async function add(args: string[]): Promise<void> {
20
+ if (args.includes("--help")) {
21
+ console.log(`
22
+ gh-stack add — track an existing local branch in a stack (no push, no PR)
23
+
24
+ USAGE
25
+ gh-stack add [branch]
26
+
27
+ Registers the branch (default: the current branch) into a stack so sync,
28
+ restack, and navigation work before you've submitted it. Walks local ancestry:
29
+ if the branch sits on top of an existing stack it's adopted into that stack;
30
+ otherwise a new stack is created from the chain, rooted at the trunk.
31
+
32
+ Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
33
+ you're ready to publish.
34
+
35
+ EXAMPLES
36
+ gh-stack add Track the current branch
37
+ gh-stack add my-wip-branch Track a specific local branch
38
+ `);
39
+ return;
40
+ }
41
+
42
+ await gateLegacyMetadata();
43
+
44
+ const positional = args.find((a) => !a.startsWith("-"));
45
+ const branch = positional ?? (await git.currentBranch());
46
+
47
+ if (!(await git.localBranchExists(branch))) {
48
+ p.cancel(`Branch ${pc.yellow(branch)} doesn't exist locally.`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const trunk = await git.trunkBranch();
53
+ if (branch === trunk || branch === "main" || branch === "master") {
54
+ p.cancel(
55
+ `${pc.yellow(branch)} is the trunk — nothing to add.\n\n Checkout a feature branch first.`,
56
+ );
57
+ process.exit(1);
58
+ }
59
+
60
+ // submit self-heals from an empty store, so a missing metadata file is fine —
61
+ // resolveOrCreateStack will create the first stack from local ancestry.
62
+ const meta: StackMetadata = (await readMetadata()) ?? {
63
+ version: 2,
64
+ current_stack: null,
65
+ stacks: {},
66
+ };
67
+
68
+ const already = findStackForBranch(meta, branch);
69
+ if (already) {
70
+ p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
71
+ return;
72
+ }
73
+
74
+ let resolution;
75
+ try {
76
+ resolution = await resolveOrCreateStack(meta, branch, trunk);
77
+ } catch (err) {
78
+ p.cancel((err as Error).message);
79
+ process.exit(1);
80
+ }
81
+
82
+ meta.current_stack = resolution.stackName;
83
+ await writeMetadata(meta);
84
+
85
+ p.intro(pc.cyan("Add to Stack"));
86
+
87
+ const stack = meta.stacks[resolution.stackName]!;
88
+ const base = stackBase(stack);
89
+ const baseNote = base === "main" || base === "master" ? "" : ` ${pc.dim(`(base: ${base})`)}`;
90
+
91
+ if (resolution.created) {
92
+ p.log.success(
93
+ `Created stack ${pc.yellow(resolution.stackName)} from ${resolution.addedBranches.length} local branch(es)${baseNote}`,
94
+ );
95
+ } else {
96
+ p.log.success(
97
+ `Added ${resolution.addedBranches.length} branch(es) to ${pc.yellow(resolution.stackName)}${baseNote}`,
98
+ );
99
+ }
100
+ for (const b of resolution.addedBranches) {
101
+ console.log(` ${pc.green("+")} ${b}`);
102
+ }
103
+
104
+ p.outro(
105
+ `Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
106
+ );
107
+ }
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
6
- import { ensureMetadata } from "../lib/safety.ts";
6
+ import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
7
7
 
8
8
  export default async function bottom(args: string[]): Promise<void> {
9
9
  if (args.includes("--help")) {
@@ -43,7 +43,7 @@ Switches to the bottom-most branch of the current stack
43
43
  return;
44
44
  }
45
45
 
46
- await git.checkout(target);
46
+ await checkoutOrExit(target);
47
47
 
48
48
  // Update tracking
49
49
  stack.last_branch = target;
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { writeMetadata, getOrderedBranches, findStackForBranch } from "../lib/metadata.ts";
6
- import { ensureMetadata } from "../lib/safety.ts";
6
+ import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
7
7
  import { selectBranch, selectStack } from "../lib/ui.ts";
8
8
 
9
9
  export default async function checkout(args: string[]): Promise<void> {
@@ -45,20 +45,21 @@ TIP
45
45
  process.exit(0);
46
46
  }
47
47
 
48
- // Update current_stack
49
- meta.current_stack = stackName;
50
- await writeMetadata(meta);
51
-
52
- // Checkout last branch in stack
48
+ // Checkout the stack's last branch FIRST — only persist current_stack once
49
+ // the switch actually lands, so a refused checkout (e.g. dirty tree) leaves
50
+ // metadata pointing at the stack we're still on rather than the target.
53
51
  const stack = meta.stacks[stackName]!;
54
52
  const lastBranch = stack.last_branch;
55
53
 
56
54
  if (lastBranch && lastBranch !== currentBranch) {
57
55
  p.log.info(`Switching to stack ${pc.yellow(stackName)}`);
58
56
  p.log.info(`Checking out ${pc.yellow(lastBranch)}`);
59
- await git.checkout(lastBranch);
57
+ await checkoutOrExit(lastBranch);
60
58
  }
61
59
 
60
+ meta.current_stack = stackName;
61
+ await writeMetadata(meta);
62
+
62
63
  p.outro(pc.green(`Switched to stack ${pc.yellow(stackName)}`));
63
64
  } else {
64
65
  // ── Branch switching ──
@@ -105,7 +106,7 @@ TIP
105
106
  }
106
107
 
107
108
  p.log.info(`Switching to ${pc.yellow(targetBranch)}`);
108
- await git.checkout(targetBranch);
109
+ await checkoutOrExit(targetBranch);
109
110
 
110
111
  // Update last_branch
111
112
  stack.last_branch = targetBranch;
@@ -3,7 +3,7 @@ import * as p from "../lib/output.ts";
3
3
  import pc from "picocolors";
4
4
  import * as git from "../lib/git.ts";
5
5
  import { findStackForBranch, stackBase, writeMetadata } from "../lib/metadata.ts";
6
- import { ensureMetadata } from "../lib/safety.ts";
6
+ import { ensureMetadata, checkoutOrExit } from "../lib/safety.ts";
7
7
 
8
8
  export default async function down(args: string[]): Promise<void> {
9
9
  if (args.includes("--help")) {
@@ -64,7 +64,7 @@ OPTIONS
64
64
  moved++;
65
65
  }
66
66
 
67
- await git.checkout(currentBranch);
67
+ await checkoutOrExit(currentBranch);
68
68
 
69
69
  // Update tracking
70
70
  stack.last_branch = currentBranch;
@@ -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
+ }