gh-stack 0.1.1 → 0.2.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 +128 -93
- package/package.json +4 -4
- package/src/commands/bottom.ts +54 -0
- package/src/commands/{switch.ts → checkout.ts} +11 -8
- package/src/commands/create.ts +83 -0
- package/src/commands/{remove.ts → delete.ts} +11 -10
- package/src/commands/down.ts +67 -0
- package/src/commands/init.ts +142 -93
- package/src/commands/list.ts +1 -1
- package/src/commands/{show.ts → log.ts} +3 -3
- package/src/commands/merge.ts +2 -1
- package/src/commands/restack.ts +14 -8
- package/src/commands/submit.ts +310 -0
- package/src/commands/sync.ts +3 -2
- package/src/commands/top.ts +79 -0
- package/src/commands/undo.ts +13 -1
- package/src/commands/up.ts +82 -0
- package/src/index.ts +59 -25
- package/src/lib/cli.ts +32 -0
- package/src/lib/git.ts +36 -3
- package/src/lib/safety.ts +110 -2
- package/src/commands/add.ts +0 -139
package/src/commands/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// gh-stack init — Create a new stack
|
|
1
|
+
// gh-stack init — Create a new stack, auto-detecting branch chains
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
@@ -8,14 +8,80 @@ import {
|
|
|
8
8
|
createStack,
|
|
9
9
|
addBranchToStack,
|
|
10
10
|
metadataExists,
|
|
11
|
+
findStackForBranch,
|
|
11
12
|
} from "../lib/metadata.ts";
|
|
12
13
|
import { getPrNumber } from "../lib/github.ts";
|
|
13
|
-
import {
|
|
14
|
+
import { isAutoYes } from "../lib/ui.ts";
|
|
14
15
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
const HELP = `
|
|
18
|
+
gh-stack init — Create a new stack from the current branch
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
gh-stack init [options]
|
|
22
|
+
|
|
23
|
+
Creates a new stack and adds the current branch. If the current branch
|
|
24
|
+
is part of a chain of branches (e.g. main → feat-1 → feat-2), init
|
|
25
|
+
auto-detects the chain and offers to add all branches to the stack.
|
|
26
|
+
|
|
27
|
+
Best used from the TOP of your branch chain so all branches are found.
|
|
28
|
+
|
|
29
|
+
OPTIONS
|
|
30
|
+
--name <name> Stack name (default: current branch name)
|
|
31
|
+
--description <desc> Stack description
|
|
32
|
+
--parent <branch> Parent/trunk branch (default: main)
|
|
33
|
+
|
|
34
|
+
EXAMPLES
|
|
35
|
+
gh-stack init # Auto-detect chain, use defaults
|
|
36
|
+
gh-stack init --name my-feature # Custom stack name
|
|
37
|
+
gh-stack init --parent develop # Different trunk branch
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect a chain of branches from trunk to the current branch.
|
|
42
|
+
*
|
|
43
|
+
* Walks down from the current branch by finding local branches whose
|
|
44
|
+
* tips are strict ancestors of the current branch. Sorts by commit
|
|
45
|
+
* distance (closest = direct parent) to reconstruct the chain.
|
|
46
|
+
*
|
|
47
|
+
* Returns branches in bottom-up order: [closest-to-trunk, ..., current]
|
|
48
|
+
*/
|
|
49
|
+
async function detectBranchChain(currentBranch: string, trunk: string): Promise<string[]> {
|
|
50
|
+
const allBranches = await git.allLocalBranches();
|
|
51
|
+
|
|
52
|
+
// Branches to exclude: current, trunk, and always main/master
|
|
53
|
+
const excluded = new Set([currentBranch, trunk, "main", "master"]);
|
|
54
|
+
|
|
55
|
+
// Find branches that are strict ancestors of current (and not trunk)
|
|
56
|
+
const ancestors: { name: string; distance: number }[] = [];
|
|
57
|
+
|
|
58
|
+
for (const branch of allBranches) {
|
|
59
|
+
if (excluded.has(branch)) continue;
|
|
60
|
+
|
|
61
|
+
// Is this branch's tip an ancestor of current?
|
|
62
|
+
if (await git.isAncestor(branch, currentBranch)) {
|
|
63
|
+
// Skip branches already merged into trunk (stale/old branches)
|
|
64
|
+
if (await git.isAncestor(branch, trunk)) continue;
|
|
65
|
+
|
|
66
|
+
// How many commits between this branch and current?
|
|
67
|
+
const distance = await git.commitCount(branch, currentBranch);
|
|
68
|
+
if (distance > 0) {
|
|
69
|
+
ancestors.push({ name: branch, distance });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
18
73
|
|
|
74
|
+
// Sort by distance descending (furthest from current = closest to trunk)
|
|
75
|
+
ancestors.sort((a, b) => b.distance - a.distance);
|
|
76
|
+
|
|
77
|
+
// Build chain: ancestors (sorted trunk→current) + current branch
|
|
78
|
+
const chain = ancestors.map((a) => a.name);
|
|
79
|
+
chain.push(currentBranch);
|
|
80
|
+
|
|
81
|
+
return chain;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default async function init(args: string[]): Promise<void> {
|
|
19
85
|
// Parse flags
|
|
20
86
|
let nameFlag: string | undefined;
|
|
21
87
|
let descFlag: string | undefined;
|
|
@@ -33,25 +99,15 @@ export default async function init(args: string[]): Promise<void> {
|
|
|
33
99
|
parentFlag = args[++i];
|
|
34
100
|
break;
|
|
35
101
|
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
|
-
`);
|
|
102
|
+
console.log(HELP);
|
|
47
103
|
return;
|
|
48
104
|
}
|
|
49
105
|
}
|
|
50
106
|
|
|
51
|
-
|
|
107
|
+
const branch = await git.currentBranch();
|
|
108
|
+
const trunk = parentFlag || "main";
|
|
52
109
|
|
|
53
|
-
|
|
54
|
-
console.log();
|
|
110
|
+
p.intro(pc.cyan("Create New Stack"));
|
|
55
111
|
|
|
56
112
|
// Get or create metadata
|
|
57
113
|
let meta: StackMetadata;
|
|
@@ -59,98 +115,91 @@ OPTIONS
|
|
|
59
115
|
meta = (await readMetadata())!;
|
|
60
116
|
} else {
|
|
61
117
|
meta = await initMetadata();
|
|
62
|
-
p.log.success("Initialized empty stack metadata");
|
|
63
118
|
}
|
|
64
119
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
120
|
+
// Check if current branch is already in a stack
|
|
121
|
+
const existingStack = findStackForBranch(meta, branch);
|
|
122
|
+
if (existingStack) {
|
|
123
|
+
p.cancel(`Branch ${pc.yellow(branch)} is already in stack ${pc.yellow(existingStack)}`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Detect branch chain ──
|
|
128
|
+
const s = p.spinner();
|
|
129
|
+
s.start("Detecting branch chain...");
|
|
130
|
+
const chain = await detectBranchChain(branch, trunk);
|
|
131
|
+
s.stop(chain.length > 1 ? `Found ${chain.length} branches in chain` : `Single branch: ${branch}`);
|
|
132
|
+
|
|
133
|
+
// Show detected chain
|
|
134
|
+
if (chain.length > 1) {
|
|
135
|
+
console.log();
|
|
136
|
+
console.log(` ${pc.dim(trunk)}`);
|
|
137
|
+
for (let i = 0; i < chain.length; i++) {
|
|
138
|
+
const isLast = i === chain.length - 1;
|
|
139
|
+
const tree = isLast ? " ┗━" : " ┣━";
|
|
140
|
+
const label = chain[i] === branch ? pc.yellow(chain[i]!) + pc.dim(" (current)") : chain[i];
|
|
141
|
+
console.log(`${tree} ${label}`);
|
|
142
|
+
if (!isLast) console.log(" ┃");
|
|
143
|
+
}
|
|
144
|
+
console.log();
|
|
145
|
+
|
|
146
|
+
// Confirm chain
|
|
147
|
+
if (!isAutoYes()) {
|
|
148
|
+
const confirmed = await p.confirm({
|
|
149
|
+
message: "Create stack with all branches?",
|
|
150
|
+
});
|
|
151
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
152
|
+
p.cancel("Cancelled");
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
79
155
|
}
|
|
80
|
-
stackName = result as string;
|
|
81
156
|
}
|
|
82
157
|
|
|
83
|
-
//
|
|
158
|
+
// ── Determine stack name ──
|
|
159
|
+
const stackName = nameFlag || branch;
|
|
160
|
+
|
|
84
161
|
if (meta.stacks[stackName]) {
|
|
85
162
|
p.cancel(`Stack "${stackName}" already exists`);
|
|
86
163
|
process.exit(1);
|
|
87
164
|
}
|
|
88
165
|
|
|
89
|
-
//
|
|
90
|
-
|
|
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
|
-
}
|
|
166
|
+
// ── Create stack ──
|
|
167
|
+
meta = await createStack(meta, stackName, descFlag || "");
|
|
102
168
|
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
169
|
+
// ── Add branches with PR detection ──
|
|
170
|
+
const prSpinner = p.spinner();
|
|
171
|
+
prSpinner.start("Detecting PRs...");
|
|
116
172
|
|
|
117
|
-
|
|
173
|
+
let prCount = 0;
|
|
174
|
+
for (let i = 0; i < chain.length; i++) {
|
|
175
|
+
const branchName = chain[i]!;
|
|
176
|
+
const parent = i === 0 ? trunk : chain[i - 1]!;
|
|
118
177
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
}
|
|
178
|
+
// Auto-detect PR
|
|
179
|
+
const prNumber = await getPrNumber(branchName);
|
|
180
|
+
if (prNumber) prCount++;
|
|
137
181
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
...(branchDesc && { description: branchDesc }),
|
|
143
|
-
};
|
|
182
|
+
const branchData: Branch = {
|
|
183
|
+
parent,
|
|
184
|
+
...(prNumber && { pr: prNumber }),
|
|
185
|
+
};
|
|
144
186
|
|
|
145
|
-
|
|
187
|
+
meta = await addBranchToStack(meta, stackName, branchName, branchData);
|
|
188
|
+
}
|
|
146
189
|
|
|
147
|
-
|
|
190
|
+
prSpinner.stop(prCount > 0 ? `Found ${prCount} PR(s)` : "No PRs found");
|
|
148
191
|
|
|
149
|
-
p.outro(pc.green(
|
|
192
|
+
p.outro(pc.green(`Stack ${pc.yellow(stackName)} created with ${chain.length} branch(es)!`));
|
|
150
193
|
|
|
194
|
+
// Next steps
|
|
195
|
+
console.log();
|
|
196
|
+
if (chain.length === 1) {
|
|
197
|
+
console.log(" Next steps:");
|
|
198
|
+
console.log(` ${pc.blue("gh-stack create <branch>")} — Add a branch to the stack`);
|
|
199
|
+
} else {
|
|
200
|
+
console.log(" Next steps:");
|
|
201
|
+
console.log(` ${pc.blue("gh-stack submit")} — Push and create PRs`);
|
|
202
|
+
}
|
|
203
|
+
console.log(` ${pc.blue("gh-stack log")} — View stack`);
|
|
151
204
|
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
205
|
}
|
package/src/commands/list.ts
CHANGED
|
@@ -14,7 +14,7 @@ USAGE
|
|
|
14
14
|
gh-stack ls
|
|
15
15
|
|
|
16
16
|
Lightweight branch listing for quick reference and scripting.
|
|
17
|
-
Use the number with 'gh-stack
|
|
17
|
+
Use the number with 'gh-stack checkout <number>' to jump to a branch.
|
|
18
18
|
|
|
19
19
|
ALIASES
|
|
20
20
|
gh-stack ls
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// gh-stack
|
|
1
|
+
// gh-stack log — Display current stack tree (default command)
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "../lib/metadata.ts";
|
|
11
11
|
import { renderStackTree } from "../lib/ui.ts";
|
|
12
12
|
|
|
13
|
-
export default async function
|
|
13
|
+
export default async function log(_args: string[]): Promise<void> {
|
|
14
14
|
// Check if metadata exists
|
|
15
15
|
if (!(await metadataExists())) {
|
|
16
16
|
p.log.warn("No stack metadata found");
|
|
@@ -71,6 +71,6 @@ export default async function show(_args: string[]): Promise<void> {
|
|
|
71
71
|
console.log();
|
|
72
72
|
|
|
73
73
|
// Tip
|
|
74
|
-
console.log(pc.dim(`Tip: Switch stacks with 'gh-stack
|
|
74
|
+
console.log(pc.dim(`Tip: Switch stacks with 'gh-stack checkout --stack'`));
|
|
75
75
|
console.log();
|
|
76
76
|
}
|
package/src/commands/merge.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as p from "@clack/prompts";
|
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
7
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
8
8
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
9
|
import { closePr } from "../lib/github.ts";
|
|
10
10
|
import { confirmAction } from "../lib/ui.ts";
|
|
@@ -39,6 +39,7 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const stack = meta.stacks[stackName]!;
|
|
42
|
+
await ensureValidStack(meta, stackName);
|
|
42
43
|
const ordered = getOrderedBranches(stack);
|
|
43
44
|
|
|
44
45
|
if (ordered.length <= 1) {
|
package/src/commands/restack.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
loadRestackState,
|
|
11
11
|
clearRestackState,
|
|
12
12
|
} from "../lib/metadata.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ensureMetadata,
|
|
15
|
+
ensureCleanWorkingTree,
|
|
16
|
+
ensureNotMain,
|
|
17
|
+
ensureValidStack,
|
|
18
|
+
} from "../lib/safety.ts";
|
|
14
19
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
15
20
|
import { confirmAction } from "../lib/ui.ts";
|
|
16
21
|
import type { StackMetadata } from "../types.ts";
|
|
@@ -72,7 +77,7 @@ ALIASES
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
// Cleanup tags
|
|
75
|
-
await git.deleteTagsMatching(
|
|
80
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
76
81
|
|
|
77
82
|
p.outro(pc.green("Stack Restack Complete!"));
|
|
78
83
|
}
|
|
@@ -142,7 +147,7 @@ async function handleResume(
|
|
|
142
147
|
const parent = stack.branches[branch]?.parent;
|
|
143
148
|
if (!parent) continue;
|
|
144
149
|
|
|
145
|
-
const tagName =
|
|
150
|
+
const tagName = git.tempBaseTagName(branch);
|
|
146
151
|
if (await git.tagExists(tagName)) {
|
|
147
152
|
const tagSha = await git.revParse(tagName);
|
|
148
153
|
console.log(
|
|
@@ -183,6 +188,7 @@ async function handleFreshRestack(
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
const stack = meta.stacks[stackName]!;
|
|
191
|
+
await ensureValidStack(meta, stackName);
|
|
186
192
|
|
|
187
193
|
// Check for stale tags from previous runs
|
|
188
194
|
await handleStaleTags();
|
|
@@ -247,7 +253,7 @@ async function handleFreshRestack(
|
|
|
247
253
|
|
|
248
254
|
const mb = await git.mergeBase(branch, parent);
|
|
249
255
|
if (mb) {
|
|
250
|
-
const tagName =
|
|
256
|
+
const tagName = git.tempBaseTagName(branch);
|
|
251
257
|
await git.createTag(tagName, mb);
|
|
252
258
|
console.log(
|
|
253
259
|
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
@@ -319,7 +325,7 @@ async function processChain(
|
|
|
319
325
|
});
|
|
320
326
|
|
|
321
327
|
// Use temporary tag as stable base reference
|
|
322
|
-
const tagName =
|
|
328
|
+
const tagName = git.tempBaseTagName(branch);
|
|
323
329
|
let success: boolean;
|
|
324
330
|
|
|
325
331
|
if (await git.tagExists(tagName)) {
|
|
@@ -433,11 +439,11 @@ async function promptPush(branch: string): Promise<void> {
|
|
|
433
439
|
async function handleStaleTags(): Promise<void> {
|
|
434
440
|
try {
|
|
435
441
|
const { $ } = await import("bun");
|
|
436
|
-
const result = await $`git tag -l
|
|
442
|
+
const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
|
|
437
443
|
const tags = result.trim();
|
|
438
444
|
if (!tags) return;
|
|
439
445
|
|
|
440
|
-
p.log.warn(
|
|
446
|
+
p.log.warn(`Found stale ${git.STACK_SYNC_TAG_GLOB} tags from a previous run`);
|
|
441
447
|
console.log();
|
|
442
448
|
|
|
443
449
|
const action = await p.select({
|
|
@@ -462,7 +468,7 @@ async function handleStaleTags(): Promise<void> {
|
|
|
462
468
|
|
|
463
469
|
if (action === "clean") {
|
|
464
470
|
p.log.info("Cleaning up stale tags...");
|
|
465
|
-
await git.deleteTagsMatching(
|
|
471
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
466
472
|
p.log.success("Tags cleaned");
|
|
467
473
|
}
|
|
468
474
|
|