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 list — Lightweight branch listing (agent-friendly)
2
+ import pc from "picocolors";
3
+ import * as git from "../lib/git.ts";
4
+ import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
5
+ import { ensureMetadata } from "../lib/safety.ts";
6
+
7
+ export default async function list(args: string[]): Promise<void> {
8
+ if (args.includes("--help")) {
9
+ console.log(`
10
+ gh-stack list — List branches with numbers
11
+
12
+ USAGE
13
+ gh-stack list
14
+ gh-stack ls
15
+
16
+ Lightweight branch listing for quick reference and scripting.
17
+ Use the number with 'gh-stack switch <number>' to jump to a branch.
18
+
19
+ ALIASES
20
+ gh-stack ls
21
+ `);
22
+ return;
23
+ }
24
+
25
+ const meta = await ensureMetadata();
26
+ const currentBranch = await git.currentBranch();
27
+
28
+ // Find which stack contains the current branch
29
+ let stackName = findStackForBranch(meta, currentBranch);
30
+ if (!stackName) stackName = meta.current_stack;
31
+
32
+ if (!stackName || !meta.stacks[stackName]) {
33
+ console.log("No active stack found.");
34
+ process.exit(1);
35
+ }
36
+
37
+ const stack = meta.stacks[stackName]!;
38
+ const ordered = getOrderedBranches(stack);
39
+
40
+ // Update tracking
41
+ meta.current_stack = stackName;
42
+ stack.last_branch = currentBranch;
43
+ await writeMetadata(meta);
44
+
45
+ // Header
46
+ console.log(`${pc.dim("Stack:")} ${pc.yellow(stackName)}`);
47
+ if (stack.description) {
48
+ console.log(`${pc.dim(stack.description)}`);
49
+ }
50
+ console.log();
51
+
52
+ // List branches
53
+ for (let i = 0; i < ordered.length; i++) {
54
+ const branchName = ordered[i]!;
55
+ const branch = stack.branches[branchName]!;
56
+ const isCurrent = branchName === currentBranch;
57
+ const prNum = branch.pr ? pc.dim(`#${branch.pr}`) : "";
58
+ const marker = isCurrent ? pc.yellow(" (current)") : "";
59
+ const num = pc.blue(`[${i + 1}]`);
60
+
61
+ console.log(` ${num} ${isCurrent ? pc.yellow(branchName) : branchName}${marker} ${prNum}`);
62
+ }
63
+ console.log();
64
+ }
@@ -0,0 +1,175 @@
1
+ // gh-stack merge — Local squash-merge top-down
2
+ // From reference/gh-stack-merge-design.md
3
+ import * as p from "@clack/prompts";
4
+ import pc from "picocolors";
5
+ import * as git from "../lib/git.ts";
6
+ import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
7
+ import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
8
+ import { takeSnapshot } from "../lib/snapshot.ts";
9
+ import { closePr } from "../lib/github.ts";
10
+ import { confirmAction } from "../lib/ui.ts";
11
+
12
+ export default async function merge(args: string[]): Promise<void> {
13
+ const dryRun = args.includes("--dry-run");
14
+
15
+ if (args.includes("--help")) {
16
+ console.log(`
17
+ gh-stack merge — Local squash-merge top-down
18
+
19
+ USAGE
20
+ gh-stack merge [--dry-run]
21
+
22
+ Squash-merges the stack from top to bottom locally:
23
+ PR3 → PR2 → PR1, then optionally rebases PR1 onto main.
24
+
25
+ This keeps all commits local (avoiding orphaned squash commits).
26
+ `);
27
+ return;
28
+ }
29
+
30
+ await ensureCleanWorkingTree();
31
+
32
+ const meta = await ensureMetadata();
33
+ const currentBranch = await git.currentBranch();
34
+ const stackName = findStackForBranch(meta, currentBranch);
35
+
36
+ if (!stackName) {
37
+ p.cancel(`Branch ${pc.blue(currentBranch)} not found in any stack`);
38
+ process.exit(1);
39
+ }
40
+
41
+ const stack = meta.stacks[stackName]!;
42
+ const ordered = getOrderedBranches(stack);
43
+
44
+ if (ordered.length <= 1) {
45
+ p.log.info("Single branch stack — nothing to merge down.");
46
+ p.log.info("Just merge via GitHub as normal.");
47
+ return;
48
+ }
49
+
50
+ p.intro(pc.cyan("Git Stack Merge (Top-Down)"));
51
+
52
+ p.log.info(`Stack: ${pc.yellow(stackName)}`);
53
+ console.log();
54
+
55
+ // Show the merge plan: from top to bottom
56
+ const reversed = ordered.toReversed();
57
+ console.log(` ${pc.bold("Merge plan:")}`);
58
+ for (let i = 0; i < reversed.length - 1; i++) {
59
+ const child = reversed[i]!;
60
+ const parent = stack.branches[child]?.parent || "???";
61
+ const childPr = stack.branches[child]?.pr;
62
+ console.log(` ${pc.yellow(child)}${childPr ? ` (#${childPr})` : ""} → ${pc.blue(parent)}`);
63
+ }
64
+ console.log(` ${pc.blue(ordered[0]!)} → ${pc.green("main")} (via GitHub)`);
65
+ console.log();
66
+
67
+ if (dryRun) {
68
+ p.outro(pc.yellow("[DRY RUN] No changes made"));
69
+ return;
70
+ }
71
+
72
+ const confirmed = await confirmAction("Merge stack top-down?");
73
+ if (!confirmed) {
74
+ p.cancel("Cancelled");
75
+ process.exit(0);
76
+ }
77
+
78
+ // Take snapshot
79
+ await takeSnapshot(meta, stackName, "merge");
80
+
81
+ // Merge top-down: for each branch from top to bottom (skip the base)
82
+ for (let i = 0; i < reversed.length - 1; i++) {
83
+ const childBranch = reversed[i]!;
84
+ const parentBranch = stack.branches[childBranch]?.parent;
85
+ if (!parentBranch || parentBranch === "main") continue;
86
+
87
+ const childPr = stack.branches[childBranch]?.pr;
88
+ const childTitle = stack.branches[childBranch]?.description || childBranch;
89
+
90
+ console.log();
91
+ console.log(pc.cyan("━".repeat(40)));
92
+ console.log(`${pc.blue("Merge:")} ${pc.yellow(childBranch)} → ${pc.blue(parentBranch)}`);
93
+ console.log(pc.cyan("━".repeat(40)));
94
+ console.log();
95
+
96
+ // Checkout parent
97
+ p.log.info(`Checking out ${pc.yellow(parentBranch)}...`);
98
+ await git.checkout(parentBranch);
99
+
100
+ // Squash merge child
101
+ p.log.info(`Squash-merging ${pc.yellow(childBranch)}...`);
102
+ const success = await git.mergeSquash(childBranch);
103
+
104
+ if (!success) {
105
+ p.log.error("Merge conflict — resolve and try again");
106
+ process.exit(2);
107
+ }
108
+
109
+ // Commit
110
+ const commitMsg = childPr ? `squash: ${childTitle} (#${childPr})` : `squash: ${childTitle}`;
111
+
112
+ await git.commit(commitMsg);
113
+ p.log.success(`Merged ${childBranch} into ${parentBranch}`);
114
+ }
115
+
116
+ // Optionally rebase base onto main
117
+ console.log();
118
+ const baseBranch = ordered[0]!;
119
+ const rebaseConfirmed = await confirmAction(`Rebase ${pc.yellow(baseBranch)} onto latest main?`);
120
+
121
+ if (rebaseConfirmed) {
122
+ p.log.info("Fetching latest main...");
123
+ await git.fetchMain();
124
+
125
+ p.log.info(`Checking out ${pc.yellow(baseBranch)}...`);
126
+ await git.checkout(baseBranch);
127
+
128
+ const success = await git.rebase("origin/main");
129
+ if (success) {
130
+ p.log.success("Rebased onto main");
131
+ } else {
132
+ p.log.error("Rebase conflict — resolve manually");
133
+ process.exit(2);
134
+ }
135
+ }
136
+
137
+ // Close intermediate PRs
138
+ console.log();
139
+ const closePrs = await confirmAction("Close intermediate PRs on GitHub?");
140
+
141
+ if (closePrs) {
142
+ const basePr = stack.branches[ordered[0]!]?.pr;
143
+ for (let i = 1; i < ordered.length; i++) {
144
+ const prNum = stack.branches[ordered[i]!]?.pr;
145
+ if (prNum) {
146
+ const comment = basePr
147
+ ? `Merged locally into base PR #${basePr}. See the base PR for the full stack.`
148
+ : "Merged locally into base PR.";
149
+ const ok = await closePr(prNum, comment);
150
+ if (ok) {
151
+ p.log.success(`Closed PR #${prNum}`);
152
+ } else {
153
+ p.log.warn(`Could not close PR #${prNum}`);
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // Archive the stack
160
+ if (!meta.archive) meta.archive = {};
161
+ meta.archive[stackName] = { ...stack };
162
+ delete meta.stacks[stackName];
163
+ if (meta.current_stack === stackName) {
164
+ const remaining = Object.keys(meta.stacks);
165
+ meta.current_stack = remaining.length > 0 ? remaining[0]! : null;
166
+ }
167
+ await writeMetadata(meta);
168
+
169
+ p.outro(pc.green("Stack merge complete! Stack archived."));
170
+
171
+ console.log();
172
+ console.log(" Next steps:");
173
+ console.log(` 1. Push ${pc.yellow(baseBranch)} and let CI run`);
174
+ console.log(` 2. Squash-merge ${pc.yellow(baseBranch)} into main via GitHub`);
175
+ }
@@ -0,0 +1,78 @@
1
+ // gh-stack remove — Remove a branch from the stack (re-link parent chain)
2
+ import * as p from "@clack/prompts";
3
+ import pc from "picocolors";
4
+ import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
5
+ import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
6
+ import { takeSnapshot } from "../lib/snapshot.ts";
7
+ import { selectBranch } from "../lib/ui.ts";
8
+ import * as git from "../lib/git.ts";
9
+
10
+ export default async function remove(args: string[]): Promise<void> {
11
+ if (args.includes("--help")) {
12
+ console.log(`
13
+ gh-stack remove — Remove a branch from the stack
14
+
15
+ USAGE
16
+ gh-stack remove [<branch>]
17
+
18
+ Removes the branch and re-parents its children to its parent.
19
+ If no branch is specified, shows interactive selector.
20
+ `);
21
+ return;
22
+ }
23
+
24
+ const meta = await ensureMetadata();
25
+ const stackName = ensureCurrentStack(meta);
26
+ const stack = meta.stacks[stackName]!;
27
+ const currentBranch = await git.currentBranch();
28
+
29
+ p.intro(pc.cyan("Remove Branch from Stack"));
30
+
31
+ // Get branch to remove
32
+ let branchName = args[0];
33
+ if (!branchName) {
34
+ branchName = (await selectBranch(stack, "Select branch to remove", currentBranch)) ?? undefined;
35
+ if (!branchName) {
36
+ p.cancel("Cancelled");
37
+ process.exit(0);
38
+ }
39
+ }
40
+
41
+ // Validate
42
+ const branch = stack.branches[branchName];
43
+ if (!branch) {
44
+ p.cancel(`Branch ${pc.yellow(branchName)} not found in stack ${pc.blue(stackName)}`);
45
+ process.exit(1);
46
+ }
47
+
48
+ // Show what will happen
49
+ const children = getChildren(stack, branchName);
50
+ p.log.info(`Removing: ${pc.yellow(branchName)}`);
51
+ p.log.info(`Parent was: ${pc.dim(branch.parent)}`);
52
+
53
+ if (children.length > 0) {
54
+ p.log.info(
55
+ `Re-parenting ${children.length} child branch${children.length > 1 ? "es" : ""} to ${pc.dim(branch.parent)}`,
56
+ );
57
+ for (const child of children) {
58
+ console.log(` ${pc.dim("→")} ${child}`);
59
+ }
60
+ }
61
+
62
+ // Confirm
63
+ const confirmed = await p.confirm({
64
+ message: `Remove ${pc.yellow(branchName)} from stack?`,
65
+ });
66
+ if (p.isCancel(confirmed) || !confirmed) {
67
+ p.cancel("Cancelled");
68
+ process.exit(0);
69
+ }
70
+
71
+ // Take snapshot before destructive operation
72
+ await takeSnapshot(meta, stackName, "remove");
73
+
74
+ // Remove branch
75
+ await removeBranchFromStack(meta, stackName, branchName);
76
+
77
+ p.outro(pc.green(`Removed ${pc.yellow(branchName)} from stack ${pc.blue(stackName)}`));
78
+ }