gh-stack 0.1.2 → 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 +3 -3
- 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} +9 -9
- 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/submit.ts +310 -0
- package/src/commands/top.ts +79 -0
- package/src/commands/up.ts +82 -0
- package/src/index.ts +52 -20
- package/src/lib/cli.ts +1 -1
- package/src/lib/git.ts +11 -0
- package/src/lib/safety.ts +1 -1
- package/src/commands/add.ts +0 -140
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
|
}
|
|
@@ -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
|
+
}
|