gh-stack 0.17.2 → 0.18.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 +33 -6
- package/package.json +1 -1
- package/src/commands/add.ts +131 -3
- package/src/commands/init.ts +4 -0
- package/src/commands/restack.ts +37 -0
- package/src/commands/sync.ts +18 -4
- package/src/commands/update-prs.ts +77 -10
- package/src/lib/advance.ts +19 -8
- package/src/lib/chain.ts +20 -0
- package/src/lib/git.ts +14 -0
- package/src/lib/github.ts +20 -0
- package/src/lib/output.ts +35 -6
- package/src/lib/using-gh-stack.md +18 -3
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -83,12 +83,20 @@ create <branch-name> [--description <desc>]
|
|
|
83
83
|
Create a new git branch off the current branch and add it to
|
|
84
84
|
the stack. The current branch must already be tracked.
|
|
85
85
|
|
|
86
|
-
add [branch]
|
|
86
|
+
add [branch] [--parent <branch>]
|
|
87
87
|
Track an EXISTING local branch (default: the current branch) in a
|
|
88
88
|
stack — without pushing or opening a PR. Walks local ancestry: if the
|
|
89
89
|
branch sits on top of an existing stack it's adopted into it (parent
|
|
90
90
|
auto-detected); otherwise a new stack is created from the chain.
|
|
91
91
|
|
|
92
|
+
--parent <branch>: stack it directly on <branch> (which it must
|
|
93
|
+
already be based on), tracking <branch> first if needed. Use this
|
|
94
|
+
for a branch with no commits of its own yet — it sits AT its
|
|
95
|
+
parent's tip, so ancestry can't tell which is the parent and it
|
|
96
|
+
would otherwise be rooted at trunk (init/add warn when this
|
|
97
|
+
happens). It also re-homes an already-tracked branch, but only
|
|
98
|
+
when it's alone in its stack and has no PR.
|
|
99
|
+
|
|
92
100
|
Use this for a WIP branch you've created with `git checkout -b` but
|
|
93
101
|
aren't ready to submit. It fills the gap between creating a branch and
|
|
94
102
|
`submit`: until a branch is tracked, `sync`/`restack`/navigation don't
|
|
@@ -152,11 +160,16 @@ ls
|
|
|
152
160
|
### Stack Management
|
|
153
161
|
|
|
154
162
|
```
|
|
155
|
-
restack [--resume] [--dry-run] [--verbose]
|
|
163
|
+
restack [--resume] [--dry-run] [--no-push] [--verbose]
|
|
156
164
|
Rebase the current branch and all descendants onto their parents.
|
|
157
165
|
Uses metadata snapshots to recover the correct rebase base even
|
|
158
166
|
after a parent's history has been rewritten.
|
|
159
167
|
|
|
168
|
+
--no-push (or GH_STACK_NO_PUSH=1): rebase locally only, and list
|
|
169
|
+
the refs left ahead of origin. It overrides --yes for pushes, so
|
|
170
|
+
an agent can restack unattended without publishing anything. An
|
|
171
|
+
interrupted --no-push run stays local-only on --resume.
|
|
172
|
+
|
|
160
173
|
On conflict:
|
|
161
174
|
git rebase --continue
|
|
162
175
|
gh-stack restack --resume
|
|
@@ -169,7 +182,7 @@ restack --onto <ref>
|
|
|
169
182
|
onto main once the parent stack has merged — only the stack's own
|
|
170
183
|
commits replay onto the new base.
|
|
171
184
|
|
|
172
|
-
sync [--dry-run]
|
|
185
|
+
sync [--dry-run] [--no-push]
|
|
173
186
|
Fetch main, rebase the base branch onto main, then restack all
|
|
174
187
|
children. Snapshots every branch's pre-sync tip so children can be
|
|
175
188
|
correctly rebased even though their parent's history was rewritten.
|
|
@@ -179,6 +192,10 @@ sync [--dry-run]
|
|
|
179
192
|
own commits replay — instead of replaying the now-squashed commits
|
|
180
193
|
and conflicting.
|
|
181
194
|
|
|
195
|
+
--no-push (or GH_STACK_NO_PUSH=1): as for restack. Also skips
|
|
196
|
+
retargeting a PR's base after advancing past a merged bottom;
|
|
197
|
+
the next `submit` pushes and retargets together.
|
|
198
|
+
|
|
182
199
|
merge [--dry-run] [-d|--delete-branch] [--collapse]
|
|
183
200
|
merge --approved [--collapse] [--dry-run] [-d|--delete-branch]
|
|
184
201
|
merge --base [--dry-run] [-d|--delete-branch]
|
|
@@ -266,9 +283,13 @@ log
|
|
|
266
283
|
|
|
267
284
|
update-prs [--force]
|
|
268
285
|
Refresh the "📚 Stacked on" visualization block in every PR
|
|
269
|
-
description for the current stack.
|
|
270
|
-
|
|
271
|
-
|
|
286
|
+
description for the current stack. Each PR's live description is
|
|
287
|
+
checked, so a block removed by an outside edit (`gh pr edit`, the
|
|
288
|
+
GitHub UI) is restored; PRs already showing the current block are
|
|
289
|
+
skipped, and --force rewrites all of them. A tracked branch with no
|
|
290
|
+
recorded PR is looked up on GitHub first, so a PR opened with
|
|
291
|
+
`gh pr create` is adopted. (submit refreshes the block too, but
|
|
292
|
+
compares against what it last wrote — use update-prs to repair.)
|
|
272
293
|
|
|
273
294
|
stacks [--current] [--json]
|
|
274
295
|
List all stacks and their topology. Read-only and network-free —
|
|
@@ -322,11 +343,14 @@ learn [--skill] [--harness <name>] [--global] [--force]
|
|
|
322
343
|
|
|
323
344
|
`--plain` is auto-enabled when `--yes` / `GH_STACK_YES=1` is set, so agents get clean, easily-filtered output by default. Use `--plain` alone if you want plain output but still interactive prompts.
|
|
324
345
|
|
|
346
|
+
If a command needs to prompt but stdin isn't a TTY, it exits 1 with the prompt it would have shown, instead of waiting for an answer that can't arrive. Set `GH_STACK_YES=1` (or pass `--yes`) in that context.
|
|
347
|
+
|
|
325
348
|
## Environment Variables
|
|
326
349
|
|
|
327
350
|
```
|
|
328
351
|
GH_STACK_YES=1 Skip all confirmations (same as --yes; also enables --plain)
|
|
329
352
|
GH_STACK_PLAIN=1 Plain output (same as --plain)
|
|
353
|
+
GH_STACK_NO_PUSH=1 restack/sync never push (same as --no-push; wins over --yes)
|
|
330
354
|
GH_STACK_NO_COLOR=1 Disable colored output
|
|
331
355
|
```
|
|
332
356
|
|
|
@@ -518,6 +542,9 @@ gh-stack submit -n # Or auto-generate titles
|
|
|
518
542
|
gh-stack sync
|
|
519
543
|
gh-stack restack
|
|
520
544
|
|
|
545
|
+
# Rebase without publishing (e.g. before a human approves the push)
|
|
546
|
+
GH_STACK_NO_PUSH=1 gh-stack restack
|
|
547
|
+
|
|
521
548
|
# Structured output
|
|
522
549
|
gh-stack status --json
|
|
523
550
|
gh-stack status --current --json
|
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -13,7 +13,7 @@ import pc from "picocolors";
|
|
|
13
13
|
import * as git from "../lib/git.ts";
|
|
14
14
|
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
15
15
|
import { readMetadata, writeMetadata, findStackForBranch, stackBase } from "../lib/metadata.ts";
|
|
16
|
-
import { resolveOrCreateStack } from "../lib/chain.ts";
|
|
16
|
+
import { colocatedBranches, resolveOrCreateStack } from "../lib/chain.ts";
|
|
17
17
|
import type { StackMetadata } from "../types.ts";
|
|
18
18
|
|
|
19
19
|
export default async function add(args: string[]): Promise<void> {
|
|
@@ -22,26 +22,40 @@ export default async function add(args: string[]): Promise<void> {
|
|
|
22
22
|
gh-stack add — track an existing local branch in a stack (no push, no PR)
|
|
23
23
|
|
|
24
24
|
USAGE
|
|
25
|
-
gh-stack add [branch]
|
|
25
|
+
gh-stack add [branch] [--parent <branch>]
|
|
26
26
|
|
|
27
27
|
Registers the branch (default: the current branch) into a stack so sync,
|
|
28
28
|
restack, and navigation work before you've submitted it. Walks local ancestry:
|
|
29
29
|
if the branch sits on top of an existing stack it's adopted into that stack;
|
|
30
30
|
otherwise a new stack is created from the chain, rooted at the trunk.
|
|
31
31
|
|
|
32
|
+
OPTIONS
|
|
33
|
+
--parent <branch> Stack the branch directly on <branch> instead of detecting
|
|
34
|
+
it (which must already be based on it). Needed when the
|
|
35
|
+
branch has no commits of its own yet, since it then sits AT
|
|
36
|
+
its parent's tip and ancestry can't tell them apart.
|
|
37
|
+
Also re-homes an already-tracked branch, when it's alone in
|
|
38
|
+
its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
|
|
39
|
+
|
|
32
40
|
Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
|
|
33
41
|
you're ready to publish.
|
|
34
42
|
|
|
35
43
|
EXAMPLES
|
|
36
44
|
gh-stack add Track the current branch
|
|
37
45
|
gh-stack add my-wip-branch Track a specific local branch
|
|
46
|
+
gh-stack add --parent feat-1 Track the current branch on top of feat-1
|
|
38
47
|
`);
|
|
39
48
|
return;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
await gateLegacyMetadata();
|
|
43
52
|
|
|
44
|
-
|
|
53
|
+
let parentFlag: string | undefined;
|
|
54
|
+
let positional: string | undefined;
|
|
55
|
+
for (let i = 0; i < args.length; i++) {
|
|
56
|
+
if (args[i] === "--parent" && args[i + 1]) parentFlag = args[++i];
|
|
57
|
+
else if (!args[i]!.startsWith("-")) positional ??= args[i];
|
|
58
|
+
}
|
|
45
59
|
const branch = positional ?? (await git.currentBranch());
|
|
46
60
|
|
|
47
61
|
if (!(await git.localBranchExists(branch))) {
|
|
@@ -65,9 +79,15 @@ EXAMPLES
|
|
|
65
79
|
stacks: {},
|
|
66
80
|
};
|
|
67
81
|
|
|
82
|
+
if (parentFlag) {
|
|
83
|
+
await addUnderParent(meta, branch, parentFlag, trunk);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
const already = findStackForBranch(meta, branch);
|
|
69
88
|
if (already) {
|
|
70
89
|
p.log.info(`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(already)}.`);
|
|
90
|
+
await warnIfColocated(branch, trunk);
|
|
71
91
|
return;
|
|
72
92
|
}
|
|
73
93
|
|
|
@@ -100,7 +120,115 @@ EXAMPLES
|
|
|
100
120
|
for (const b of resolution.addedBranches) {
|
|
101
121
|
console.log(` ${pc.green("+")} ${b}`);
|
|
102
122
|
}
|
|
123
|
+
if (resolution.created && resolution.chain.length === 1) await warnIfColocated(branch, trunk);
|
|
124
|
+
|
|
125
|
+
p.outro(
|
|
126
|
+
`Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
103
129
|
|
|
130
|
+
/**
|
|
131
|
+
* A branch with no commits of its own sits AT its parent's tip, so ancestry
|
|
132
|
+
* can't find the parent and it gets rooted at trunk. Say so, and how to fix it,
|
|
133
|
+
* rather than leaving a stack that `sync` would later rebase onto trunk —
|
|
134
|
+
* replaying the parent's commits. (#37)
|
|
135
|
+
*/
|
|
136
|
+
export async function warnIfColocated(branch: string, trunk: string): Promise<void> {
|
|
137
|
+
const same = await colocatedBranches(branch, trunk);
|
|
138
|
+
if (same.length === 0) return;
|
|
139
|
+
p.log.warn(
|
|
140
|
+
`${pc.yellow(branch)} has no commits of its own — it shares its tip with ${same.map((b) => pc.yellow(b)).join(", ")}, so it was rooted at ${trunk}.\n` +
|
|
141
|
+
` If it belongs on one of those, re-home it:\n` +
|
|
142
|
+
same.map((b) => ` ${pc.green(`gh-stack add ${branch} --parent ${b}`)}`).join("\n"),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `add --parent`: stack `branch` directly on `parent`, tracking `parent` first
|
|
148
|
+
* (from its own ancestry) if needed. An already-tracked branch is re-homed only
|
|
149
|
+
* when that can't lose anything: it's alone in its stack and has no PR — the
|
|
150
|
+
* shape a mistaken `init` at trunk leaves behind. Metadata-only; the branch
|
|
151
|
+
* must already be based on `parent`. (#37)
|
|
152
|
+
*/
|
|
153
|
+
async function addUnderParent(
|
|
154
|
+
meta: StackMetadata,
|
|
155
|
+
branch: string,
|
|
156
|
+
parent: string,
|
|
157
|
+
trunk: string,
|
|
158
|
+
): Promise<void> {
|
|
159
|
+
if (parent === branch) {
|
|
160
|
+
p.cancel(`A branch can't be its own parent.`);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
if (parent === trunk || parent === "main" || parent === "master") {
|
|
164
|
+
p.cancel(
|
|
165
|
+
`--parent is for stacking on a feature branch. To root ${pc.yellow(branch)} at ${trunk}, run ${pc.green("gh-stack add")} without --parent.`,
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
if (!(await git.localBranchExists(parent))) {
|
|
170
|
+
p.cancel(`Branch ${pc.yellow(parent)} doesn't exist locally.`);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
}
|
|
173
|
+
if (!(await git.isAncestor(parent, branch))) {
|
|
174
|
+
p.cancel(
|
|
175
|
+
`${pc.yellow(branch)} isn't based on ${pc.yellow(parent)} — rebase it first:\n\n ${pc.green(`git rebase ${parent} ${branch}`)}`,
|
|
176
|
+
);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const oldStackName = findStackForBranch(meta, branch);
|
|
181
|
+
const oldBranch = oldStackName ? meta.stacks[oldStackName]!.branches[branch]! : null;
|
|
182
|
+
|
|
183
|
+
if (oldStackName && oldBranch) {
|
|
184
|
+
if (oldBranch.parent === parent) {
|
|
185
|
+
p.log.info(`${pc.yellow(branch)} is already stacked on ${pc.yellow(parent)}.`);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const oldStack = meta.stacks[oldStackName]!;
|
|
189
|
+
const alone = Object.keys(oldStack.branches).length === 1;
|
|
190
|
+
if (!alone || oldBranch.pr != null) {
|
|
191
|
+
p.cancel(
|
|
192
|
+
`${pc.yellow(branch)} is already tracked in stack ${pc.yellow(oldStackName)}` +
|
|
193
|
+
(oldBranch.pr != null ? ` with PR #${oldBranch.pr}` : " alongside other branches") +
|
|
194
|
+
` — re-homing it could strand them, so add --parent won't move it.`,
|
|
195
|
+
);
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Track the parent first, from its own ancestry, if it isn't yet. Resolved
|
|
201
|
+
// before dropping the branch's old stack so a failure leaves nothing changed.
|
|
202
|
+
let targetStackName = findStackForBranch(meta, parent);
|
|
203
|
+
const adoptedParents: string[] = [];
|
|
204
|
+
if (!targetStackName) {
|
|
205
|
+
try {
|
|
206
|
+
const res = await resolveOrCreateStack(meta, parent, trunk);
|
|
207
|
+
targetStackName = res.stackName;
|
|
208
|
+
adoptedParents.push(...res.addedBranches);
|
|
209
|
+
} catch (err) {
|
|
210
|
+
p.cancel((err as Error).message);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Drop the throwaway one-branch stack. Deleted, not archived: restoring it
|
|
216
|
+
// would track the branch in two stacks at once.
|
|
217
|
+
if (oldStackName && oldStackName !== targetStackName) delete meta.stacks[oldStackName];
|
|
218
|
+
|
|
219
|
+
const target = meta.stacks[targetStackName]!;
|
|
220
|
+
target.branches[branch] = { ...oldBranch, parent };
|
|
221
|
+
target.last_branch = branch;
|
|
222
|
+
meta.current_stack = targetStackName;
|
|
223
|
+
await writeMetadata(meta);
|
|
224
|
+
|
|
225
|
+
p.intro(pc.cyan("Add to Stack"));
|
|
226
|
+
for (const b of adoptedParents) console.log(` ${pc.green("+")} ${b}`);
|
|
227
|
+
p.log.success(
|
|
228
|
+
oldStackName
|
|
229
|
+
? `Re-homed ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)} ${pc.dim(`(removed one-branch stack ${oldStackName})`)}`
|
|
230
|
+
: `Added ${pc.yellow(branch)} onto ${pc.yellow(parent)} in stack ${pc.yellow(targetStackName)}`,
|
|
231
|
+
);
|
|
104
232
|
p.outro(
|
|
105
233
|
`Tracked — ${pc.green("gh-stack sync")}/${pc.green("restack")} now work. ${pc.dim("Run")} ${pc.green("gh-stack submit")} ${pc.dim("to open PRs.")}`,
|
|
106
234
|
);
|
package/src/commands/init.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { getPrNumber } from "../lib/github.ts";
|
|
14
14
|
import { isAutoYes } from "../lib/ui.ts";
|
|
15
15
|
import { detectBranchChain } from "../lib/chain.ts";
|
|
16
|
+
import { warnIfColocated } from "./add.ts";
|
|
16
17
|
import { gateLegacyMetadata } from "../lib/safety.ts";
|
|
17
18
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
18
19
|
|
|
@@ -151,6 +152,9 @@ export default async function init(args: string[]): Promise<void> {
|
|
|
151
152
|
|
|
152
153
|
p.outro(pc.green(`Stack ${pc.yellow(stackName)} created with ${chain.length} branch(es)!`));
|
|
153
154
|
|
|
155
|
+
// A zero-commit branch can't see its parent through ancestry (#37).
|
|
156
|
+
if (chain.length === 1) await warnIfColocated(branch, trunk);
|
|
157
|
+
|
|
154
158
|
// Next steps
|
|
155
159
|
console.log();
|
|
156
160
|
if (chain.length === 1) {
|
package/src/commands/restack.ts
CHANGED
|
@@ -19,8 +19,21 @@ import { takeSnapshot, resolveRestackBoundary, findRecordedBaseTip } from "../li
|
|
|
19
19
|
import { confirmAction } from "../lib/ui.ts";
|
|
20
20
|
import type { StackMetadata } from "../types.ts";
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* `--no-push` / `GH_STACK_NO_PUSH=1`: rebase locally, never publish (#36).
|
|
24
|
+
* Module-level because every rebase path funnels through `pushBranchRef`.
|
|
25
|
+
* Wins over `--yes`, which still auto-accepts the rebases themselves.
|
|
26
|
+
*/
|
|
27
|
+
let noPush = false;
|
|
28
|
+
|
|
29
|
+
/** Whether `--no-push` (or `GH_STACK_NO_PUSH=1`) is in effect for these args. */
|
|
30
|
+
export function wantsNoPush(args: string[]): boolean {
|
|
31
|
+
return args.includes("--no-push") || process.env.GH_STACK_NO_PUSH === "1";
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
export default async function restack(args: string[]): Promise<void> {
|
|
23
35
|
const dryRun = args.includes("--dry-run");
|
|
36
|
+
noPush = wantsNoPush(args);
|
|
24
37
|
const resume = args.includes("--resume");
|
|
25
38
|
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
26
39
|
|
|
@@ -42,6 +55,8 @@ USAGE
|
|
|
42
55
|
OPTIONS
|
|
43
56
|
--onto <ref> Re-root the stack onto a new base ref (changes stack base)
|
|
44
57
|
--yes, -y Skip confirmations (auto-accept all rebases and pushes)
|
|
58
|
+
--no-push Rebase locally only — never push (also GH_STACK_NO_PUSH=1).
|
|
59
|
+
Wins over --yes for pushes; rebases are still auto-accepted
|
|
45
60
|
--resume Resume after resolving rebase conflicts
|
|
46
61
|
--dry-run Show what would happen without executing
|
|
47
62
|
--verbose Show diagnostic info (snapshot vs merge-base)
|
|
@@ -49,6 +64,8 @@ OPTIONS
|
|
|
49
64
|
EXAMPLES
|
|
50
65
|
gh-stack restack Interactive (prompts before each rebase)
|
|
51
66
|
gh-stack restack --yes Non-interactive (for agents/CI)
|
|
67
|
+
gh-stack restack --yes --no-push
|
|
68
|
+
Non-interactive, local-only (review first)
|
|
52
69
|
gh-stack restack --resume Continue after resolving conflicts
|
|
53
70
|
gh-stack restack --onto main Re-root a split stack onto main after its
|
|
54
71
|
parent stack has merged
|
|
@@ -110,6 +127,21 @@ ALIASES
|
|
|
110
127
|
// when a push didn't take. (#12)
|
|
111
128
|
if (!dryRun) {
|
|
112
129
|
const stale = await findStaleRefs(touched, meta);
|
|
130
|
+
if (stale.length > 0 && noPush) {
|
|
131
|
+
// Expected under --no-push: report what's ahead of origin so a later
|
|
132
|
+
// submit/restack knows what to publish, but don't fail the run.
|
|
133
|
+
console.log();
|
|
134
|
+
p.log.info("Not pushed (--no-push) — these refs are ahead of origin:");
|
|
135
|
+
for (const { branch, local, remote } of stale) {
|
|
136
|
+
console.log(` ${pc.yellow("↑")} ${branch} local ${local.slice(0, 8)} origin ${remote}`);
|
|
137
|
+
}
|
|
138
|
+
console.log();
|
|
139
|
+
console.log(
|
|
140
|
+
` Publish when ready: ${pc.green("gh-stack restack")} or ${pc.green("gh-stack submit")}`,
|
|
141
|
+
);
|
|
142
|
+
p.outro(pc.green("Stack Restack Complete (local only)"));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
113
145
|
if (stale.length > 0) {
|
|
114
146
|
console.log();
|
|
115
147
|
p.log.error("Some branch refs are NOT in sync with origin:");
|
|
@@ -185,6 +217,8 @@ async function handleResume(
|
|
|
185
217
|
p.cancel("No saved state found. Nothing to resume.");
|
|
186
218
|
process.exit(1);
|
|
187
219
|
}
|
|
220
|
+
// An interrupted --no-push run stays local-only when resumed.
|
|
221
|
+
if (state.no_push) noPush = true;
|
|
188
222
|
|
|
189
223
|
// Check if rebase is still in progress
|
|
190
224
|
if (await git.isRebaseInProgress()) {
|
|
@@ -597,6 +631,7 @@ async function processChain(
|
|
|
597
631
|
current_index: i,
|
|
598
632
|
stack_name: stackName,
|
|
599
633
|
chain,
|
|
634
|
+
...(noPush && { no_push: true }),
|
|
600
635
|
});
|
|
601
636
|
|
|
602
637
|
let success: boolean;
|
|
@@ -672,6 +707,8 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
|
672
707
|
|
|
673
708
|
async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
|
|
674
709
|
if (branch === "main" || branch === "master") return;
|
|
710
|
+
// --no-push: leave origin alone; the end-of-run summary lists what's ahead.
|
|
711
|
+
if (noPush) return;
|
|
675
712
|
|
|
676
713
|
const localSha = await git.revParse(branch);
|
|
677
714
|
const hasRemote = await git.remoteBranchExists(branch);
|
package/src/commands/sync.ts
CHANGED
|
@@ -17,20 +17,27 @@ import { confirmAction } from "../lib/ui.ts";
|
|
|
17
17
|
import { getPrInfo } from "../lib/github.ts";
|
|
18
18
|
import { planMerges, advanceNewBottom, type BranchReview } from "../lib/advance.ts";
|
|
19
19
|
import type { StackMetadata } from "../types.ts";
|
|
20
|
+
import { wantsNoPush } from "./restack.ts";
|
|
20
21
|
|
|
21
22
|
export default async function sync(args: string[]): Promise<void> {
|
|
22
23
|
const dryRun = args.includes("--dry-run");
|
|
24
|
+
const noPush = wantsNoPush(args);
|
|
23
25
|
|
|
24
26
|
if (args.includes("--help")) {
|
|
25
27
|
console.log(`
|
|
26
28
|
gh-stack sync — Sync base with main + restack all
|
|
27
29
|
|
|
28
30
|
USAGE
|
|
29
|
-
gh-stack sync [--dry-run]
|
|
31
|
+
gh-stack sync [--dry-run] [--no-push]
|
|
30
32
|
|
|
31
33
|
Fetches latest main, rebases the base branch onto main,
|
|
32
34
|
then restacks all children. Equivalent to running restack
|
|
33
35
|
with the base branch included.
|
|
36
|
+
|
|
37
|
+
OPTIONS
|
|
38
|
+
--dry-run Show what would happen without executing
|
|
39
|
+
--no-push Rebase locally only — never push or retarget PRs
|
|
40
|
+
(also GH_STACK_NO_PUSH=1). Wins over --yes for pushes
|
|
34
41
|
`);
|
|
35
42
|
return;
|
|
36
43
|
}
|
|
@@ -91,7 +98,7 @@ with the base branch included.
|
|
|
91
98
|
// them. A plain rebase onto main would then replay their now-squashed commits
|
|
92
99
|
// and conflict — so advance past them (re-rooting the survivor onto main with
|
|
93
100
|
// `--onto`) before the normal sync runs. (#20)
|
|
94
|
-
const advance = await advanceMergedBottoms(meta, stackName, base, dryRun);
|
|
101
|
+
const advance = await advanceMergedBottoms(meta, stackName, base, dryRun, noPush);
|
|
95
102
|
if (advance.drained) {
|
|
96
103
|
p.outro(pc.green("Bottom PR(s) already merged — stack fully drained & archived."));
|
|
97
104
|
return;
|
|
@@ -178,6 +185,7 @@ with the base branch included.
|
|
|
178
185
|
current_index: 0,
|
|
179
186
|
stack_name: stackName,
|
|
180
187
|
chain: ordered,
|
|
188
|
+
...(noPush && { no_push: true }),
|
|
181
189
|
});
|
|
182
190
|
|
|
183
191
|
p.log.info("Rebasing onto origin/main...");
|
|
@@ -194,7 +202,11 @@ with the base branch included.
|
|
|
194
202
|
p.log.success("Base branch rebased onto main");
|
|
195
203
|
|
|
196
204
|
// Prompt to push base
|
|
197
|
-
|
|
205
|
+
if (noPush) {
|
|
206
|
+
p.log.info(`Not pushing ${pc.yellow(baseBranch)} (--no-push) — it's now ahead of origin`);
|
|
207
|
+
} else {
|
|
208
|
+
await promptForcePush(baseBranch);
|
|
209
|
+
}
|
|
198
210
|
}
|
|
199
211
|
|
|
200
212
|
// Step 2: Restack children
|
|
@@ -218,7 +230,7 @@ with the base branch included.
|
|
|
218
230
|
// Switch to first child and run restack
|
|
219
231
|
if (children.length > 0) {
|
|
220
232
|
await git.checkout(children[0]!);
|
|
221
|
-
await restack(dryRun ? ["--dry-run"] : []);
|
|
233
|
+
await restack([...(dryRun ? ["--dry-run"] : []), ...(noPush ? ["--no-push"] : [])]);
|
|
222
234
|
}
|
|
223
235
|
}
|
|
224
236
|
|
|
@@ -246,6 +258,7 @@ async function advanceMergedBottoms(
|
|
|
246
258
|
stackName: string,
|
|
247
259
|
trunk: string,
|
|
248
260
|
dryRun: boolean,
|
|
261
|
+
noPush: boolean,
|
|
249
262
|
): Promise<AdvanceOutcome> {
|
|
250
263
|
const stack = meta.stacks[stackName]!;
|
|
251
264
|
const ordered = getOrderedBranches(stack);
|
|
@@ -302,6 +315,7 @@ async function advanceMergedBottoms(
|
|
|
302
315
|
childPr,
|
|
303
316
|
targetRef,
|
|
304
317
|
recordedBase: trunk,
|
|
318
|
+
push: !noPush,
|
|
305
319
|
});
|
|
306
320
|
if (!res.ok) {
|
|
307
321
|
console.log();
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../lib/metadata.ts";
|
|
18
18
|
import { ensureMetadata } from "../lib/safety.ts";
|
|
19
19
|
import {
|
|
20
|
+
findOpenPrForBranch,
|
|
20
21
|
getPrInfo,
|
|
21
22
|
getPrBody,
|
|
22
23
|
updatePrBody,
|
|
@@ -102,6 +103,32 @@ function vizHash(viz: string): string {
|
|
|
102
103
|
return createHash("sha1").update(viz).digest("hex");
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Whether a live PR body already carries exactly this viz block. GitHub
|
|
108
|
+
* stores bodies edited in its web UI with CRLF line endings, so normalize
|
|
109
|
+
* before comparing — otherwise every such PR would look perpetually stale.
|
|
110
|
+
*/
|
|
111
|
+
export function bodyHasViz(body: string, viz: string): boolean {
|
|
112
|
+
return body.replace(/\r\n/g, "\n").includes(viz);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Record PR numbers for tracked branches whose PR was opened outside gh-stack
|
|
117
|
+
* (e.g. `gh pr create`), by looking up the open PR for each PR-less branch.
|
|
118
|
+
* Only an unambiguous single match is adopted. Returns the branches adopted —
|
|
119
|
+
* the caller persists metadata when non-empty. (#35)
|
|
120
|
+
*/
|
|
121
|
+
export async function discoverMissingPrs(stack: Stack, ordered: string[]): Promise<string[]> {
|
|
122
|
+
const missing = ordered.filter((b) => stack.branches[b]!.pr == null);
|
|
123
|
+
const found = await mapLimit(missing, 8, async (branch) => {
|
|
124
|
+
const pr = await findOpenPrForBranch(branch);
|
|
125
|
+
if (pr == null) return null;
|
|
126
|
+
stack.branches[branch]!.pr = pr;
|
|
127
|
+
return branch;
|
|
128
|
+
});
|
|
129
|
+
return found.filter((b): b is string => b != null);
|
|
130
|
+
}
|
|
131
|
+
|
|
105
132
|
/** Splice the rendered viz block into a PR body, replacing any existing one. */
|
|
106
133
|
function mergeViz(body: string, viz: string): string {
|
|
107
134
|
let base = body;
|
|
@@ -201,6 +228,7 @@ export interface VizResult {
|
|
|
201
228
|
updated: number; // PRs whose description we PATCHed
|
|
202
229
|
unchanged: number; // PRs skipped because their rendered block was identical
|
|
203
230
|
skipped: number; // branches with no PR
|
|
231
|
+
skippedBranches: string[]; // …and their names, so callers can say which
|
|
204
232
|
dirty: boolean; // metadata mutated (caller should persist)
|
|
205
233
|
}
|
|
206
234
|
|
|
@@ -213,12 +241,17 @@ export interface VizResult {
|
|
|
213
241
|
* body: the override is used as the base instead of the current GitHub body,
|
|
214
242
|
* the managed `📚 Stacked on` block is re-merged into it, and the PR is updated
|
|
215
243
|
* even if the viz block itself didn't change.
|
|
244
|
+
*
|
|
245
|
+
* `verifyLive` checks each PR whose cached hash matches against its actual
|
|
246
|
+
* body, so a viz block removed by an external edit (`gh pr edit`, the web UI)
|
|
247
|
+
* is restored rather than reported "unchanged". It costs one read per PR, so
|
|
248
|
+
* `update-prs` opts in while `submit` stays on the cheap hash check. (#34)
|
|
216
249
|
*/
|
|
217
250
|
export async function refreshStackViz(
|
|
218
251
|
meta: StackMetadata,
|
|
219
252
|
stack: Stack,
|
|
220
253
|
ordered: string[],
|
|
221
|
-
opts: { force?: boolean; bodyOverrides?: Record<string, string> } = {},
|
|
254
|
+
opts: { force?: boolean; verifyLive?: boolean; bodyOverrides?: Record<string, string> } = {},
|
|
222
255
|
): Promise<VizResult> {
|
|
223
256
|
const force = opts.force ?? false;
|
|
224
257
|
const bodyOverrides = opts.bodyOverrides ?? {};
|
|
@@ -239,18 +272,34 @@ export async function refreshStackViz(
|
|
|
239
272
|
const bm = stack.branches[info.branch]!;
|
|
240
273
|
// A body override forces the PATCH even when the viz block is unchanged.
|
|
241
274
|
const overridden = bodyOverrides[info.branch] != null;
|
|
242
|
-
|
|
275
|
+
const needsUpdate = force || overridden || bm.vizHash !== hash;
|
|
276
|
+
return { info, viz, hash, bm, needsUpdate, liveBody: null as string | null };
|
|
243
277
|
});
|
|
244
278
|
|
|
279
|
+
if (opts.verifyLive) {
|
|
280
|
+
await mapLimit(
|
|
281
|
+
plans.filter((plan) => !plan.needsUpdate),
|
|
282
|
+
8,
|
|
283
|
+
async (plan) => {
|
|
284
|
+
const body = await getPrBody(plan.info.prNumber!);
|
|
285
|
+
if (body !== null && !bodyHasViz(body, plan.viz)) {
|
|
286
|
+
plan.needsUpdate = true;
|
|
287
|
+
plan.liveBody = body;
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
245
293
|
const toUpdate = plans.filter((plan) => plan.needsUpdate);
|
|
246
294
|
const unchanged = plans.length - toUpdate.length;
|
|
247
|
-
const
|
|
295
|
+
const skippedBranches = branchInfos.filter((b) => b.prNumber == null).map((b) => b.branch);
|
|
296
|
+
const skipped = skippedBranches.length;
|
|
248
297
|
|
|
249
298
|
const oks = await mapLimit(toUpdate, 8, async (plan) => {
|
|
250
299
|
// `submit -b` replaces the body; otherwise keep the PR's current body and
|
|
251
300
|
// just re-merge the viz block into it.
|
|
252
301
|
const override = bodyOverrides[plan.info.branch];
|
|
253
|
-
const body = override ?? (await getPrBody(plan.info.prNumber!));
|
|
302
|
+
const body = override ?? plan.liveBody ?? (await getPrBody(plan.info.prNumber!));
|
|
254
303
|
if (body === null) return false;
|
|
255
304
|
const ok = await updatePrBody(plan.info.prNumber!, mergeViz(body, plan.viz));
|
|
256
305
|
if (ok) plan.bm.vizHash = plan.hash;
|
|
@@ -260,7 +309,7 @@ export async function refreshStackViz(
|
|
|
260
309
|
const updated = oks.filter(Boolean).length;
|
|
261
310
|
if (updated > 0) dirty = true;
|
|
262
311
|
|
|
263
|
-
return { updated, unchanged, skipped, dirty };
|
|
312
|
+
return { updated, unchanged, skipped, skippedBranches, dirty };
|
|
264
313
|
}
|
|
265
314
|
|
|
266
315
|
export default async function updatePrs(args: string[]): Promise<void> {
|
|
@@ -272,8 +321,13 @@ USAGE
|
|
|
272
321
|
gh-stack update-prs [--force]
|
|
273
322
|
|
|
274
323
|
Updates all PRs in the current stack with a standardized stack section
|
|
275
|
-
showing the tree structure, PR links, and per-PR stack index.
|
|
276
|
-
|
|
324
|
+
showing the tree structure, PR links, and per-PR stack index. Each PR's
|
|
325
|
+
live description is checked, so a stack section removed by an outside
|
|
326
|
+
edit (gh pr edit, the GitHub UI) is restored. PRs that already show the
|
|
327
|
+
current section are left alone; --force rewrites all.
|
|
328
|
+
|
|
329
|
+
Tracked branches with no recorded PR are looked up on GitHub first, so a
|
|
330
|
+
PR opened outside gh-stack (e.g. gh pr create) is adopted automatically.
|
|
277
331
|
`);
|
|
278
332
|
return;
|
|
279
333
|
}
|
|
@@ -298,17 +352,30 @@ PRs whose rendered block is unchanged are skipped; --force rewrites all.
|
|
|
298
352
|
|
|
299
353
|
const s = p.spinner();
|
|
300
354
|
s.start("Updating PR titles & descriptions...");
|
|
355
|
+
const adopted = await discoverMissingPrs(stack, ordered);
|
|
301
356
|
const titles = await reconcilePrTitles(stack, ordered);
|
|
302
|
-
const result = await refreshStackViz(meta, stack, ordered, { force });
|
|
357
|
+
const result = await refreshStackViz(meta, stack, ordered, { force, verifyLive: true });
|
|
303
358
|
s.stop("Done");
|
|
304
359
|
|
|
305
|
-
if (titles.dirty || result.dirty) await writeMetadata(meta);
|
|
360
|
+
if (adopted.length > 0 || titles.dirty || result.dirty) await writeMetadata(meta);
|
|
361
|
+
|
|
362
|
+
for (const b of adopted) {
|
|
363
|
+
p.log.success(
|
|
364
|
+
`Adopted PR #${stack.branches[b]!.pr} for ${pc.blue(b)} (opened outside gh-stack)`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
if (result.skippedBranches.length > 0) {
|
|
368
|
+
p.log.warn(
|
|
369
|
+
`Skipped ${result.skippedBranches.length} branch(es) with no open PR: ${result.skippedBranches.map((b) => pc.blue(b)).join(", ")}\n` +
|
|
370
|
+
` Open them with ${pc.green("gh-stack submit")}`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
306
373
|
|
|
307
374
|
console.log();
|
|
308
375
|
p.outro(
|
|
309
376
|
pc.green(
|
|
310
377
|
`Updated ${result.updated} description(s), ${titles.updated} title(s)` +
|
|
311
|
-
(result.unchanged > 0 ? `, ${result.unchanged}
|
|
378
|
+
(result.unchanged > 0 ? `, ${result.unchanged} already up to date` : "") +
|
|
312
379
|
(result.skipped > 0 ? `, ${result.skipped} without PRs` : ""),
|
|
313
380
|
),
|
|
314
381
|
);
|
package/src/lib/advance.ts
CHANGED
|
@@ -146,8 +146,11 @@ 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;
|
|
149
151
|
}): Promise<AdvanceResult> {
|
|
150
152
|
const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
|
|
153
|
+
const push = opts.push ?? true;
|
|
151
154
|
|
|
152
155
|
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
153
156
|
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
@@ -164,15 +167,23 @@ export async function advanceNewBottom(opts: {
|
|
|
164
167
|
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
165
168
|
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
166
169
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
if (push) {
|
|
171
|
+
// Push the rebased head first so the PR diff is clean before its base flips.
|
|
172
|
+
await git.pushStackedBranch(child);
|
|
173
|
+
if (childPr != null) {
|
|
174
|
+
const moved = await setPrBase(childPr, recordedBase);
|
|
175
|
+
if (moved) {
|
|
176
|
+
p.log.success(`PR #${childPr} base → ${pc.green(recordedBase)}`);
|
|
177
|
+
} else {
|
|
178
|
+
p.log.warn(`Could not retarget PR #${childPr} — set its base to ${recordedBase} manually`);
|
|
179
|
+
}
|
|
175
180
|
}
|
|
181
|
+
} else if (childPr != null) {
|
|
182
|
+
// Retargeting the base without pushing the rebased head would show the
|
|
183
|
+
// merged commits in the PR diff, so leave both for `submit` to reconcile.
|
|
184
|
+
p.log.info(
|
|
185
|
+
`Not pushing ${pc.yellow(child)} or retargeting PR #${childPr} (--no-push) — ${pc.green("gh-stack submit")} publishes both`,
|
|
186
|
+
);
|
|
176
187
|
}
|
|
177
188
|
|
|
178
189
|
// Metadata last: re-parents child (and siblings) onto the merged branch's
|
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)
|
package/src/lib/git.ts
CHANGED
|
@@ -346,6 +346,20 @@ export async function allLocalBranches(): Promise<string[]> {
|
|
|
346
346
|
return result.trim().split("\n").filter(Boolean);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Map every local branch to its tip SHA, in one call.
|
|
351
|
+
*/
|
|
352
|
+
export async function localBranchTips(): Promise<Map<string, string>> {
|
|
353
|
+
const result =
|
|
354
|
+
await $`git for-each-ref --format=${"%(refname:short) %(objectname)"} refs/heads`.text();
|
|
355
|
+
const tips = new Map<string, string>();
|
|
356
|
+
for (const line of result.trim().split("\n").filter(Boolean)) {
|
|
357
|
+
const [name, sha] = line.split(" ");
|
|
358
|
+
if (name && sha) tips.set(name, sha);
|
|
359
|
+
}
|
|
360
|
+
return tips;
|
|
361
|
+
}
|
|
362
|
+
|
|
349
363
|
/**
|
|
350
364
|
* Determine the trunk branch name. Prefers "main", falls back to "master"
|
|
351
365
|
* if only that exists locally. Defaults to "main" when neither is present.
|
package/src/lib/github.ts
CHANGED
|
@@ -40,6 +40,26 @@ export async function getPrNumber(branch: string): Promise<number | null> {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Find the open PR whose head is `branch`, for adopting a PR opened outside
|
|
45
|
+
* gh-stack. Unlike getPrNumber, returns null unless there is exactly one match
|
|
46
|
+
* — a fork with the same branch name must never be adopted by accident.
|
|
47
|
+
*/
|
|
48
|
+
export async function findOpenPrForBranch(branch: string): Promise<number | null> {
|
|
49
|
+
try {
|
|
50
|
+
const out =
|
|
51
|
+
await $`gh pr list --head ${branch} --state open --json number --jq '.[].number'`.text();
|
|
52
|
+
const nums = out
|
|
53
|
+
.trim()
|
|
54
|
+
.split(/\s+/)
|
|
55
|
+
.filter(Boolean)
|
|
56
|
+
.map((n) => parseInt(n, 10));
|
|
57
|
+
return nums.length === 1 && !isNaN(nums[0]!) ? nums[0]! : null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
/**
|
|
44
64
|
* Get PR info for a specific PR number.
|
|
45
65
|
*/
|
package/src/lib/output.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// - no ANSI colors (picocolors auto-disables via NO_COLOR; we also strip
|
|
8
8
|
// any residual escapes defensively)
|
|
9
9
|
//
|
|
10
|
-
// Interactive prompts (text, confirm, select) are
|
|
10
|
+
// Interactive prompts (text, confirm, select) are unchanged by plain mode —
|
|
11
11
|
// plain mode is about output, not interactivity. Agents already pair --plain
|
|
12
|
-
// with --yes/GH_STACK_YES so the interactive paths aren't reached.
|
|
12
|
+
// with --yes/GH_STACK_YES so the interactive paths aren't reached. If one IS
|
|
13
|
+
// reached with no TTY on stdin, nothing can ever answer it, so we fail fast
|
|
14
|
+
// instead of blocking forever (#33).
|
|
13
15
|
import * as clack from "@clack/prompts";
|
|
14
16
|
|
|
15
17
|
let _plain = false;
|
|
@@ -27,11 +29,38 @@ function strip(s: string | undefined): string {
|
|
|
27
29
|
return (s ?? "").replace(ANSI_RE, "");
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
// ── Interactive prompts
|
|
32
|
+
// ── Interactive prompts ──
|
|
31
33
|
export const isCancel = clack.isCancel;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Exit non-zero instead of prompting when stdin isn't a TTY. A prompt there
|
|
37
|
+
* can never be answered, and it would otherwise hang (holding the repo) past
|
|
38
|
+
* the caller's timeout. (#33)
|
|
39
|
+
*/
|
|
40
|
+
function ensureInteractive(message: string): void {
|
|
41
|
+
if (process.stdin.isTTY) return;
|
|
42
|
+
console.error(
|
|
43
|
+
`gh-stack: refusing to prompt — stdin is not a TTY.\n` +
|
|
44
|
+
` Prompt: ${strip(message)}\n` +
|
|
45
|
+
` Pass --yes (or set GH_STACK_YES=1) to confirm automatically.`,
|
|
46
|
+
);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const text: typeof clack.text = (opts) => {
|
|
51
|
+
ensureInteractive(opts.message);
|
|
52
|
+
return clack.text(opts);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const confirm: typeof clack.confirm = (opts) => {
|
|
56
|
+
ensureInteractive(opts.message);
|
|
57
|
+
return clack.confirm(opts);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const select: typeof clack.select = (opts) => {
|
|
61
|
+
ensureInteractive(opts.message);
|
|
62
|
+
return clack.select(opts);
|
|
63
|
+
};
|
|
35
64
|
|
|
36
65
|
// ── Banner / error ──
|
|
37
66
|
export function intro(message?: string): void {
|
|
@@ -64,6 +64,13 @@ export GH_STACK_YES=1 # skip every confirmation, plain output
|
|
|
64
64
|
of generic auto-generated ones (see below).
|
|
65
65
|
- Commands that rebase (`restack`, `sync`, `merge`) **refuse to run on a dirty
|
|
66
66
|
tree** — commit or stash first.
|
|
67
|
+
- **`--yes` also accepts pushes.** To rebase without publishing (e.g. until a
|
|
68
|
+
human approves), add `--no-push` to `restack`/`sync`, or export
|
|
69
|
+
`GH_STACK_NO_PUSH=1`. It wins over `--yes` for pushes and lists what's ahead
|
|
70
|
+
of origin at the end.
|
|
71
|
+
- A prompt reached without a TTY on stdin **exits 1** (naming the prompt)
|
|
72
|
+
instead of hanging. If you see `refusing to prompt`, re-run with
|
|
73
|
+
`GH_STACK_YES=1`.
|
|
67
74
|
|
|
68
75
|
## Core workflow
|
|
69
76
|
|
|
@@ -94,10 +101,17 @@ created a branch with `git checkout -b` and want to `sync`/`restack` it _before_
|
|
|
94
101
|
opening a PR, `add` registers it in a stack (network-free — no push, no PR):
|
|
95
102
|
|
|
96
103
|
```bash
|
|
97
|
-
gh-stack add
|
|
98
|
-
gh-stack add <branch>
|
|
104
|
+
gh-stack add # track the current branch
|
|
105
|
+
gh-stack add <branch> # track a named branch
|
|
106
|
+
gh-stack add --parent <branch> # stack the current branch directly on <branch>
|
|
99
107
|
```
|
|
100
108
|
|
|
109
|
+
**A branch with no commits yet can't be placed by ancestry.** It sits _at_ its
|
|
110
|
+
parent's tip, so `init`/`add` root it at trunk and warn that it "has no commits
|
|
111
|
+
of its own". Fix it with `gh-stack add --parent <parent>`, which tracks the
|
|
112
|
+
parent too if needed. `--parent` also re-homes a branch that's already tracked,
|
|
113
|
+
but only when it's alone in its stack with no PR.
|
|
114
|
+
|
|
101
115
|
It walks local ancestry: a branch sitting on top of an existing stack is adopted
|
|
102
116
|
into it (parent auto-detected); otherwise a new stack is created from the chain.
|
|
103
117
|
This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
|
|
@@ -163,6 +177,7 @@ gh-stack checkout # interactive picker (alias: co)
|
|
|
163
177
|
gh-stack sync # fetch trunk, rebase base onto it, restack ALL children
|
|
164
178
|
gh-stack restack # rebase current branch + descendants onto their parents
|
|
165
179
|
gh-stack restack --dry-run
|
|
180
|
+
gh-stack restack --no-push # rebase locally only (also: GH_STACK_NO_PUSH=1)
|
|
166
181
|
```
|
|
167
182
|
|
|
168
183
|
**⚠️ Restack propagates UPWARD from the current branch.** Run it from the
|
|
@@ -229,7 +244,7 @@ gh-stack rename # rename a tracked branch (keeps metadata in sync)
|
|
|
229
244
|
gh-stack undo # restore from the last snapshot
|
|
230
245
|
gh-stack archive # manage archived stacks
|
|
231
246
|
gh-stack doctor # migrate & repair stack metadata
|
|
232
|
-
gh-stack update-prs #
|
|
247
|
+
gh-stack update-prs # repair stack viz in PR descriptions + adopt PRs opened with gh pr create
|
|
233
248
|
```
|
|
234
249
|
|
|
235
250
|
## Stack visualization
|
package/src/types.ts
CHANGED