gh-stack 0.18.0 → 0.19.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 +232 -16
- 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 +263 -5
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +140 -154
- 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 +29 -4
- 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/using-gh-stack.md +124 -4
- 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 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
|
|
|
@@ -148,9 +148,15 @@ export async function advanceNewBottom(opts: {
|
|
|
148
148
|
recordedBase: string;
|
|
149
149
|
/** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
|
|
150
150
|
push?: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* The child's origin tip as the remote guard checked and cleared it: the
|
|
153
|
+
* push leases on exactly that sha (see git.forcePushWithLease).
|
|
154
|
+
*/
|
|
155
|
+
expect?: string;
|
|
151
156
|
}): Promise<AdvanceResult> {
|
|
152
157
|
const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
|
|
153
158
|
const push = opts.push ?? true;
|
|
159
|
+
let retargeted = false;
|
|
154
160
|
|
|
155
161
|
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
156
162
|
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
@@ -169,13 +175,23 @@ export async function advanceNewBottom(opts: {
|
|
|
169
175
|
|
|
170
176
|
if (push) {
|
|
171
177
|
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
172
|
-
|
|
173
|
-
|
|
178
|
+
// If it didn't land, leave the base alone: retargeting onto the trunk with
|
|
179
|
+
// the old head on origin would show the merged commits in the PR (#29).
|
|
180
|
+
const pushed = await git.forcePushWithLease(child, { expect: opts.expect });
|
|
181
|
+
if (!pushed.ok) {
|
|
182
|
+
p.log.error(
|
|
183
|
+
`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.`,
|
|
184
|
+
);
|
|
185
|
+
for (const line of git.pushFailureHint(child, pushed)) console.log(` ${line}`);
|
|
186
|
+
} else if (childPr != null) {
|
|
174
187
|
const moved = await setPrBase(childPr, recordedBase);
|
|
175
188
|
if (moved) {
|
|
189
|
+
retargeted = true;
|
|
176
190
|
p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
|
|
177
191
|
} else {
|
|
178
|
-
p.log.warn(
|
|
192
|
+
p.log.warn(
|
|
193
|
+
`Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
|
|
194
|
+
);
|
|
179
195
|
}
|
|
180
196
|
}
|
|
181
197
|
} else if (childPr != null) {
|
|
@@ -190,5 +206,14 @@ export async function advanceNewBottom(opts: {
|
|
|
190
206
|
// parent (the trunk) and writes. Done after the git work succeeds so a failure
|
|
191
207
|
// never leaves metadata describing a state git didn't reach.
|
|
192
208
|
await removeBranchFromStack(meta, stackName, mergedBranch);
|
|
209
|
+
|
|
210
|
+
// Record where the child's PR really points. Until it's retargeted it still
|
|
211
|
+
// targets the merged branch, and the next successful push of the child
|
|
212
|
+
// retargets it (restack's pushStep), so it's never left there.
|
|
213
|
+
const entry = meta.stacks[stackName]?.branches[child];
|
|
214
|
+
if (entry && childPr != null) {
|
|
215
|
+
entry.prBase = retargeted ? recordedBase : mergedBranch;
|
|
216
|
+
await writeMetadata(meta);
|
|
217
|
+
}
|
|
193
218
|
return { ok: true };
|
|
194
219
|
}
|