gh-stack 0.17.2 → 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 +256 -13
- package/package.json +9 -8
- package/src/commands/add.ts +131 -3
- package/src/commands/config.ts +133 -0
- package/src/commands/init.ts +4 -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 +954 -211
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +145 -145
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +87 -15
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +45 -9
- package/src/lib/chain.ts +20 -0
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +98 -22
- package/src/lib/github.ts +83 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +64 -14
- 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 +140 -5
- package/src/types.ts +62 -2
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
|
|
|
@@ -146,8 +146,17 @@ export async function advanceNewBottom(opts: {
|
|
|
146
146
|
childPr: number | null;
|
|
147
147
|
targetRef: string;
|
|
148
148
|
recordedBase: string;
|
|
149
|
+
/** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
|
|
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;
|
|
149
156
|
}): Promise<AdvanceResult> {
|
|
150
157
|
const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
|
|
158
|
+
const push = opts.push ?? true;
|
|
159
|
+
let retargeted = false;
|
|
151
160
|
|
|
152
161
|
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
153
162
|
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
@@ -164,20 +173,47 @@ export async function advanceNewBottom(opts: {
|
|
|
164
173
|
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
165
174
|
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
166
175
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
if (push) {
|
|
177
|
+
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
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) {
|
|
187
|
+
const moved = await setPrBase(childPr, recordedBase);
|
|
188
|
+
if (moved) {
|
|
189
|
+
retargeted = true;
|
|
190
|
+
p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
|
|
191
|
+
} else {
|
|
192
|
+
p.log.warn(
|
|
193
|
+
`Could not retarget PR #${childPr} — the next push of ${child} retries, or set its base to ${recordedBase} manually`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
175
196
|
}
|
|
197
|
+
} else if (childPr != null) {
|
|
198
|
+
// Retargeting the base without pushing the rebased head would show the
|
|
199
|
+
// merged commits in the PR diff, so leave both for `submit` to reconcile.
|
|
200
|
+
p.log.info(
|
|
201
|
+
`Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
|
|
202
|
+
);
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
// Metadata last: re-parents child (and siblings) onto the merged branch's
|
|
179
206
|
// parent (the trunk) and writes. Done after the git work succeeds so a failure
|
|
180
207
|
// never leaves metadata describing a state git didn't reach.
|
|
181
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
|
+
}
|
|
182
218
|
return { ok: true };
|
|
183
219
|
}
|
package/src/lib/chain.ts
CHANGED
|
@@ -53,6 +53,26 @@ export async function detectBranchChain(currentBranch: string, trunk: string): P
|
|
|
53
53
|
return chain;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Local branches (other than trunk) whose tip is the SAME commit as `branch`.
|
|
58
|
+
* A branch with no commits of its own sits at its parent's tip rather than
|
|
59
|
+
* above it, so `detectBranchChain` can't see the parent — and from ancestry
|
|
60
|
+
* alone the two are indistinguishable. Callers use this to warn instead of
|
|
61
|
+
* silently rooting the branch at trunk. (#37)
|
|
62
|
+
*/
|
|
63
|
+
export async function colocatedBranches(branch: string, trunk: string): Promise<string[]> {
|
|
64
|
+
const tip = await git.revParse(branch);
|
|
65
|
+
const excluded = new Set([branch, trunk, "main", "master"]);
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
for (const [other, sha] of await git.localBranchTips()) {
|
|
68
|
+
if (excluded.has(other) || sha !== tip) continue;
|
|
69
|
+
// A branch sitting on trunk's tip isn't a stack parent, just trunk.
|
|
70
|
+
if (await git.isAncestor(other, trunk)) continue;
|
|
71
|
+
out.push(other);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
export interface StackResolution {
|
|
57
77
|
stackName: string;
|
|
58
78
|
base: string; // effective base of the resulting stack (for display)
|