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
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// gh-stack submit — Push branches and create/update PRs
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { $ } from "bun";
|
|
5
|
+
import * as git from "../lib/git.ts";
|
|
6
|
+
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
|
+
import { ensureMetadata } from "../lib/safety.ts";
|
|
8
|
+
import { isAutoYes } from "../lib/ui.ts";
|
|
9
|
+
import { getPrNumber, getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
|
|
10
|
+
import { buildStackViz } from "./update-prs.ts";
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
gh-stack submit — Push branches and create/update PRs
|
|
14
|
+
|
|
15
|
+
USAGE
|
|
16
|
+
gh-stack submit [options]
|
|
17
|
+
|
|
18
|
+
Pushes all branches from trunk to the current branch, creating PRs
|
|
19
|
+
for branches that don't have them and updating stack visualization
|
|
20
|
+
in all PR descriptions. Idempotent — safe to run repeatedly.
|
|
21
|
+
|
|
22
|
+
OPTIONS
|
|
23
|
+
-d, --draft Create new PRs as drafts
|
|
24
|
+
-n, --no-edit Don't prompt for PR titles (auto-generate from branch name)
|
|
25
|
+
--dry-run Show what would happen without doing anything
|
|
26
|
+
|
|
27
|
+
EXAMPLES
|
|
28
|
+
gh-stack submit # Push + create PRs interactively
|
|
29
|
+
gh-stack submit -n # Push + create PRs with auto-titles
|
|
30
|
+
gh-stack submit --draft # Create new PRs as drafts
|
|
31
|
+
gh-stack submit --dry-run # Preview what would happen
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate a PR title from a branch name.
|
|
36
|
+
* Strips common prefixes like "kiliman/", replaces hyphens with spaces,
|
|
37
|
+
* and capitalizes the first letter.
|
|
38
|
+
*/
|
|
39
|
+
function branchToTitle(branch: string): string {
|
|
40
|
+
// Strip common prefixes like "kiliman/", "user/"
|
|
41
|
+
let name = branch.includes("/") ? branch.split("/").slice(1).join("/") : branch;
|
|
42
|
+
// Replace hyphens with spaces
|
|
43
|
+
name = name.replace(/-/g, " ");
|
|
44
|
+
// Capitalize first letter
|
|
45
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Push a branch to origin with upstream tracking and force-with-lease.
|
|
50
|
+
*/
|
|
51
|
+
async function pushBranch(branch: string): Promise<boolean> {
|
|
52
|
+
try {
|
|
53
|
+
await $`git push -u --force-with-lease origin ${branch}`.quiet();
|
|
54
|
+
return true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default async function submit(args: string[]): Promise<void> {
|
|
61
|
+
if (args.includes("--help")) {
|
|
62
|
+
console.log(HELP);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const draftFlag = args.includes("--draft") || args.includes("-d");
|
|
67
|
+
const dryRun = args.includes("--dry-run");
|
|
68
|
+
const noEdit = args.includes("--no-edit") || args.includes("-n") || isAutoYes();
|
|
69
|
+
|
|
70
|
+
const meta = await ensureMetadata();
|
|
71
|
+
const branch = await git.currentBranch();
|
|
72
|
+
const stackName = findStackForBranch(meta, branch);
|
|
73
|
+
|
|
74
|
+
if (!stackName) {
|
|
75
|
+
p.cancel(`Branch ${pc.blue(branch)} not found in any stack`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const stack = meta.stacks[stackName]!;
|
|
80
|
+
const ordered = getOrderedBranches(stack);
|
|
81
|
+
|
|
82
|
+
// Determine downstack scope: branches from trunk up to and including current
|
|
83
|
+
const currentIndex = ordered.indexOf(branch);
|
|
84
|
+
const scope = currentIndex >= 0 ? ordered.slice(0, currentIndex + 1) : ordered;
|
|
85
|
+
|
|
86
|
+
p.intro(pc.cyan("Submit Stack"));
|
|
87
|
+
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
88
|
+
p.log.info(
|
|
89
|
+
`Scope: ${scope.length} of ${ordered.length} branch(es) (downstack from ${pc.blue(branch)})`,
|
|
90
|
+
);
|
|
91
|
+
console.log();
|
|
92
|
+
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
p.log.info(pc.dim("Dry run — no changes will be made"));
|
|
95
|
+
console.log();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Phase 1: Push branches and create/update PRs ──
|
|
99
|
+
let pushed = 0;
|
|
100
|
+
let created = 0;
|
|
101
|
+
let existing = 0;
|
|
102
|
+
let metadataChanged = false;
|
|
103
|
+
|
|
104
|
+
for (const branchName of scope) {
|
|
105
|
+
const branchMeta = stack.branches[branchName]!;
|
|
106
|
+
const parentBranch = branchMeta.parent;
|
|
107
|
+
|
|
108
|
+
// ── Push ──
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
p.log.step(`${pc.dim("push")} ${branchName} → origin/${branchName}`);
|
|
111
|
+
} else {
|
|
112
|
+
const s = p.spinner();
|
|
113
|
+
s.start(`Pushing ${pc.blue(branchName)}...`);
|
|
114
|
+
const ok = await pushBranch(branchName);
|
|
115
|
+
if (ok) {
|
|
116
|
+
s.stop(`Pushed ${pc.blue(branchName)}`);
|
|
117
|
+
pushed++;
|
|
118
|
+
} else {
|
|
119
|
+
s.stop(pc.red(`Failed to push ${branchName}`));
|
|
120
|
+
p.cancel(`Push failed for ${pc.blue(branchName)}. Aborting.`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Check for existing PR ──
|
|
126
|
+
let prNumber = branchMeta.pr ?? null;
|
|
127
|
+
|
|
128
|
+
if (!prNumber && !dryRun) {
|
|
129
|
+
prNumber = await getPrNumber(branchName);
|
|
130
|
+
if (prNumber) {
|
|
131
|
+
// Found an existing PR not tracked in metadata — save it
|
|
132
|
+
branchMeta.pr = prNumber;
|
|
133
|
+
metadataChanged = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (prNumber) {
|
|
138
|
+
// ── Existing PR: update base if needed ──
|
|
139
|
+
existing++;
|
|
140
|
+
if (dryRun) {
|
|
141
|
+
p.log.step(`${pc.dim("exists")} PR #${prNumber} for ${branchName}`);
|
|
142
|
+
} else {
|
|
143
|
+
// Ensure the PR base is correct
|
|
144
|
+
try {
|
|
145
|
+
await $`gh pr edit ${prNumber} --base ${parentBranch}`.quiet();
|
|
146
|
+
} catch {
|
|
147
|
+
// Best effort — base might already be correct
|
|
148
|
+
}
|
|
149
|
+
p.log.info(`${pc.dim("✓")} PR #${prNumber} for ${pc.blue(branchName)}`);
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// ── Create new PR ──
|
|
153
|
+
const defaultTitle = branchToTitle(branchName);
|
|
154
|
+
let title = defaultTitle;
|
|
155
|
+
|
|
156
|
+
if (!noEdit && !dryRun) {
|
|
157
|
+
const input = await p.text({
|
|
158
|
+
message: `PR title for ${pc.blue(branchName)}`,
|
|
159
|
+
initialValue: defaultTitle,
|
|
160
|
+
placeholder: defaultTitle,
|
|
161
|
+
});
|
|
162
|
+
if (p.isCancel(input)) {
|
|
163
|
+
p.cancel("Cancelled");
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
title = (input as string).trim() || defaultTitle;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (dryRun) {
|
|
170
|
+
p.log.step(
|
|
171
|
+
`${pc.dim("create")} PR for ${branchName} → ${parentBranch} "${title}"${draftFlag ? " (draft)" : ""}`,
|
|
172
|
+
);
|
|
173
|
+
created++;
|
|
174
|
+
} else {
|
|
175
|
+
const s = p.spinner();
|
|
176
|
+
s.start(`Creating PR for ${pc.blue(branchName)}...`);
|
|
177
|
+
|
|
178
|
+
const ghArgs = [
|
|
179
|
+
"pr",
|
|
180
|
+
"create",
|
|
181
|
+
"--base",
|
|
182
|
+
parentBranch,
|
|
183
|
+
"--head",
|
|
184
|
+
branchName,
|
|
185
|
+
"--title",
|
|
186
|
+
title,
|
|
187
|
+
"--body",
|
|
188
|
+
"",
|
|
189
|
+
];
|
|
190
|
+
if (draftFlag) ghArgs.push("--draft");
|
|
191
|
+
|
|
192
|
+
const proc = Bun.spawn(["gh", ...ghArgs], {
|
|
193
|
+
stdout: "pipe",
|
|
194
|
+
stderr: "pipe",
|
|
195
|
+
});
|
|
196
|
+
const output = await new Response(proc.stdout).text();
|
|
197
|
+
const exitCode = await proc.exited;
|
|
198
|
+
|
|
199
|
+
if (exitCode === 0) {
|
|
200
|
+
// Extract PR number from URL output
|
|
201
|
+
const prUrl = output.trim();
|
|
202
|
+
const prNumMatch = prUrl.match(/\/pull\/(\d+)/);
|
|
203
|
+
const newPrNumber = prNumMatch ? parseInt(prNumMatch[1]!, 10) : null;
|
|
204
|
+
|
|
205
|
+
if (newPrNumber) {
|
|
206
|
+
branchMeta.pr = newPrNumber;
|
|
207
|
+
metadataChanged = true;
|
|
208
|
+
s.stop(`Created PR #${newPrNumber} for ${pc.blue(branchName)}`);
|
|
209
|
+
} else {
|
|
210
|
+
s.stop(`Created PR for ${pc.blue(branchName)} (could not parse PR number)`);
|
|
211
|
+
}
|
|
212
|
+
created++;
|
|
213
|
+
} else {
|
|
214
|
+
const stderr = await new Response(proc.stderr).text();
|
|
215
|
+
s.stop(pc.red(`Failed to create PR for ${branchName}`));
|
|
216
|
+
p.log.error(stderr.trim());
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Save metadata if we discovered or created PRs
|
|
223
|
+
if (metadataChanged && !dryRun) {
|
|
224
|
+
await writeMetadata(meta);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.log();
|
|
228
|
+
|
|
229
|
+
if (dryRun) {
|
|
230
|
+
p.outro(
|
|
231
|
+
pc.dim(
|
|
232
|
+
`Dry run complete: would push ${scope.length}, create ${created}, update ${existing} PR(s)`,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Phase 2: Update stack visualization in all PR descriptions ──
|
|
239
|
+
p.log.info(pc.cyan("Updating stack visualization..."));
|
|
240
|
+
|
|
241
|
+
// Collect PR info for ALL branches in the stack (not just scope)
|
|
242
|
+
interface BranchPrInfo {
|
|
243
|
+
branch: string;
|
|
244
|
+
prNumber: number | null;
|
|
245
|
+
prTitle: string;
|
|
246
|
+
prUrl: string | null;
|
|
247
|
+
reviewEmojiStr: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const branchInfos: BranchPrInfo[] = [];
|
|
251
|
+
|
|
252
|
+
for (const branchName of ordered) {
|
|
253
|
+
const branchMeta = stack.branches[branchName]!;
|
|
254
|
+
const prNum = branchMeta.pr ?? null;
|
|
255
|
+
let prTitle = branchMeta.description || branchName;
|
|
256
|
+
let prUrl: string | null = null;
|
|
257
|
+
let review = "PENDING";
|
|
258
|
+
|
|
259
|
+
if (prNum) {
|
|
260
|
+
const info = await getPrInfo(prNum);
|
|
261
|
+
if (info) {
|
|
262
|
+
prTitle = info.title || prTitle;
|
|
263
|
+
prUrl = info.url;
|
|
264
|
+
review = info.reviewDecision || "PENDING";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
branchInfos.push({
|
|
269
|
+
branch: branchName,
|
|
270
|
+
prNumber: prNum,
|
|
271
|
+
prTitle,
|
|
272
|
+
prUrl,
|
|
273
|
+
reviewEmojiStr: reviewEmoji(review),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Update each PR that has a number
|
|
278
|
+
let vizUpdated = 0;
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < branchInfos.length; i++) {
|
|
281
|
+
const info = branchInfos[i]!;
|
|
282
|
+
if (!info.prNumber) continue;
|
|
283
|
+
|
|
284
|
+
const stackViz = buildStackViz(branchInfos, i);
|
|
285
|
+
|
|
286
|
+
const currentBody = await getPrBody(info.prNumber);
|
|
287
|
+
if (currentBody === null) continue;
|
|
288
|
+
|
|
289
|
+
// Remove existing stack section
|
|
290
|
+
let updatedBody = currentBody;
|
|
291
|
+
const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
|
|
292
|
+
if (stackSectionIdx !== -1) {
|
|
293
|
+
updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Append new stack section
|
|
297
|
+
const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
|
|
298
|
+
|
|
299
|
+
const ok = await updatePrBody(info.prNumber, newBody);
|
|
300
|
+
if (ok) vizUpdated++;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Summary ──
|
|
304
|
+
console.log();
|
|
305
|
+
p.outro(
|
|
306
|
+
pc.green(
|
|
307
|
+
`Done! Pushed ${pushed} branch(es), created ${created} PR(s), updated ${vizUpdated} description(s)`,
|
|
308
|
+
),
|
|
309
|
+
);
|
|
310
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, saveRestackState } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
7
7
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
8
8
|
import { confirmAction } from "../lib/ui.ts";
|
|
9
9
|
|
|
@@ -36,6 +36,7 @@ with the base branch included.
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const stack = meta.stacks[stackName]!;
|
|
39
|
+
await ensureValidStack(meta, stackName);
|
|
39
40
|
const ordered = getOrderedBranches(stack);
|
|
40
41
|
|
|
41
42
|
if (ordered.length === 0) {
|
|
@@ -98,7 +99,7 @@ with the base branch included.
|
|
|
98
99
|
const parentRef = parent === "main" ? "origin/main" : parent;
|
|
99
100
|
const mb = await git.mergeBase(branch, parentRef);
|
|
100
101
|
if (mb) {
|
|
101
|
-
const tagName =
|
|
102
|
+
const tagName = git.tempBaseTagName(branch);
|
|
102
103
|
await git.createTag(tagName, mb);
|
|
103
104
|
console.log(
|
|
104
105
|
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// gh-stack top — Jump to the tip of the stack
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import * as git from "../lib/git.ts";
|
|
5
|
+
import {
|
|
6
|
+
findStackForBranch,
|
|
7
|
+
getOrderedBranches,
|
|
8
|
+
getChildren,
|
|
9
|
+
writeMetadata,
|
|
10
|
+
} from "../lib/metadata.ts";
|
|
11
|
+
import { ensureMetadata } from "../lib/safety.ts";
|
|
12
|
+
|
|
13
|
+
export default async function top(args: string[]): Promise<void> {
|
|
14
|
+
if (args.includes("--help")) {
|
|
15
|
+
console.log(`
|
|
16
|
+
gh-stack top — Jump to the tip of the stack
|
|
17
|
+
|
|
18
|
+
USAGE
|
|
19
|
+
gh-stack top
|
|
20
|
+
|
|
21
|
+
Switches to the topmost (leaf) branch of the current stack.
|
|
22
|
+
Prompts if there are multiple leaf branches.
|
|
23
|
+
`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const meta = await ensureMetadata();
|
|
28
|
+
const currentBranch = await git.currentBranch();
|
|
29
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
30
|
+
|
|
31
|
+
if (!stackName) {
|
|
32
|
+
p.cancel(`Branch ${pc.yellow(currentBranch)} is not in any stack`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stack = meta.stacks[stackName]!;
|
|
37
|
+
const ordered = getOrderedBranches(stack);
|
|
38
|
+
|
|
39
|
+
// Find leaf branches (no children)
|
|
40
|
+
const leaves = ordered.filter((b) => getChildren(stack, b).length === 0);
|
|
41
|
+
|
|
42
|
+
if (leaves.length === 0) {
|
|
43
|
+
p.log.warn("No branches in stack");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let target: string;
|
|
48
|
+
if (leaves.length === 1) {
|
|
49
|
+
target = leaves[0]!;
|
|
50
|
+
} else {
|
|
51
|
+
const result = await p.select({
|
|
52
|
+
message: "Multiple tips — which branch?",
|
|
53
|
+
options: leaves.map((l) => ({
|
|
54
|
+
value: l,
|
|
55
|
+
label: l,
|
|
56
|
+
hint: stack.branches[l]?.pr ? `#${stack.branches[l]!.pr}` : undefined,
|
|
57
|
+
})),
|
|
58
|
+
});
|
|
59
|
+
if (p.isCancel(result)) {
|
|
60
|
+
p.cancel("Cancelled");
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
target = result as string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (target === currentBranch) {
|
|
67
|
+
p.log.info(`Already at the top: ${pc.yellow(currentBranch)}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await git.checkout(target);
|
|
72
|
+
|
|
73
|
+
// Update tracking
|
|
74
|
+
stack.last_branch = target;
|
|
75
|
+
meta.current_stack = stackName;
|
|
76
|
+
await writeMetadata(meta);
|
|
77
|
+
|
|
78
|
+
p.log.success(`On ${pc.yellow(target)}`);
|
|
79
|
+
}
|
package/src/commands/undo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// gh-stack undo — Restore from last snapshot
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
|
+
import * as git from "../lib/git.ts";
|
|
3
4
|
import pc from "picocolors";
|
|
4
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
5
6
|
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
@@ -47,10 +48,21 @@ a destructive operation (restack, merge, sync, remove).
|
|
|
47
48
|
process.exit(0);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
let activeBranch: string | null = null;
|
|
52
|
+
try {
|
|
53
|
+
activeBranch = await git.currentBranch();
|
|
54
|
+
} catch {
|
|
55
|
+
// Detached HEAD is fine — we can restore all branches with branch -f.
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
// Restore each branch HEAD
|
|
51
59
|
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
52
60
|
try {
|
|
53
|
-
|
|
61
|
+
if (branch === activeBranch) {
|
|
62
|
+
await $`git reset --hard ${sha}`.quiet();
|
|
63
|
+
} else {
|
|
64
|
+
await $`git branch -f ${branch} ${sha}`.quiet();
|
|
65
|
+
}
|
|
54
66
|
p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
|
|
55
67
|
} catch {
|
|
56
68
|
p.log.error(`Failed to restore ${branch}`);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// gh-stack up — Move to child branch (upstack)
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import * as git from "../lib/git.ts";
|
|
5
|
+
import { findStackForBranch, getChildren, writeMetadata } from "../lib/metadata.ts";
|
|
6
|
+
import { ensureMetadata } from "../lib/safety.ts";
|
|
7
|
+
|
|
8
|
+
export default async function up(args: string[]): Promise<void> {
|
|
9
|
+
if (args.includes("--help")) {
|
|
10
|
+
console.log(`
|
|
11
|
+
gh-stack up — Move to child branch (upstack)
|
|
12
|
+
|
|
13
|
+
USAGE
|
|
14
|
+
gh-stack up [steps]
|
|
15
|
+
|
|
16
|
+
OPTIONS
|
|
17
|
+
[steps] Number of levels to move up (default: 1)
|
|
18
|
+
`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const steps = parseInt(args[0] || "1", 10);
|
|
23
|
+
if (isNaN(steps) || steps < 1) {
|
|
24
|
+
p.cancel("Invalid step count");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const meta = await ensureMetadata();
|
|
29
|
+
let currentBranch = await git.currentBranch();
|
|
30
|
+
const stackName = findStackForBranch(meta, currentBranch);
|
|
31
|
+
|
|
32
|
+
if (!stackName) {
|
|
33
|
+
p.cancel(`Branch ${pc.yellow(currentBranch)} is not in any stack`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stack = meta.stacks[stackName]!;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < steps; i++) {
|
|
40
|
+
const children = getChildren(stack, currentBranch);
|
|
41
|
+
|
|
42
|
+
if (children.length === 0) {
|
|
43
|
+
if (i === 0) {
|
|
44
|
+
p.log.warn(`${pc.yellow(currentBranch)} is already at the top of the stack`);
|
|
45
|
+
} else {
|
|
46
|
+
p.log.info(`Reached top of stack at ${pc.yellow(currentBranch)} after ${i} step(s)`);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let target: string;
|
|
52
|
+
if (children.length === 1) {
|
|
53
|
+
target = children[0]!;
|
|
54
|
+
} else {
|
|
55
|
+
// Multiple children — prompt
|
|
56
|
+
const result = await p.select({
|
|
57
|
+
message: "Multiple children — which branch?",
|
|
58
|
+
options: children.map((c) => ({
|
|
59
|
+
value: c,
|
|
60
|
+
label: c,
|
|
61
|
+
hint: stack.branches[c]?.pr ? `#${stack.branches[c]!.pr}` : undefined,
|
|
62
|
+
})),
|
|
63
|
+
});
|
|
64
|
+
if (p.isCancel(result)) {
|
|
65
|
+
p.cancel("Cancelled");
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
target = result as string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
currentBranch = target;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await git.checkout(currentBranch);
|
|
75
|
+
|
|
76
|
+
// Update tracking
|
|
77
|
+
stack.last_branch = currentBranch;
|
|
78
|
+
meta.current_stack = stackName;
|
|
79
|
+
await writeMetadata(meta);
|
|
80
|
+
|
|
81
|
+
p.log.success(`On ${pc.yellow(currentBranch)}`);
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,26 +7,28 @@ if (process.env.GH_STACK_NO_COLOR) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
|
+
import { parseCliArgs } from "./lib/cli.ts";
|
|
10
11
|
import { setAutoYes } from "./lib/ui.ts";
|
|
11
12
|
|
|
12
13
|
const { version: VERSION } = await import("../package.json");
|
|
13
14
|
|
|
14
15
|
const args = process.argv.slice(2);
|
|
15
|
-
const
|
|
16
|
+
const parsed = parseCliArgs(args, process.env);
|
|
17
|
+
const command = parsed.command;
|
|
16
18
|
|
|
17
19
|
// Handle global flags
|
|
18
|
-
if (
|
|
20
|
+
if (parsed.showVersion) {
|
|
19
21
|
console.log(`gh-stack v${VERSION}`);
|
|
20
22
|
process.exit(0);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
if (
|
|
25
|
+
if (parsed.showGlobalHelp) {
|
|
24
26
|
printHelp();
|
|
25
27
|
process.exit(0);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Global --yes flag: skip all interactive confirmations (for agents/CI)
|
|
29
|
-
if (
|
|
31
|
+
if (parsed.autoYes) {
|
|
30
32
|
setAutoYes(true);
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -34,30 +36,30 @@ if (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES ==
|
|
|
34
36
|
await ensureGitRepo();
|
|
35
37
|
|
|
36
38
|
// Route to subcommand — strip global flags from command args
|
|
37
|
-
const commandArgs =
|
|
39
|
+
const commandArgs = parsed.commandArgs;
|
|
38
40
|
|
|
39
41
|
switch (command) {
|
|
40
|
-
case "
|
|
41
|
-
await (await import("./commands/
|
|
42
|
+
case "log":
|
|
43
|
+
await (await import("./commands/log.ts")).default(commandArgs);
|
|
42
44
|
break;
|
|
43
45
|
|
|
44
46
|
case "init":
|
|
45
47
|
await (await import("./commands/init.ts")).default(commandArgs);
|
|
46
48
|
break;
|
|
47
49
|
|
|
48
|
-
case "
|
|
49
|
-
await (await import("./commands/
|
|
50
|
+
case "create":
|
|
51
|
+
await (await import("./commands/create.ts")).default(commandArgs);
|
|
50
52
|
break;
|
|
51
53
|
|
|
52
|
-
case "
|
|
53
|
-
await (await import("./commands/
|
|
54
|
+
case "delete":
|
|
55
|
+
await (await import("./commands/delete.ts")).default(commandArgs);
|
|
54
56
|
break;
|
|
55
57
|
|
|
56
|
-
case "
|
|
57
|
-
|
|
58
|
+
case "checkout":
|
|
59
|
+
case "co":
|
|
60
|
+
await (await import("./commands/checkout.ts")).default(commandArgs);
|
|
58
61
|
break;
|
|
59
62
|
|
|
60
|
-
case "list":
|
|
61
63
|
case "ls":
|
|
62
64
|
await (await import("./commands/list.ts")).default(commandArgs);
|
|
63
65
|
break;
|
|
@@ -71,8 +73,8 @@ switch (command) {
|
|
|
71
73
|
await (await import("./commands/sync.ts")).default(commandArgs);
|
|
72
74
|
break;
|
|
73
75
|
|
|
74
|
-
case "
|
|
75
|
-
await (await import("./commands/
|
|
76
|
+
case "submit":
|
|
77
|
+
await (await import("./commands/submit.ts")).default(commandArgs);
|
|
76
78
|
break;
|
|
77
79
|
|
|
78
80
|
case "status":
|
|
@@ -91,6 +93,22 @@ switch (command) {
|
|
|
91
93
|
await (await import("./commands/archive.ts")).default(commandArgs);
|
|
92
94
|
break;
|
|
93
95
|
|
|
96
|
+
case "up":
|
|
97
|
+
await (await import("./commands/up.ts")).default(commandArgs);
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case "down":
|
|
101
|
+
await (await import("./commands/down.ts")).default(commandArgs);
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case "top":
|
|
105
|
+
await (await import("./commands/top.ts")).default(commandArgs);
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case "bottom":
|
|
109
|
+
await (await import("./commands/bottom.ts")).default(commandArgs);
|
|
110
|
+
break;
|
|
111
|
+
|
|
94
112
|
case "--help":
|
|
95
113
|
case "help":
|
|
96
114
|
printHelp();
|
|
@@ -109,18 +127,34 @@ gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
|
|
|
109
127
|
${bold("USAGE")}
|
|
110
128
|
gh-stack <command> [options]
|
|
111
129
|
|
|
112
|
-
${bold("
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
${bold("TERMS")}
|
|
131
|
+
trunk Base branch of the repository (usually main)
|
|
132
|
+
stack A chain of dependent branches
|
|
133
|
+
upstack Branches that depend on the current branch (children)
|
|
134
|
+
downstack Branches the current branch depends on (ancestors)
|
|
135
|
+
|
|
136
|
+
${bold("CORE WORKFLOW")}
|
|
137
|
+
${green("init")} Create a new stack from the current branch
|
|
138
|
+
${green("create")} Create a new branch and add to the stack
|
|
139
|
+
${green("submit")} Push branches and create/update PRs
|
|
140
|
+
${green("log")} Display current stack tree ${dim("(default)")}
|
|
141
|
+
|
|
142
|
+
${bold("STACK NAVIGATION")}
|
|
143
|
+
${green("checkout")} Switch to a branch or stack ${dim("(alias: co)")}
|
|
144
|
+
${green("up")} Move to child branch (upstack)
|
|
145
|
+
${green("down")} Move to parent branch (downstack)
|
|
146
|
+
${green("top")} Jump to the tip of the stack
|
|
147
|
+
${green("bottom")} Jump to the base of the stack
|
|
148
|
+
${green("ls")} List branches with numbers
|
|
149
|
+
|
|
150
|
+
${bold("STACK MANAGEMENT")}
|
|
119
151
|
${green("restack")} Rebase children onto updated parents
|
|
120
152
|
${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
153
|
${green("merge")} Local squash-merge top-down
|
|
154
|
+
${green("delete")} Remove a branch from the stack
|
|
155
|
+
|
|
156
|
+
${bold("INFO & MAINTENANCE")}
|
|
157
|
+
${green("status")} PR dashboard (CI, reviews)
|
|
124
158
|
${green("undo")} Restore from last snapshot
|
|
125
159
|
${green("archive")} Manage archived stacks
|
|
126
160
|
|