gh-stack 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kiliman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,229 @@
1
+ # gh-stack
2
+
3
+ Stacked PR manager for squash-merge workflows.
4
+
5
+ Manages stacked pull requests with metadata stored in `.git/gh-stack-metadata.json`. Designed for repositories that use squash-merge (where tools like Graphite break down).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ # From source (requires Bun)
11
+ bun install
12
+ bun run build
13
+ ln -s $(pwd)/dist/gh-stack ~/.local/bin/gh-stack
14
+
15
+ # Or install globally
16
+ bun install -g gh-stack
17
+ ```
18
+
19
+ ### Prerequisites
20
+
21
+ - [Bun](https://bun.sh) runtime
22
+ - [GitHub CLI](https://cli.github.com/) (`gh`) — authenticated
23
+ - Git 2.30+
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # Create a stack from your current branch
29
+ gh-stack init --name my-feature
30
+
31
+ # Add more branches to the stack
32
+ gh-stack add --create kiliman/pr2-WEB-1234
33
+
34
+ # View the stack
35
+ gh-stack # tree view (default)
36
+ gh-stack list # numbered list
37
+
38
+ # Sync with main and restack all branches
39
+ gh-stack sync
40
+
41
+ # Check PR status across all stacks
42
+ gh-stack status
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ ### Stack Management
48
+
49
+ ```
50
+ init [--name <name>] [--description <desc>]
51
+ Create a new stack and add the current branch as the first entry.
52
+
53
+ add [<branch>] [--parent <branch>] [--create] [--description <desc>]
54
+ Add a branch to the current stack. Defaults to the current branch.
55
+ --create makes a new branch off the top of stack first.
56
+
57
+ remove <branch>
58
+ Remove a branch from the stack and re-parent its children.
59
+ ```
60
+
61
+ ### Navigation
62
+
63
+ ```
64
+ show
65
+ Display the current stack as a tree with PR numbers, titles,
66
+ review status, and CI state. This is the default command.
67
+
68
+ list
69
+ List branches with position numbers. Lightweight output for
70
+ scripting and agents. (alias: ls)
71
+
72
+ switch [<number>]
73
+ Switch to a branch by position number, or interactive picker.
74
+ --stack Switch between stacks instead of branches
75
+
76
+ status [--current] [--json]
77
+ PR dashboard showing review state, CI status, and merge readiness.
78
+ --current Show only the current stack or standalone PR
79
+ --json Structured JSON output (progress goes to stderr)
80
+ ```
81
+
82
+ ### Rebase & Sync
83
+
84
+ ```
85
+ restack [--resume] [--dry-run] [--verbose] [--yes]
86
+ Rebase the current branch and all descendants onto their parents.
87
+ Uses tag-based references for stable rebasing across the chain.
88
+
89
+ On conflict:
90
+ git rebase --continue
91
+ gh-stack restack --resume
92
+
93
+ (alias: rebase)
94
+
95
+ sync [--dry-run] [--yes]
96
+ Fetch main, rebase the base branch onto main, then restack all
97
+ children. Creates tags for ALL branches before any rebasing starts.
98
+ ```
99
+
100
+ ### Merge & Ship
101
+
102
+ ```
103
+ merge [--dry-run]
104
+ Squash-merge the stack top-down locally (PR3 -> PR2 -> PR1),
105
+ then optionally rebase onto main. Keeps all commits local to
106
+ avoid orphaned squash commits from GitHub-only merges.
107
+
108
+ update-prs
109
+ Update all PR descriptions with a stack visualization:
110
+
111
+ ### 📚 Stacked on
112
+ ⚫ main
113
+ ┃
114
+ ┣━ ✅ #123 Backend models 👈
115
+ ┃
116
+ ┗━ ⏳ #124 Frontend UI
117
+ ```
118
+
119
+ ### Maintenance
120
+
121
+ ```
122
+ archive [--list] [--restore <name>]
123
+ Manage archived stacks.
124
+
125
+ undo
126
+ Restore the last snapshot taken before a destructive operation.
127
+ ```
128
+
129
+ ## Global Options
130
+
131
+ ```
132
+ --yes, -y Skip all confirmations (for agents/CI)
133
+ --help Show help for a command
134
+ --version, -V Show version
135
+ ```
136
+
137
+ ## Environment Variables
138
+
139
+ ```
140
+ GH_STACK_YES=1 Skip all confirmations (same as --yes)
141
+ GH_STACK_NO_COLOR=1 Disable colored output
142
+ ```
143
+
144
+ ## How It Works
145
+
146
+ ### Tag-Based Rebasing
147
+
148
+ The critical insight: after rebasing a parent branch, `git merge-base` returns wrong results for its children. gh-stack solves this by creating temporary `stack-sync-*` tags marking each branch's divergence point **before** any rebasing starts, then using those stable references for `git rebase --onto`.
149
+
150
+ ### Metadata
151
+
152
+ Stack metadata lives at `.git/gh-stack-metadata.json` (never committed):
153
+
154
+ ```json
155
+ {
156
+ "version": 2,
157
+ "current_stack": "podcast-mvp",
158
+ "stacks": {
159
+ "podcast-mvp": {
160
+ "description": "Podcast MVP features",
161
+ "last_branch": "kiliman/pr2-WEB-1234",
162
+ "branches": {
163
+ "kiliman/pr1-WEB-1234": {
164
+ "parent": "main",
165
+ "pr": 21306,
166
+ "description": "Backend models"
167
+ },
168
+ "kiliman/pr2-WEB-1234": {
169
+ "parent": "kiliman/pr1-WEB-1234",
170
+ "pr": 21452,
171
+ "description": "Frontend UI"
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ ```
178
+
179
+ ### Snapshots
180
+
181
+ Before any destructive operation (restack, sync, merge, remove), gh-stack saves a snapshot of all branch HEADs. Run `gh-stack undo` to restore.
182
+
183
+ ## Example Workflow
184
+
185
+ ```bash
186
+ # Start a new stack
187
+ git checkout -b kiliman/api-layer-WEB-1234
188
+ # ... code, commit, push, create PR ...
189
+ gh-stack init --name api-feature
190
+
191
+ # Add second PR on top
192
+ gh-stack add --create kiliman/frontend-WEB-1234
193
+ # ... code, commit, push, create PR ...
194
+
195
+ # Update PR descriptions with stack visualization
196
+ gh-stack update-prs
197
+
198
+ # Later: sync everything with main
199
+ gh-stack sync --yes
200
+
201
+ # Check status
202
+ gh-stack status --current --json
203
+
204
+ # When PRs are approved, merge the stack
205
+ gh-stack merge
206
+ ```
207
+
208
+ ## Agent/CI Usage
209
+
210
+ gh-stack is designed to be used by AI agents and CI pipelines:
211
+
212
+ ```bash
213
+ # Non-interactive mode
214
+ export GH_STACK_YES=1
215
+ gh-stack sync
216
+ gh-stack restack
217
+
218
+ # Structured output
219
+ gh-stack status --json
220
+ gh-stack status --current --json
221
+ gh-stack list
222
+
223
+ # Quick branch switching
224
+ gh-stack switch 2
225
+ ```
226
+
227
+ ## License
228
+
229
+ MIT
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "gh-stack",
3
+ "version": "0.1.0",
4
+ "description": "Stacked PR manager for squash-merge workflows",
5
+ "author": "Kiliman <kiliman@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/kiliman/gh-stack"
10
+ },
11
+ "keywords": [
12
+ "git",
13
+ "github",
14
+ "stacked-prs",
15
+ "squash-merge",
16
+ "cli",
17
+ "pull-requests",
18
+ "rebase",
19
+ "bun"
20
+ ],
21
+ "type": "module",
22
+ "bin": {
23
+ "gh-stack": "src/index.ts"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "!src/**/__tests__",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.0.0"
33
+ },
34
+ "scripts": {
35
+ "dev": "bun run src/index.ts",
36
+ "build": "bun build --compile src/index.ts --outfile dist/gh-stack",
37
+ "test": "bun test",
38
+ "lint": "oxlint src/",
39
+ "format": "oxfmt --write src/",
40
+ "format:check": "oxfmt --check src/",
41
+ "typecheck": "tsc --noEmit",
42
+ "check": "bun run lint && bun run typecheck && bun test",
43
+ "prepublishOnly": "bun run check",
44
+ "prepare": "husky"
45
+ },
46
+ "lint-staged": {
47
+ "*.ts": [
48
+ "oxlint",
49
+ "oxfmt --write"
50
+ ]
51
+ },
52
+ "devDependencies": {
53
+ "@types/bun": "latest",
54
+ "husky": "^9.1.7",
55
+ "lint-staged": "^16.3.3",
56
+ "oxfmt": "^0.40.0",
57
+ "oxlint": "^1.55.0"
58
+ },
59
+ "peerDependencies": {
60
+ "typescript": "^5"
61
+ },
62
+ "dependencies": {
63
+ "@clack/prompts": "^1.1.0",
64
+ "picocolors": "^1.1.1"
65
+ }
66
+ }
@@ -0,0 +1,137 @@
1
+ // gh-stack add — Add a branch to the current stack
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import * as git from "../lib/git.ts";
5
+ import { addBranchToStack, getTopOfStack, getOrderedBranches } from "../lib/metadata.ts";
6
+ import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
7
+ import { getPrNumber } from "../lib/github.ts";
8
+ import { selectParent } from "../lib/ui.ts";
9
+ import type { Branch } from "../types.ts";
10
+
11
+ export default async function add(args: string[]): Promise<void> {
12
+ // Parse flags
13
+ let parentFlag: string | undefined;
14
+ let descFlag: string | undefined;
15
+ let createFlag: string | undefined;
16
+ let branchArg: string | undefined;
17
+
18
+ for (let i = 0; i < args.length; i++) {
19
+ switch (args[i]) {
20
+ case "--parent":
21
+ parentFlag = args[++i];
22
+ break;
23
+ case "--description":
24
+ descFlag = args[++i];
25
+ break;
26
+ case "--create":
27
+ createFlag = args[++i];
28
+ break;
29
+ case "--help":
30
+ console.log(`
31
+ gh-stack add — Add a branch to the current stack
32
+
33
+ USAGE
34
+ gh-stack add [<branch>] [options]
35
+
36
+ OPTIONS
37
+ --parent <branch> Parent branch (default: top of stack)
38
+ --create <branch> Create new branch off top of stack and add it
39
+ --description <desc> Description for the branch
40
+ `);
41
+ return;
42
+ default:
43
+ if (!args[i]!.startsWith("--")) {
44
+ branchArg = args[i];
45
+ }
46
+ }
47
+ }
48
+
49
+ const meta = await ensureMetadata();
50
+ const stackName = ensureCurrentStack(meta);
51
+ const stack = meta.stacks[stackName]!;
52
+
53
+ p.intro(pc.cyan(`Add branch to ${pc.yellow(stackName)}`));
54
+
55
+ let branchToAdd: string;
56
+
57
+ // --create: make a new branch off the top of the stack
58
+ if (createFlag) {
59
+ const topBranch = getTopOfStack(stack);
60
+ if (!topBranch) {
61
+ p.cancel("Stack has no branches yet. Add the base branch first.");
62
+ process.exit(1);
63
+ }
64
+
65
+ const currentBranch = await git.currentBranch();
66
+ if (currentBranch !== topBranch) {
67
+ p.log.info(`Switching to ${pc.yellow(topBranch)}...`);
68
+ await git.checkout(topBranch);
69
+ }
70
+
71
+ p.log.info(`Creating branch off top of stack: ${pc.yellow(topBranch)}`);
72
+ await git.createBranch(createFlag);
73
+ p.log.success(`Created branch: ${pc.yellow(createFlag)}`);
74
+
75
+ branchToAdd = createFlag;
76
+ parentFlag = topBranch;
77
+ } else {
78
+ branchToAdd = branchArg || (await git.currentBranch());
79
+ }
80
+
81
+ // Check if branch is already in the stack
82
+ if (stack.branches[branchToAdd]) {
83
+ p.cancel(`Branch ${pc.yellow(branchToAdd)} is already in stack ${pc.blue(stackName)}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log(` Adding: ${pc.yellow(branchToAdd)}`);
88
+ console.log();
89
+
90
+ // Get parent
91
+ let parent = parentFlag;
92
+ if (!parent) {
93
+ // Default to top of stack
94
+ const ordered = getOrderedBranches(stack);
95
+
96
+ if (ordered.length === 0) {
97
+ parent = "main";
98
+ } else {
99
+ parent = (await selectParent(stack, branchToAdd)) ?? undefined;
100
+ if (!parent) {
101
+ p.cancel("Cancelled");
102
+ process.exit(0);
103
+ }
104
+ }
105
+ }
106
+
107
+ p.log.info(`Parent: ${pc.yellow(parent)}`);
108
+
109
+ // Auto-detect PR number
110
+ const s = p.spinner();
111
+ s.start("Looking for PR...");
112
+ const prNumber = await getPrNumber(branchToAdd);
113
+ s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
114
+
115
+ // Get description
116
+ let description = descFlag;
117
+ if (description === undefined && !createFlag) {
118
+ const result = await p.text({
119
+ message: "Branch description (optional)",
120
+ placeholder: "e.g., API layer",
121
+ });
122
+ if (!p.isCancel(result)) {
123
+ description = (result as string) || undefined;
124
+ }
125
+ }
126
+
127
+ // Add branch
128
+ const branchData: Branch = {
129
+ parent,
130
+ ...(prNumber && { pr: prNumber }),
131
+ ...(description && { description }),
132
+ };
133
+
134
+ await addBranchToStack(meta, stackName, branchToAdd, branchData);
135
+
136
+ p.outro(pc.green(`Added ${pc.yellow(branchToAdd)} to stack ${pc.blue(stackName)}`));
137
+ }
@@ -0,0 +1,60 @@
1
+ // gh-stack archive — Manage archived stacks
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { writeMetadata } from "../lib/metadata.ts";
5
+ import { ensureMetadata } from "../lib/safety.ts";
6
+
7
+ export default async function archive(args: string[]): Promise<void> {
8
+ const restoreIdx = args.indexOf("--restore");
9
+ const restoreName = restoreIdx !== -1 ? args[restoreIdx + 1] : undefined;
10
+
11
+ if (args.includes("--help")) {
12
+ console.log(`
13
+ gh-stack archive — Manage archived stacks
14
+
15
+ USAGE
16
+ gh-stack archive [--list] List archived stacks
17
+ gh-stack archive --restore <name> Restore an archived stack
18
+ `);
19
+ return;
20
+ }
21
+
22
+ const meta = await ensureMetadata();
23
+
24
+ if (restoreName) {
25
+ // Restore an archived stack
26
+ if (!meta.archive || !meta.archive[restoreName]) {
27
+ p.cancel(`Archived stack "${restoreName}" not found`);
28
+ process.exit(1);
29
+ }
30
+
31
+ meta.stacks[restoreName] = meta.archive[restoreName]!;
32
+ delete meta.archive[restoreName];
33
+ meta.current_stack = restoreName;
34
+ await writeMetadata(meta);
35
+
36
+ p.log.success(`Restored stack ${pc.yellow(restoreName)} from archive`);
37
+ return;
38
+ }
39
+
40
+ // List archived stacks
41
+ p.intro(pc.cyan("Archived Stacks"));
42
+
43
+ if (!meta.archive || Object.keys(meta.archive).length === 0) {
44
+ p.log.info("No archived stacks");
45
+ return;
46
+ }
47
+
48
+ for (const [name, stack] of Object.entries(meta.archive)) {
49
+ const branchCount = Object.keys(stack.branches).length;
50
+ console.log(
51
+ ` ${pc.blue("○")} ${pc.yellow(name)} (${branchCount} branch${branchCount !== 1 ? "es" : ""})`,
52
+ );
53
+ if (stack.description) {
54
+ console.log(` ${pc.dim(stack.description)}`);
55
+ }
56
+ console.log();
57
+ }
58
+
59
+ console.log(pc.dim(` Restore with: gh-stack archive --restore <name>`));
60
+ }
@@ -0,0 +1,156 @@
1
+ // gh-stack init — Create a new stack with the current branch
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import * as git from "../lib/git.ts";
5
+ import {
6
+ readMetadata,
7
+ initMetadata,
8
+ createStack,
9
+ addBranchToStack,
10
+ metadataExists,
11
+ } from "../lib/metadata.ts";
12
+ import { getPrNumber } from "../lib/github.ts";
13
+ import { selectParent } from "../lib/ui.ts";
14
+ import type { StackMetadata, Branch } from "../types.ts";
15
+
16
+ export default async function init(args: string[]): Promise<void> {
17
+ const branch = await git.currentBranch();
18
+
19
+ // Parse flags
20
+ let nameFlag: string | undefined;
21
+ let descFlag: string | undefined;
22
+ let parentFlag: string | undefined;
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ switch (args[i]) {
26
+ case "--name":
27
+ nameFlag = args[++i];
28
+ break;
29
+ case "--description":
30
+ descFlag = args[++i];
31
+ break;
32
+ case "--parent":
33
+ parentFlag = args[++i];
34
+ break;
35
+ case "--help":
36
+ console.log(`
37
+ gh-stack init — Create a new stack
38
+
39
+ USAGE
40
+ gh-stack init [--name <name>] [--description <desc>] [--parent <branch>]
41
+
42
+ OPTIONS
43
+ --name <name> Stack name (skip prompt)
44
+ --description <desc> Stack description
45
+ --parent <branch> Parent branch for current branch (default: main)
46
+ `);
47
+ return;
48
+ }
49
+ }
50
+
51
+ p.intro(pc.cyan("Create New Stack"));
52
+
53
+ console.log(` Initializing stack with: ${pc.yellow(branch)}`);
54
+ console.log();
55
+
56
+ // Get or create metadata
57
+ let meta: StackMetadata;
58
+ if (await metadataExists()) {
59
+ meta = (await readMetadata())!;
60
+ } else {
61
+ meta = await initMetadata();
62
+ p.log.success("Initialized empty stack metadata");
63
+ }
64
+
65
+ // Get stack name
66
+ let stackName = nameFlag;
67
+ if (!stackName) {
68
+ const result = await p.text({
69
+ message: "Stack name",
70
+ placeholder: "e.g., podcast-mvp",
71
+ validate: (val = "") => {
72
+ if (!val.trim()) return "Stack name is required";
73
+ if (meta.stacks[val.trim()]) return `Stack "${val}" already exists`;
74
+ },
75
+ });
76
+ if (p.isCancel(result)) {
77
+ p.cancel("Cancelled");
78
+ process.exit(0);
79
+ }
80
+ stackName = result as string;
81
+ }
82
+
83
+ // Check if stack already exists
84
+ if (meta.stacks[stackName]) {
85
+ p.cancel(`Stack "${stackName}" already exists`);
86
+ process.exit(1);
87
+ }
88
+
89
+ // Get stack description
90
+ let description = descFlag;
91
+ if (description === undefined) {
92
+ const result = await p.text({
93
+ message: "Stack description (optional)",
94
+ placeholder: "e.g., Podcast MVP features",
95
+ });
96
+ if (p.isCancel(result)) {
97
+ p.cancel("Cancelled");
98
+ process.exit(0);
99
+ }
100
+ description = (result as string) || "";
101
+ }
102
+
103
+ // Create the stack
104
+ meta = await createStack(meta, stackName, description);
105
+ p.log.success(`Created stack: ${pc.blue(stackName)}`);
106
+
107
+ // Get parent branch
108
+ let parent: string | undefined = parentFlag;
109
+ if (!parent) {
110
+ parent = (await selectParent(null, branch)) ?? undefined;
111
+ if (!parent) {
112
+ p.cancel("Cancelled");
113
+ process.exit(0);
114
+ }
115
+ }
116
+
117
+ p.log.info(`Parent: ${pc.yellow(parent)}`);
118
+
119
+ // Auto-detect PR number
120
+ const s = p.spinner();
121
+ s.start("Looking for PR...");
122
+ const prNumber = await getPrNumber(branch);
123
+ s.stop(prNumber ? `Found PR #${prNumber}` : "No PR found");
124
+
125
+ // Get branch description
126
+ let branchDesc: string | undefined;
127
+ if (!nameFlag) {
128
+ // Only prompt for branch description in interactive mode
129
+ const result = await p.text({
130
+ message: "Branch description (optional)",
131
+ placeholder: "e.g., Backend models",
132
+ });
133
+ if (!p.isCancel(result)) {
134
+ branchDesc = (result as string) || undefined;
135
+ }
136
+ }
137
+
138
+ // Add branch to stack
139
+ const branchData: Branch = {
140
+ parent,
141
+ ...(prNumber && { pr: prNumber }),
142
+ ...(branchDesc && { description: branchDesc }),
143
+ };
144
+
145
+ meta = await addBranchToStack(meta, stackName, branch, branchData);
146
+
147
+ p.log.success(`Added ${pc.yellow(branch)} to stack`);
148
+
149
+ p.outro(pc.green("Stack initialized!"));
150
+
151
+ console.log();
152
+ console.log(" Next steps:");
153
+ console.log(` ${pc.blue("gh-stack add")} — Add more branches`);
154
+ console.log(` ${pc.blue("gh-stack")} — View stack`);
155
+ console.log(` ${pc.blue("gh-stack restack")} — Sync stack`);
156
+ }