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.
@@ -0,0 +1,64 @@
1
+ // gh-stack undo — Restore from last snapshot
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
5
+ import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
6
+ import { confirmAction } from "../lib/ui.ts";
7
+ import { $ } from "bun";
8
+
9
+ export default async function undo(args: string[]): Promise<void> {
10
+ if (args.includes("--help")) {
11
+ console.log(`
12
+ gh-stack undo — Restore from last snapshot
13
+
14
+ USAGE
15
+ gh-stack undo
16
+
17
+ Restores branch HEADs from the last snapshot taken before
18
+ a destructive operation (restack, merge, sync, remove).
19
+ `);
20
+ return;
21
+ }
22
+
23
+ await ensureCleanWorkingTree();
24
+
25
+ const meta = await ensureMetadata();
26
+
27
+ const snapshot = getLastSnapshot(meta);
28
+ if (!snapshot) {
29
+ p.cancel("No snapshots available to undo");
30
+ process.exit(1);
31
+ }
32
+
33
+ p.intro(pc.cyan("Undo Last Operation"));
34
+
35
+ p.log.info(`Operation: ${pc.yellow(snapshot.operation)}`);
36
+ p.log.info(`Timestamp: ${pc.dim(snapshot.timestamp)}`);
37
+ console.log();
38
+ p.log.info("Will restore:");
39
+ for (const [branch, sha] of Object.entries(snapshot.branches)) {
40
+ console.log(` ${branch} → ${pc.dim(sha.slice(0, 8))}`);
41
+ }
42
+ console.log();
43
+
44
+ const confirmed = await confirmAction("Restore these branch positions?");
45
+ if (!confirmed) {
46
+ p.cancel("Cancelled");
47
+ process.exit(0);
48
+ }
49
+
50
+ // Restore each branch HEAD
51
+ for (const [branch, sha] of Object.entries(snapshot.branches)) {
52
+ try {
53
+ await $`git branch -f ${branch} ${sha}`.quiet();
54
+ p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
55
+ } catch {
56
+ p.log.error(`Failed to restore ${branch}`);
57
+ }
58
+ }
59
+
60
+ // Pop the snapshot
61
+ await popSnapshot(meta);
62
+
63
+ p.outro(pc.green("Undo complete!"));
64
+ }
@@ -0,0 +1,177 @@
1
+ // gh-stack update-prs — Update PR descriptions with stack visualization
2
+ // Port of reference/gh-stack-update-pr.sh
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import * as git from "../lib/git.ts";
6
+ import { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
7
+ import { ensureMetadata } from "../lib/safety.ts";
8
+ import { getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
9
+
10
+ export default async function updatePrs(args: string[]): Promise<void> {
11
+ if (args.includes("--help")) {
12
+ console.log(`
13
+ gh-stack update-prs — Update PR descriptions with stack visualization
14
+
15
+ USAGE
16
+ gh-stack update-prs
17
+
18
+ Updates all PRs in the current stack with a standardized stack section
19
+ showing the tree structure, PR links, and review status.
20
+ `);
21
+ return;
22
+ }
23
+
24
+ const meta = await ensureMetadata();
25
+ const branch = await git.currentBranch();
26
+ const stackName = findStackForBranch(meta, branch);
27
+
28
+ if (!stackName) {
29
+ p.cancel(`Branch ${pc.blue(branch)} not found in any stack`);
30
+ process.exit(1);
31
+ }
32
+
33
+ const stack = meta.stacks[stackName]!;
34
+ const ordered = getOrderedBranches(stack);
35
+
36
+ p.intro(pc.cyan("Update PR Descriptions"));
37
+ p.log.info(`Stack: ${pc.yellow(stackName)}`);
38
+ p.log.info(`Found ${ordered.length} branch(es) in stack`);
39
+ console.log();
40
+
41
+ // Collect PR info for all branches
42
+ const s = p.spinner();
43
+ s.start("Fetching PR info from GitHub...");
44
+
45
+ interface BranchPrInfo {
46
+ branch: string;
47
+ prNumber: number | null;
48
+ prTitle: string;
49
+ reviewEmojiStr: string;
50
+ }
51
+
52
+ const branchInfos: BranchPrInfo[] = [];
53
+
54
+ for (const branchName of ordered) {
55
+ const branchMeta = stack.branches[branchName]!;
56
+ const prNumber = branchMeta.pr ?? null;
57
+ let prTitle = branchMeta.description || branchName;
58
+ let review = "PENDING";
59
+
60
+ if (prNumber) {
61
+ const info = await getPrInfo(prNumber);
62
+ if (info) {
63
+ prTitle = info.title || prTitle;
64
+ review = info.reviewDecision || "PENDING";
65
+ }
66
+ }
67
+
68
+ branchInfos.push({
69
+ branch: branchName,
70
+ prNumber,
71
+ prTitle,
72
+ reviewEmojiStr: reviewEmoji(review),
73
+ });
74
+ }
75
+
76
+ s.stop("Fetched PR info");
77
+
78
+ // Update each PR
79
+ let updated = 0;
80
+ let skipped = 0;
81
+
82
+ for (let targetIdx = 0; targetIdx < branchInfos.length; targetIdx++) {
83
+ const target = branchInfos[targetIdx]!;
84
+
85
+ if (!target.prNumber) {
86
+ p.log.info(`${pc.dim("⏭")} ${target.branch} — no PR, skipping`);
87
+ skipped++;
88
+ continue;
89
+ }
90
+
91
+ // Build stack visualization for this PR
92
+ const stackViz = buildStackViz(branchInfos, targetIdx);
93
+
94
+ // Get current PR body and replace/append stack section
95
+ const currentBody = await getPrBody(target.prNumber);
96
+ if (currentBody === null) {
97
+ p.log.warn(`Could not fetch body for PR #${target.prNumber}`);
98
+ skipped++;
99
+ continue;
100
+ }
101
+
102
+ // Remove existing stack section
103
+ let updatedBody = currentBody;
104
+ const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
105
+ if (stackSectionIdx !== -1) {
106
+ updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
107
+ }
108
+
109
+ // Append new stack section
110
+ const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
111
+
112
+ // Update PR
113
+ const ok = await updatePrBody(target.prNumber, newBody);
114
+ if (ok) {
115
+ p.log.success(`PR #${target.prNumber} ${target.prTitle}`);
116
+ updated++;
117
+ } else {
118
+ p.log.error(`Failed to update PR #${target.prNumber}`);
119
+ }
120
+ }
121
+
122
+ console.log();
123
+ p.outro(
124
+ pc.green(
125
+ `Done! Updated ${updated} PR(s)${skipped > 0 ? `, skipped ${skipped} without PRs` : ""}`,
126
+ ),
127
+ );
128
+ }
129
+
130
+ interface BranchPrInfo {
131
+ branch: string;
132
+ prNumber: number | null;
133
+ prTitle: string;
134
+ reviewEmojiStr: string;
135
+ }
136
+
137
+ function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
138
+ const lines: string[] = ["### 📚 Stacked on", ""];
139
+
140
+ if (branches.length === 1) {
141
+ lines.push("- ⚫ **main**");
142
+ return lines.join("\n");
143
+ }
144
+
145
+ lines.push("<pre>");
146
+ lines.push("⚫ main");
147
+ lines.push("┃");
148
+
149
+ for (let i = 0; i < branches.length; i++) {
150
+ const info = branches[i]!;
151
+ const isLast = i === branches.length - 1;
152
+ const isTarget = i === targetIndex;
153
+
154
+ // PR link
155
+ let prLink: string;
156
+ if (info.prNumber) {
157
+ prLink = `<a href="https://github.com/beehiiv/swarm/pull/${info.prNumber}">#${info.prNumber}</a>`;
158
+ } else {
159
+ prLink = "(no PR yet)";
160
+ }
161
+
162
+ // 👈 marker for the current PR
163
+ const marker = isTarget ? " 👈" : "";
164
+
165
+ // Tree character
166
+ const tree = isLast ? "┗━" : "┣━";
167
+
168
+ lines.push(`${tree} ${info.reviewEmojiStr} ${prLink} ${info.prTitle}${marker}`);
169
+
170
+ if (!isLast) {
171
+ lines.push("┃");
172
+ }
173
+ }
174
+
175
+ lines.push("</pre>");
176
+ return lines.join("\n");
177
+ }
package/src/index.ts ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env bun
2
+ // gh-stack — Unified stacked PR manager for squash-merge workflows
3
+
4
+ // Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
5
+ if (process.env.GH_STACK_NO_COLOR) {
6
+ process.env.NO_COLOR = "1";
7
+ }
8
+
9
+ import { ensureGitRepo } from "./lib/safety.ts";
10
+ import { setAutoYes } from "./lib/ui.ts";
11
+
12
+ const { version: VERSION } = await import("../package.json");
13
+
14
+ const args = process.argv.slice(2);
15
+ const command = args[0] || "show";
16
+
17
+ // Handle global flags
18
+ if (args.includes("--version") || args.includes("-V")) {
19
+ console.log(`gh-stack v${VERSION}`);
20
+ process.exit(0);
21
+ }
22
+
23
+ if (args.includes("--help") && !args[0]) {
24
+ printHelp();
25
+ process.exit(0);
26
+ }
27
+
28
+ // Global --yes flag: skip all interactive confirmations (for agents/CI)
29
+ if (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES === "1") {
30
+ setAutoYes(true);
31
+ }
32
+
33
+ // Ensure we're in a git repo for all commands
34
+ await ensureGitRepo();
35
+
36
+ // Route to subcommand — strip global flags from command args
37
+ const commandArgs = args.slice(1).filter((a) => a !== "--yes" && a !== "-y");
38
+
39
+ switch (command) {
40
+ case "show":
41
+ await (await import("./commands/show.ts")).default(commandArgs);
42
+ break;
43
+
44
+ case "init":
45
+ await (await import("./commands/init.ts")).default(commandArgs);
46
+ break;
47
+
48
+ case "add":
49
+ await (await import("./commands/add.ts")).default(commandArgs);
50
+ break;
51
+
52
+ case "remove":
53
+ await (await import("./commands/remove.ts")).default(commandArgs);
54
+ break;
55
+
56
+ case "switch":
57
+ await (await import("./commands/switch.ts")).default(commandArgs);
58
+ break;
59
+
60
+ case "list":
61
+ case "ls":
62
+ await (await import("./commands/list.ts")).default(commandArgs);
63
+ break;
64
+
65
+ case "restack":
66
+ case "rebase":
67
+ await (await import("./commands/restack.ts")).default(commandArgs);
68
+ break;
69
+
70
+ case "sync":
71
+ await (await import("./commands/sync.ts")).default(commandArgs);
72
+ break;
73
+
74
+ case "update-prs":
75
+ await (await import("./commands/update-prs.ts")).default(commandArgs);
76
+ break;
77
+
78
+ case "status":
79
+ await (await import("./commands/status.ts")).default(commandArgs);
80
+ break;
81
+
82
+ case "merge":
83
+ await (await import("./commands/merge.ts")).default(commandArgs);
84
+ break;
85
+
86
+ case "undo":
87
+ await (await import("./commands/undo.ts")).default(commandArgs);
88
+ break;
89
+
90
+ case "archive":
91
+ await (await import("./commands/archive.ts")).default(commandArgs);
92
+ break;
93
+
94
+ case "--help":
95
+ case "help":
96
+ printHelp();
97
+ break;
98
+
99
+ default:
100
+ console.error(`Unknown command: ${command}\n`);
101
+ printHelp();
102
+ process.exit(1);
103
+ }
104
+
105
+ function printHelp() {
106
+ console.log(`
107
+ gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
108
+
109
+ ${bold("USAGE")}
110
+ gh-stack <command> [options]
111
+
112
+ ${bold("COMMANDS")}
113
+ ${green("show")} Display current stack tree ${dim("(default)")}
114
+ ${green("list")} List branches with numbers ${dim("(alias: ls)")}
115
+ ${green("init")} Create a new stack
116
+ ${green("add")} Add a branch to the current stack
117
+ ${green("remove")} Remove a branch from the stack
118
+ ${green("switch")} Switch branch or stack
119
+ ${green("restack")} Rebase children onto updated parents
120
+ ${green("sync")} Sync base with main + restack all
121
+ ${green("update-prs")} Update PR descriptions with stack viz
122
+ ${green("status")} PR dashboard (CI, reviews)
123
+ ${green("merge")} Local squash-merge top-down
124
+ ${green("undo")} Restore from last snapshot
125
+ ${green("archive")} Manage archived stacks
126
+
127
+ ${bold("GLOBAL OPTIONS")}
128
+ --yes, -y Skip confirmations ${dim("(for agents/CI)")}
129
+ --help Show help
130
+ --version, -V Show version
131
+
132
+ ${bold("ENVIRONMENT")}
133
+ GH_STACK_YES=1 Same as --yes
134
+ GH_STACK_NO_COLOR Disable colored output
135
+ `);
136
+ }
137
+
138
+ function bold(s: string) {
139
+ return process.env.NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`;
140
+ }
141
+ function green(s: string) {
142
+ return process.env.NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`;
143
+ }
144
+ function dim(s: string) {
145
+ return process.env.NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`;
146
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,237 @@
1
+ // Git command helpers using Bun's shell API
2
+ import { $ } from "bun";
3
+
4
+ /**
5
+ * Get the current branch name.
6
+ * Throws if in detached HEAD state.
7
+ */
8
+ export async function currentBranch(): Promise<string> {
9
+ const result = await $`git rev-parse --abbrev-ref HEAD`.text();
10
+ const branch = result.trim();
11
+ if (branch === "HEAD") {
12
+ throw new Error("Detached HEAD state — please checkout a branch first");
13
+ }
14
+ return branch;
15
+ }
16
+
17
+ /**
18
+ * Check if we're inside a git repository.
19
+ */
20
+ export async function isGitRepo(): Promise<boolean> {
21
+ try {
22
+ await $`git rev-parse --git-dir`.quiet();
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get the path to the .git directory (handles worktrees too).
31
+ */
32
+ export async function gitDir(): Promise<string> {
33
+ const result = await $`git rev-parse --git-dir`.text();
34
+ return result.trim();
35
+ }
36
+
37
+ /**
38
+ * Check if working tree is clean (no uncommitted changes).
39
+ */
40
+ export async function isCleanWorkingTree(): Promise<boolean> {
41
+ try {
42
+ await $`git diff-index --quiet HEAD --`.quiet();
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if a rebase is currently in progress.
51
+ */
52
+ export async function isRebaseInProgress(): Promise<boolean> {
53
+ const dir = await gitDir();
54
+
55
+ // Check if either directory exists by trying to read a known file
56
+ try {
57
+ const stat1 = await Bun.file(`${dir}/rebase-merge/interactive`).exists();
58
+ if (stat1) return true;
59
+ } catch {}
60
+
61
+ try {
62
+ const stat2 = await Bun.file(`${dir}/rebase-apply/rebasing`).exists();
63
+ if (stat2) return true;
64
+ } catch {}
65
+
66
+ // Also check the directories themselves
67
+ const { exitCode: code1 } = await $`test -d ${dir}/rebase-merge`.nothrow().quiet();
68
+ const { exitCode: code2 } = await $`test -d ${dir}/rebase-apply`.nothrow().quiet();
69
+
70
+ return code1 === 0 || code2 === 0;
71
+ }
72
+
73
+ /**
74
+ * Checkout a branch.
75
+ */
76
+ export async function checkout(branch: string): Promise<void> {
77
+ await $`git checkout ${branch}`.quiet();
78
+ }
79
+
80
+ /**
81
+ * Create and checkout a new branch from the current HEAD.
82
+ */
83
+ export async function createBranch(name: string): Promise<void> {
84
+ await $`git checkout -b ${name}`;
85
+ }
86
+
87
+ /**
88
+ * Get the SHA of a ref.
89
+ */
90
+ export async function revParse(ref: string): Promise<string> {
91
+ const result = await $`git rev-parse ${ref}`.text();
92
+ return result.trim();
93
+ }
94
+
95
+ /**
96
+ * Get the merge-base of two refs.
97
+ * Returns null if no common ancestor is found.
98
+ */
99
+ export async function mergeBase(ref1: string, ref2: string): Promise<string | null> {
100
+ try {
101
+ const result = await $`git merge-base ${ref1} ${ref2}`.text();
102
+ return result.trim();
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Check if ref1 is an ancestor of ref2.
110
+ */
111
+ export async function isAncestor(ancestor: string, descendant: string): Promise<boolean> {
112
+ const { exitCode } = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`
113
+ .nothrow()
114
+ .quiet();
115
+ return exitCode === 0;
116
+ }
117
+
118
+ /**
119
+ * Force-push a branch with lease protection.
120
+ */
121
+ export async function forcePushWithLease(branch?: string): Promise<boolean> {
122
+ try {
123
+ if (branch) {
124
+ await $`git push --force-with-lease origin ${branch}`;
125
+ } else {
126
+ await $`git push --force-with-lease`;
127
+ }
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Check if a branch exists on the remote.
136
+ */
137
+ export async function remoteBranchExists(branch: string): Promise<boolean> {
138
+ const { exitCode } = await $`git ls-remote --exit-code --heads origin ${branch}`
139
+ .nothrow()
140
+ .quiet();
141
+ return exitCode === 0;
142
+ }
143
+
144
+ /**
145
+ * Create a temporary tag at a specific commit.
146
+ */
147
+ export async function createTag(name: string, commit: string): Promise<void> {
148
+ await $`git tag -f ${name} ${commit}`.quiet();
149
+ }
150
+
151
+ /**
152
+ * Delete tags matching a pattern.
153
+ */
154
+ export async function deleteTagsMatching(pattern: string): Promise<void> {
155
+ try {
156
+ const result = await $`git tag -l ${pattern}`.text();
157
+ const tags = result.trim().split("\n").filter(Boolean);
158
+ for (const tag of tags) {
159
+ await $`git tag -d ${tag}`.quiet();
160
+ }
161
+ } catch {
162
+ // No tags to delete — that's fine
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check if a tag exists.
168
+ */
169
+ export async function tagExists(name: string): Promise<boolean> {
170
+ const { exitCode } = await $`git rev-parse --verify ${name}`.nothrow().quiet();
171
+ return exitCode === 0;
172
+ }
173
+
174
+ /**
175
+ * Perform a rebase: rebase branch onto newBase, starting from oldBase.
176
+ * Uses `git rebase --onto <newBase> <oldBase> <branch>`.
177
+ * Returns true if successful, false if conflicts occurred.
178
+ */
179
+ export async function rebaseOnto(
180
+ newBase: string,
181
+ oldBase: string,
182
+ branch: string,
183
+ ): Promise<boolean> {
184
+ const { exitCode } = await $`git rebase --onto ${newBase} ${oldBase} ${branch}`.nothrow();
185
+ return exitCode === 0;
186
+ }
187
+
188
+ /**
189
+ * Simple rebase onto a target.
190
+ * Returns true if successful, false if conflicts occurred.
191
+ */
192
+ export async function rebase(target: string): Promise<boolean> {
193
+ const { exitCode } = await $`git rebase ${target}`.nothrow();
194
+ return exitCode === 0;
195
+ }
196
+
197
+ /**
198
+ * Squash-merge a branch into the current branch.
199
+ * Returns true if successful.
200
+ */
201
+ export async function mergeSquash(branch: string): Promise<boolean> {
202
+ const { exitCode } = await $`git merge --squash ${branch}`.nothrow();
203
+ return exitCode === 0;
204
+ }
205
+
206
+ /**
207
+ * Commit with a message.
208
+ */
209
+ export async function commit(message: string): Promise<void> {
210
+ await $`git commit -m ${message}`;
211
+ }
212
+
213
+ /**
214
+ * Fetch origin main.
215
+ */
216
+ export async function fetchMain(): Promise<void> {
217
+ await $`git fetch origin main`.quiet();
218
+ }
219
+
220
+ /**
221
+ * Get count of commits between two refs.
222
+ */
223
+ export async function commitCount(from: string, to: string): Promise<number> {
224
+ try {
225
+ const result = await $`git rev-list --count ${from}..${to}`.text();
226
+ return parseInt(result.trim(), 10);
227
+ } catch {
228
+ return 0;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Sanitize a branch name for use in tag names.
234
+ */
235
+ export function sanitizeBranchForTag(branch: string): string {
236
+ return branch.replace(/[^a-zA-Z0-9-]/g, "_");
237
+ }