gh-stack 0.15.0 → 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 +23 -1
- package/package.json +1 -1
- package/src/commands/learn.ts +152 -0
- package/src/commands/restack.ts +12 -4
- package/src/index.ts +11 -2
- package/src/lib/git.ts +31 -0
- package/src/lib/md.d.ts +7 -0
- package/src/lib/skill.ts +22 -0
- package/src/lib/using-gh-stack.md +234 -0
package/README.md
CHANGED
|
@@ -278,6 +278,19 @@ doctor
|
|
|
278
278
|
Migrate old (v2) metadata to the v3 layout, reconcile git branch
|
|
279
279
|
config against the topology files, and flag stacks whose base
|
|
280
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
|
|
281
294
|
```
|
|
282
295
|
|
|
283
296
|
## Global Options
|
|
@@ -460,7 +473,16 @@ gh-stack merge # finishes base PR → main + archives the stack
|
|
|
460
473
|
|
|
461
474
|
## Agent/CI Usage
|
|
462
475
|
|
|
463
|
-
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
|
+
```
|
|
464
486
|
|
|
465
487
|
```bash
|
|
466
488
|
# Non-interactive mode — all prompts auto-resolved
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/restack.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
160
|
+
stale.push({ branch, local, remote: remote.slice(0, 8) });
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
return stale;
|
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
|
-
|
|
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
|
*/
|
package/src/lib/md.d.ts
ADDED
|
@@ -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/skill.ts
ADDED
|
@@ -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.
|