gh-stack 0.18.0 → 0.19.1
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 +247 -18
- package/package.json +9 -8
- package/src/commands/config.ts +133 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +352 -12
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +144 -156
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +67 -17
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/safety.ts +12 -4
- package/src/lib/using-gh-stack.md +131 -5
- package/src/types.ts +62 -4
package/src/index.ts
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// gh-stack — Unified stacked PR manager for squash-merge workflows
|
|
3
3
|
|
|
4
|
-
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
|
|
5
|
-
//
|
|
6
|
-
// and
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it,
|
|
5
|
+
// and turn colors off for plain mode (--plain, GH_STACK_YES=1, GH_STACK_PLAIN=1)
|
|
6
|
+
// and --json (a machine reads the stderr logs, and error messages land inside
|
|
7
|
+
// the JSON itself).
|
|
8
|
+
//
|
|
9
|
+
// picocolors reads NO_COLOR once, when it first loads. So this file has NO
|
|
10
|
+
// static imports: those are evaluated before this body runs, and Bun evaluates
|
|
11
|
+
// a CommonJS dependency like picocolors even before an earlier ESM side-effect
|
|
12
|
+
// import. Everything that can pull in picocolors is imported dynamically below.
|
|
13
|
+
const earlyArgs = new Set(process.argv.slice(2));
|
|
14
|
+
const colorOff =
|
|
15
|
+
!!process.env.GH_STACK_NO_COLOR ||
|
|
16
|
+
earlyArgs.has("--plain") ||
|
|
17
|
+
earlyArgs.has("--json") ||
|
|
11
18
|
process.env.GH_STACK_YES === "1" ||
|
|
12
19
|
process.env.GH_STACK_PLAIN === "1";
|
|
13
|
-
if (process.env.
|
|
14
|
-
process.env.NO_COLOR = "1";
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
import { ensureGitRepo } from "./lib/safety.ts";
|
|
18
|
-
import { parseCliArgs } from "./lib/cli.ts";
|
|
19
|
-
import { setAutoYes } from "./lib/ui.ts";
|
|
20
|
-
import { setPlain } from "./lib/output.ts";
|
|
20
|
+
if (colorOff) process.env.NO_COLOR = "1";
|
|
21
21
|
|
|
22
|
+
const { ensureGitRepo } = await import("./lib/safety.ts");
|
|
23
|
+
const { parseCliArgs } = await import("./lib/cli.ts");
|
|
24
|
+
const { setAutoYes } = await import("./lib/ui.ts");
|
|
25
|
+
const { setPlain } = await import("./lib/output.ts");
|
|
22
26
|
const { version: VERSION } = await import("../package.json");
|
|
23
27
|
|
|
24
28
|
const args = process.argv.slice(2);
|
|
@@ -48,122 +52,140 @@ if (parsed.plain) {
|
|
|
48
52
|
|
|
49
53
|
// Ensure we're in a git repo for all commands — except `learn`, which just
|
|
50
54
|
// emits/installs the agent skill and is useful outside any repo (e.g. an agent
|
|
51
|
-
// onboarding before cloning, or `learn --skill --global`)
|
|
52
|
-
|
|
55
|
+
// onboarding before cloning, or `learn --skill --global`), and `config`, which
|
|
56
|
+
// shows the user and env layers anywhere (and the repo layer inside a repo).
|
|
57
|
+
if (command !== "learn" && command !== "config") {
|
|
53
58
|
await ensureGitRepo();
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
// Route to subcommand — strip global flags from command args
|
|
57
62
|
const commandArgs = parsed.commandArgs;
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
64
|
+
// A config problem (malformed TOML, a non-boolean env value) is reported as
|
|
65
|
+
// one clean line, whichever command hit it.
|
|
66
|
+
const { ConfigError } = await import("./lib/config.ts");
|
|
67
|
+
try {
|
|
68
|
+
switch (command) {
|
|
69
|
+
case "log":
|
|
70
|
+
await (await import("./commands/log.ts")).default(commandArgs);
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case "init":
|
|
74
|
+
await (await import("./commands/init.ts")).default(commandArgs);
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case "create":
|
|
78
|
+
await (await import("./commands/create.ts")).default(commandArgs);
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case "add":
|
|
82
|
+
await (await import("./commands/add.ts")).default(commandArgs);
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case "join":
|
|
86
|
+
await (await import("./commands/join.ts")).default(commandArgs);
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case "delete":
|
|
90
|
+
await (await import("./commands/delete.ts")).default(commandArgs);
|
|
91
|
+
break;
|
|
92
|
+
|
|
93
|
+
case "split":
|
|
94
|
+
await (await import("./commands/split.ts")).default(commandArgs);
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case "rename":
|
|
98
|
+
await (await import("./commands/rename.ts")).default(commandArgs);
|
|
99
|
+
break;
|
|
100
|
+
|
|
101
|
+
case "checkout":
|
|
102
|
+
case "co":
|
|
103
|
+
await (await import("./commands/checkout.ts")).default(commandArgs);
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case "ls":
|
|
107
|
+
await (await import("./commands/list.ts")).default(commandArgs);
|
|
108
|
+
break;
|
|
109
|
+
|
|
110
|
+
case "restack":
|
|
111
|
+
case "rebase":
|
|
112
|
+
await (await import("./commands/restack.ts")).default(commandArgs);
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "sync":
|
|
116
|
+
await (await import("./commands/sync.ts")).default(commandArgs);
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
case "submit":
|
|
120
|
+
await (await import("./commands/submit.ts")).default(commandArgs);
|
|
121
|
+
break;
|
|
122
|
+
|
|
123
|
+
case "status":
|
|
124
|
+
await (await import("./commands/status.ts")).default(commandArgs);
|
|
125
|
+
break;
|
|
126
|
+
|
|
127
|
+
case "merge":
|
|
128
|
+
await (await import("./commands/merge.ts")).default(commandArgs);
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case "undo":
|
|
132
|
+
await (await import("./commands/undo.ts")).default(commandArgs);
|
|
133
|
+
break;
|
|
134
|
+
|
|
135
|
+
case "doctor":
|
|
136
|
+
await (await import("./commands/doctor.ts")).default(commandArgs);
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "update-prs":
|
|
140
|
+
await (await import("./commands/update-prs.ts")).default(commandArgs);
|
|
141
|
+
break;
|
|
142
|
+
|
|
143
|
+
case "stacks":
|
|
144
|
+
await (await import("./commands/stacks.ts")).default(commandArgs);
|
|
145
|
+
break;
|
|
146
|
+
|
|
147
|
+
case "archive":
|
|
148
|
+
await (await import("./commands/archive.ts")).default(commandArgs);
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case "up":
|
|
152
|
+
await (await import("./commands/up.ts")).default(commandArgs);
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case "down":
|
|
156
|
+
await (await import("./commands/down.ts")).default(commandArgs);
|
|
157
|
+
break;
|
|
158
|
+
|
|
159
|
+
case "top":
|
|
160
|
+
await (await import("./commands/top.ts")).default(commandArgs);
|
|
161
|
+
break;
|
|
162
|
+
|
|
163
|
+
case "bottom":
|
|
164
|
+
await (await import("./commands/bottom.ts")).default(commandArgs);
|
|
165
|
+
break;
|
|
166
|
+
|
|
167
|
+
case "learn":
|
|
168
|
+
await (await import("./commands/learn.ts")).default(commandArgs);
|
|
169
|
+
break;
|
|
170
|
+
|
|
171
|
+
case "config":
|
|
172
|
+
await (await import("./commands/config.ts")).default(commandArgs);
|
|
173
|
+
break;
|
|
174
|
+
|
|
175
|
+
case "--help":
|
|
176
|
+
case "help":
|
|
177
|
+
printHelp();
|
|
178
|
+
break;
|
|
179
|
+
|
|
180
|
+
default:
|
|
181
|
+
console.error(`Unknown command: ${command}\n`);
|
|
182
|
+
printHelp();
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (!(err instanceof ConfigError)) throw err;
|
|
187
|
+
console.error(`gh-stack: ${err.message}`);
|
|
188
|
+
process.exit(1);
|
|
167
189
|
}
|
|
168
190
|
|
|
169
191
|
function printHelp() {
|
|
@@ -200,6 +222,7 @@ ${bold("STACK MANAGEMENT")}
|
|
|
200
222
|
${green("sync")} Sync base with main + restack all
|
|
201
223
|
${green("merge")} Squash-merge via GitHub (top-down, or --approved bottom-up)
|
|
202
224
|
${green("split")} Cut a stack into two at a branch
|
|
225
|
+
${green("join")} Splice an existing branch/PR into a stack
|
|
203
226
|
${green("rename")} Rename a tracked branch (keeps metadata in sync)
|
|
204
227
|
${green("delete")} Remove a branch from the stack
|
|
205
228
|
|
|
@@ -209,6 +232,7 @@ ${bold("INFO & MAINTENANCE")}
|
|
|
209
232
|
${green("archive")} Manage archived stacks
|
|
210
233
|
${green("update-prs")} Refresh stack visualization in PR descriptions
|
|
211
234
|
${green("doctor")} Migrate & repair stack metadata
|
|
235
|
+
${green("config")} Show flag defaults and where each comes from
|
|
212
236
|
${green("learn")} Print the agent skill (--skill installs it)
|
|
213
237
|
|
|
214
238
|
${bold("GLOBAL OPTIONS")}
|
|
@@ -221,6 +245,12 @@ ${bold("ENVIRONMENT")}
|
|
|
221
245
|
GH_STACK_YES=1 Same as --yes (also enables --plain)
|
|
222
246
|
GH_STACK_PLAIN=1 Same as --plain
|
|
223
247
|
GH_STACK_NO_COLOR Disable colored output
|
|
248
|
+
GH_STACK_DRAFT=1 submit creates new PRs as drafts (0 = ready)
|
|
249
|
+
GH_STACK_NO_PUSH=1 restack/sync/merge --base never push (0 = push)
|
|
250
|
+
|
|
251
|
+
${bold("CONFIG")}
|
|
252
|
+
.gh-stack.toml (repo root) and ~/.config/gh-stack/config.toml set flag
|
|
253
|
+
defaults, e.g. [submit] draft = true. Run ${green("gh-stack config")} to see them.
|
|
224
254
|
`);
|
|
225
255
|
}
|
|
226
256
|
|
package/src/lib/advance.ts
CHANGED
|
@@ -20,7 +20,7 @@ import * as p from "./output.ts";
|
|
|
20
20
|
import pc from "picocolors";
|
|
21
21
|
import * as git from "./git.ts";
|
|
22
22
|
import type { StackMetadata } from "../types.ts";
|
|
23
|
-
import { removeBranchFromStack } from "./metadata.ts";
|
|
23
|
+
import { removeBranchFromStack, writeMetadata } from "./metadata.ts";
|
|
24
24
|
import { resolveRestackBoundary } from "./snapshot.ts";
|
|
25
25
|
import { setPrBase } from "./github.ts";
|
|
26
26
|
|
|
@@ -120,6 +120,17 @@ export async function resolveForkPoint(
|
|
|
120
120
|
mergedBranch: string,
|
|
121
121
|
child: string,
|
|
122
122
|
): Promise<string | null> {
|
|
123
|
+
// `gh pr merge --delete-branch` deletes the merged branch locally too, which
|
|
124
|
+
// leaves the shared resolver no ref to read snapshots against. Its pre-merge
|
|
125
|
+
// tip is in the snapshot the caller took: use the newest recorded tip the
|
|
126
|
+
// child still builds on.
|
|
127
|
+
if (!(await git.localBranchExists(mergedBranch))) {
|
|
128
|
+
for (const snap of (meta.snapshots ?? []).toReversed()) {
|
|
129
|
+
const tip = snap.branches[mergedBranch];
|
|
130
|
+
if (tip && (await git.isAncestor(tip, child))) return tip;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
123
134
|
const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
|
|
124
135
|
return boundary?.sha ?? null;
|
|
125
136
|
}
|
|
@@ -137,6 +148,9 @@ export type AdvanceResult =
|
|
|
137
148
|
*
|
|
138
149
|
* The caller MUST have taken a snapshot first — on a rebase conflict this leaves
|
|
139
150
|
* the in-progress `git rebase` for the user to resolve and returns `conflict`.
|
|
151
|
+
* Metadata still names the merged branch as the child's parent then, so the
|
|
152
|
+
* fix is to re-run the caller: this skips the finished rebase and does the rest.
|
|
153
|
+
* (`restack` would rebase the child back onto the merged branch.)
|
|
140
154
|
*/
|
|
141
155
|
export async function advanceNewBottom(opts: {
|
|
142
156
|
meta: StackMetadata;
|
|
@@ -148,34 +162,61 @@ export async function advanceNewBottom(opts: {
|
|
|
148
162
|
recordedBase: string;
|
|
149
163
|
/** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
|
|
150
164
|
push?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* The child's origin tip as the remote guard checked and cleared it: the
|
|
167
|
+
* push leases on exactly that sha (see git.forcePushWithLease).
|
|
168
|
+
*/
|
|
169
|
+
expect?: string;
|
|
151
170
|
}): Promise<AdvanceResult> {
|
|
152
171
|
const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
|
|
153
172
|
const push = opts.push ?? true;
|
|
173
|
+
let retargeted = false;
|
|
174
|
+
|
|
175
|
+
// Already on the target: an earlier run stopped on a conflict the user then
|
|
176
|
+
// resolved, or the branch was re-rooted by hand (#51). Its fork point is
|
|
177
|
+
// behind it now, so skip the rebase and finish the rest (push, PR base,
|
|
178
|
+
// metadata). Before a re-root the child can't contain the target, which
|
|
179
|
+
// holds the merged PR's new squash commit.
|
|
180
|
+
if (await git.isAncestor(targetRef, child)) {
|
|
181
|
+
p.log.info(
|
|
182
|
+
`${pc.yellow(child)} is already on ${pc.green(recordedBase)} — finishing its re-root`,
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
186
|
+
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
187
|
+
|
|
188
|
+
// Guard: the fork-point must be an ancestor of the child, else `--onto` would
|
|
189
|
+
// replay an unrelated range. Bail loudly rather than mangle history.
|
|
190
|
+
if (!(await git.isAncestor(forkPoint, child))) {
|
|
191
|
+
return { ok: false, reason: "bad-fork-point", forkPoint };
|
|
192
|
+
}
|
|
154
193
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!(await git.isAncestor(forkPoint, child))) {
|
|
161
|
-
return { ok: false, reason: "bad-fork-point", forkPoint };
|
|
194
|
+
p.log.info(
|
|
195
|
+
`Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
|
|
196
|
+
);
|
|
197
|
+
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
198
|
+
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
162
199
|
}
|
|
163
200
|
|
|
164
|
-
p.log.info(
|
|
165
|
-
`Re-rooting ${pc.yellow(child)} onto ${pc.green(recordedBase)} ${pc.dim(`(base: ${forkPoint.slice(0, 8)})`)}`,
|
|
166
|
-
);
|
|
167
|
-
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
168
|
-
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
169
|
-
|
|
170
201
|
if (push) {
|
|
171
202
|
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
172
|
-
|
|
173
|
-
|
|
203
|
+
// If it didn't land, leave the base alone: retargeting onto the trunk with
|
|
204
|
+
// the old head on origin would show the merged commits in the PR (#29).
|
|
205
|
+
const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
|
|
206
|
+
if (!pushed.ok) {
|
|
207
|
+
p.log.error(
|
|
208
|
+
`Push of ${pc.yellow(child)} failed — PR${childPr != null ? ` #${childPr}` : ""} left on ${mergedBranch} for now. The next push of ${child} that lands (gh-stack restack --resume, sync) retargets it.`,
|
|
209
|
+
);
|
|
210
|
+
for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
|
|
211
|
+
} else if (childPr != null) {
|
|
174
212
|
const moved = await setPrBase(childPr, recordedBase);
|
|
175
213
|
if (moved) {
|
|
214
|
+
retargeted = true;
|
|
176
215
|
p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
|
|
177
216
|
} else {
|
|
178
|
-
p.log.warn(
|
|
217
|
+
p.log.warn(
|
|
218
|
+
`Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
|
|
219
|
+
);
|
|
179
220
|
}
|
|
180
221
|
}
|
|
181
222
|
} else if (childPr != null) {
|
|
@@ -190,5 +231,14 @@ export async function advanceNewBottom(opts: {
|
|
|
190
231
|
// parent (the trunk) and writes. Done after the git work succeeds so a failure
|
|
191
232
|
// never leaves metadata describing a state git didn't reach.
|
|
192
233
|
await removeBranchFromStack(meta, stackName, mergedBranch);
|
|
234
|
+
|
|
235
|
+
// Record where the child's PR really points. Until it's retargeted it still
|
|
236
|
+
// targets the merged branch, and the next successful push of the child
|
|
237
|
+
// retargets it (restack's pushStep), so it's never left there.
|
|
238
|
+
const entry = meta.stacks[stackName]?.branches[child];
|
|
239
|
+
if (entry && childPr != null) {
|
|
240
|
+
entry.prBase = retargeted ? recordedBase : mergedBranch;
|
|
241
|
+
await writeMetadata(meta);
|
|
242
|
+
}
|
|
193
243
|
return { ok: true };
|
|
194
244
|
}
|