gh-stack 0.17.1 → 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 +35 -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 +76 -36
- package/src/commands/sync.ts +19 -5
- package/src/commands/update-prs.ts +77 -10
- package/src/lib/advance.ts +27 -18
- package/src/lib/chain.ts +20 -0
- package/src/lib/git.ts +23 -0
- package/src/lib/github.ts +20 -0
- package/src/lib/output.ts +35 -6
- package/src/lib/snapshot.ts +72 -52
- 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
|
|
|
@@ -342,6 +366,8 @@ The critical insight: after a parent branch's history is rewritten (e.g., rebase
|
|
|
342
366
|
|
|
343
367
|
gh-stack solves this by snapshotting every branch's tip **before** any destructive operation. When restacking a child whose parent has been rewritten, gh-stack walks the snapshots newest-first and finds the most recent recorded tip that's no longer an ancestor of the parent's current tip. That orphaned SHA is the correct rebase base — `git rebase --onto <new-parent-tip> <orphaned-old-tip> <child>` replays only the child's unique commits.
|
|
344
368
|
|
|
369
|
+
If the child sat **behind** the parent when the snapshot was taken (a placeholder branch created before any work landed on it), the recorded tip isn't in the child's history — but it still pins the parent's pre-rewrite history, so the boundary is derived as the child's fork point on it (`merge-base(child, recorded-tip)`). A child with no unique commits is simply moved to the parent's tip, no rebase involved. When no safe boundary can be determined at all, restack refuses with a manual `git rebase --onto` recipe rather than guessing.
|
|
370
|
+
|
|
345
371
|
Snapshots also power `gh-stack undo`, so the same data structure does double duty.
|
|
346
372
|
|
|
347
373
|
### Stack Position in PR Titles
|
|
@@ -516,6 +542,9 @@ gh-stack submit -n # Or auto-generate titles
|
|
|
516
542
|
gh-stack sync
|
|
517
543
|
gh-stack restack
|
|
518
544
|
|
|
545
|
+
# Rebase without publishing (e.g. before a human approves the push)
|
|
546
|
+
GH_STACK_NO_PUSH=1 gh-stack restack
|
|
547
|
+
|
|
519
548
|
# Structured output
|
|
520
549
|
gh-stack status --json
|
|
521
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
|
@@ -15,12 +15,25 @@ import {
|
|
|
15
15
|
writeMetadata,
|
|
16
16
|
} from "../lib/metadata.ts";
|
|
17
17
|
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
18
|
-
import { takeSnapshot,
|
|
18
|
+
import { takeSnapshot, resolveRestackBoundary, findRecordedBaseTip } from "../lib/snapshot.ts";
|
|
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()) {
|
|
@@ -549,49 +583,45 @@ async function processChain(
|
|
|
549
583
|
continue;
|
|
550
584
|
}
|
|
551
585
|
|
|
552
|
-
// Resolve the rebase
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
// 1. Look for a recent snapshot where the parent's recorded tip is no
|
|
556
|
-
// longer an ancestor of the parent's current tip AND is still an
|
|
557
|
-
// ancestor of the child. That recorded SHA is the orphaned
|
|
558
|
-
// old-parent tip — exactly the rebase base we need.
|
|
559
|
-
// 2. Fall back to `merge-base(branch, parent)` if no snapshot tells us
|
|
560
|
-
// the parent was rewritten (typical case: parent only had commits
|
|
561
|
-
// appended, not rebased).
|
|
562
|
-
//
|
|
563
|
-
// The child-ancestry check (issue #5) prevents stale snapshots from
|
|
564
|
-
// older sessions — where the child has already been rebased past the
|
|
565
|
-
// recorded point — from being used as a rebase base.
|
|
586
|
+
// Resolve the rebase boundary — snapshot pre-rewrite tip, a fork point
|
|
587
|
+
// derived from a rejected snapshot (#30), or merge-base, in that order.
|
|
588
|
+
// See resolveRestackBoundary for the full strategy.
|
|
566
589
|
//
|
|
567
590
|
// Re-read metadata fresh because previous iterations of this loop may
|
|
568
591
|
// have updated snapshots.
|
|
569
592
|
const freshMeta = (await readMetadata()) ?? meta;
|
|
570
|
-
const
|
|
571
|
-
const oldBaseFromMergeBase = await git.mergeBase(branch, parent);
|
|
572
|
-
const oldBase = oldBaseFromSnapshot ?? oldBaseFromMergeBase;
|
|
593
|
+
const boundary = await resolveRestackBoundary(freshMeta, parent, branch);
|
|
573
594
|
|
|
574
595
|
if (verbose) {
|
|
575
596
|
console.log();
|
|
576
597
|
console.log(pc.yellow(" Diagnostic: base resolution"));
|
|
577
598
|
console.log(
|
|
578
|
-
` ${pc.cyan("
|
|
579
|
-
);
|
|
580
|
-
console.log(
|
|
581
|
-
` ${pc.cyan("Current merge-base:")} ${oldBaseFromMergeBase?.slice(0, 8) ?? "(none)"}`,
|
|
599
|
+
` ${pc.cyan("Boundary:")} ${boundary ? `${boundary.sha.slice(0, 8)} (${boundary.source})` : "(none)"}`,
|
|
582
600
|
);
|
|
583
|
-
if (
|
|
584
|
-
oldBaseFromSnapshot &&
|
|
585
|
-
oldBaseFromMergeBase &&
|
|
586
|
-
oldBaseFromSnapshot !== oldBaseFromMergeBase
|
|
587
|
-
) {
|
|
601
|
+
if (boundary && boundary.source !== "merge-base") {
|
|
588
602
|
console.log(
|
|
589
|
-
` ${pc.yellow("⚠ Parent was rewritten")} —
|
|
603
|
+
` ${pc.yellow("⚠ Parent was rewritten")} — merge-base alone would have replayed parent commits`,
|
|
590
604
|
);
|
|
591
605
|
}
|
|
592
606
|
console.log();
|
|
593
607
|
}
|
|
594
608
|
|
|
609
|
+
if (!boundary) {
|
|
610
|
+
// No snapshot evidence and no common history — any base would be a
|
|
611
|
+
// guess, and a wrong guess replays the parent's history onto itself.
|
|
612
|
+
// Refuse with the manual command, consistent with the --onto reroot
|
|
613
|
+
// path. (#30: never silently guess when we can't derive the boundary.)
|
|
614
|
+
p.log.error(
|
|
615
|
+
`Cannot determine a safe rebase base for ${pc.yellow(branch)} onto ${pc.yellow(parent)}.`,
|
|
616
|
+
);
|
|
617
|
+
console.log();
|
|
618
|
+
console.log(" Rebase it manually with an explicit boundary:");
|
|
619
|
+
console.log(` ${pc.green(`git rebase --onto ${parent} <old-parent-tip> ${branch}`)}`);
|
|
620
|
+
console.log();
|
|
621
|
+
console.log(` Then re-run ${pc.green("gh-stack restack")} to continue the chain.`);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
|
|
595
625
|
// Checkout the branch
|
|
596
626
|
p.log.info(`Checking out ${pc.yellow(branch)}...`);
|
|
597
627
|
await git.checkout(branch);
|
|
@@ -601,24 +631,32 @@ async function processChain(
|
|
|
601
631
|
current_index: i,
|
|
602
632
|
stack_name: stackName,
|
|
603
633
|
chain,
|
|
634
|
+
...(noPush && { no_push: true }),
|
|
604
635
|
});
|
|
605
636
|
|
|
606
637
|
let success: boolean;
|
|
607
|
-
|
|
608
|
-
|
|
638
|
+
const branchTip = await git.revParse(branch);
|
|
639
|
+
if (boundary.sha === branchTip) {
|
|
640
|
+
// The child has no commits of its own (a placeholder branch created off
|
|
641
|
+
// the parent before any work landed). Nothing to replay — move the ref
|
|
642
|
+
// instead of invoking rebase. (#30)
|
|
643
|
+
p.log.info(
|
|
644
|
+
`${pc.yellow(branch)} has no unique commits — moving it to ${pc.yellow(parent)}'s tip`,
|
|
645
|
+
);
|
|
646
|
+
success = await git.resetHard(parent);
|
|
647
|
+
} else {
|
|
648
|
+
const sourceLabel =
|
|
649
|
+
boundary.source === "snapshot-derived" ? "snapshot (derived fork-point)" : boundary.source;
|
|
609
650
|
p.log.info(
|
|
610
|
-
`Rebasing onto ${pc.yellow(parent)} (base: ${
|
|
651
|
+
`Rebasing onto ${pc.yellow(parent)} (base: ${boundary.sha.slice(0, 8)} from ${sourceLabel})`,
|
|
611
652
|
);
|
|
612
653
|
|
|
613
654
|
if (verbose) {
|
|
614
|
-
const count = await git.commitCount(
|
|
655
|
+
const count = await git.commitCount(boundary.sha, branch);
|
|
615
656
|
console.log(` Moving ${count} commit(s)`);
|
|
616
657
|
}
|
|
617
658
|
|
|
618
|
-
success = await git.rebaseOnto(parent,
|
|
619
|
-
} else {
|
|
620
|
-
p.log.warn("Could not determine rebase base — using simple rebase");
|
|
621
|
-
success = await git.rebase(parent);
|
|
659
|
+
success = await git.rebaseOnto(parent, boundary.sha, branch);
|
|
622
660
|
}
|
|
623
661
|
|
|
624
662
|
if (success) {
|
|
@@ -669,6 +707,8 @@ function branchHasPr(meta: StackMetadata, branch: string): boolean {
|
|
|
669
707
|
|
|
670
708
|
async function pushBranchRef(meta: StackMetadata, branch: string): Promise<void> {
|
|
671
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;
|
|
672
712
|
|
|
673
713
|
const localSha = await git.revParse(branch);
|
|
674
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;
|
|
@@ -146,7 +153,7 @@ with the base branch included.
|
|
|
146
153
|
|
|
147
154
|
// Take snapshot BEFORE rebasing. This records every branch's pre-rebase
|
|
148
155
|
// tip — including the orphaned base-branch tip that restack will later
|
|
149
|
-
// need to find when restacking children. (See
|
|
156
|
+
// need to find when restacking children. (See resolveRestackBoundary.)
|
|
150
157
|
await takeSnapshot(meta, stackName, "sync");
|
|
151
158
|
|
|
152
159
|
// Step 1: Rebase base branch onto main
|
|
@@ -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
|
@@ -21,7 +21,7 @@ import pc from "picocolors";
|
|
|
21
21
|
import * as git from "./git.ts";
|
|
22
22
|
import type { StackMetadata } from "../types.ts";
|
|
23
23
|
import { removeBranchFromStack } from "./metadata.ts";
|
|
24
|
-
import {
|
|
24
|
+
import { resolveRestackBoundary } from "./snapshot.ts";
|
|
25
25
|
import { setPrBase } from "./github.ts";
|
|
26
26
|
|
|
27
27
|
/** What a single bottom branch needs: an active squash-merge, or just advancing
|
|
@@ -109,21 +109,19 @@ function reviewReason(decision: string | null): string {
|
|
|
109
109
|
|
|
110
110
|
/**
|
|
111
111
|
* Resolve the rebase fork-point for re-rooting `child` after `mergedBranch`
|
|
112
|
-
* landed on the trunk.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* Returns null if neither resolves (caller must abort rather than guess).
|
|
112
|
+
* landed on the trunk. Shares restack's resolver (snapshot pre-rewrite tip →
|
|
113
|
+
* derived fork point → merge-base; see resolveRestackBoundary) — the snapshot
|
|
114
|
+
* paths handle a merged branch that was itself rebased earlier in a
|
|
115
|
+
* multi-merge cascade, including a child that sat behind it (#30).
|
|
116
|
+
* Returns null if nothing resolves (caller must abort rather than guess).
|
|
118
117
|
*/
|
|
119
118
|
export async function resolveForkPoint(
|
|
120
119
|
meta: StackMetadata,
|
|
121
120
|
mergedBranch: string,
|
|
122
121
|
child: string,
|
|
123
122
|
): Promise<string | null> {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
return git.mergeBase(child, mergedBranch);
|
|
123
|
+
const boundary = await resolveRestackBoundary(meta, mergedBranch, child);
|
|
124
|
+
return boundary?.sha ?? null;
|
|
127
125
|
}
|
|
128
126
|
|
|
129
127
|
/** Outcome of advancing a single new bottom onto the trunk. */
|
|
@@ -148,8 +146,11 @@ export async function advanceNewBottom(opts: {
|
|
|
148
146
|
childPr: number | null;
|
|
149
147
|
targetRef: string;
|
|
150
148
|
recordedBase: string;
|
|
149
|
+
/** false = `--no-push`: rebase + metadata only, leave origin and the PR alone (#36). */
|
|
150
|
+
push?: boolean;
|
|
151
151
|
}): Promise<AdvanceResult> {
|
|
152
152
|
const { meta, stackName, mergedBranch, child, childPr, targetRef, recordedBase } = opts;
|
|
153
|
+
const push = opts.push ?? true;
|
|
153
154
|
|
|
154
155
|
const forkPoint = await resolveForkPoint(meta, mergedBranch, child);
|
|
155
156
|
if (!forkPoint) return { ok: false, reason: "no-fork-point" };
|
|
@@ -166,15 +167,23 @@ export async function advanceNewBottom(opts: {
|
|
|
166
167
|
const ok = await git.rebaseOnto(targetRef, forkPoint, child);
|
|
167
168
|
if (!ok) return { ok: false, reason: "conflict", forkPoint };
|
|
168
169
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
}
|
|
177
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
|
+
);
|
|
178
187
|
}
|
|
179
188
|
|
|
180
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
|
@@ -285,6 +285,15 @@ export async function rebaseOnto(
|
|
|
285
285
|
return exitCode === 0;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Move the current branch's ref to `ref` (working tree included).
|
|
290
|
+
* Returns true if successful. Caller must have verified the tree is clean.
|
|
291
|
+
*/
|
|
292
|
+
export async function resetHard(ref: string): Promise<boolean> {
|
|
293
|
+
const { exitCode } = await $`git reset --hard ${ref}`.nothrow().quiet();
|
|
294
|
+
return exitCode === 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
/**
|
|
289
298
|
* Simple rebase onto a target.
|
|
290
299
|
* Returns true if successful, false if conflicts occurred.
|
|
@@ -337,6 +346,20 @@ export async function allLocalBranches(): Promise<string[]> {
|
|
|
337
346
|
return result.trim().split("\n").filter(Boolean);
|
|
338
347
|
}
|
|
339
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
|
+
|
|
340
363
|
/**
|
|
341
364
|
* Determine the trunk branch name. Prefers "main", falls back to "master"
|
|
342
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 {
|
package/src/lib/snapshot.ts
CHANGED
|
@@ -13,77 +13,97 @@ import { mkdir, readdir, unlink } from "node:fs/promises";
|
|
|
13
13
|
|
|
14
14
|
const MAX_SNAPSHOTS_PER_STACK = 10;
|
|
15
15
|
|
|
16
|
+
/** Where a resolved restack boundary came from. */
|
|
17
|
+
export type BoundarySource = "snapshot" | "snapshot-derived" | "merge-base";
|
|
18
|
+
|
|
19
|
+
export type RestackBoundary = { sha: string; source: BoundarySource };
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
|
-
*
|
|
22
|
+
* Resolve the rebase boundary for restacking `childBranch` onto
|
|
23
|
+
* `parentBranch`: the commit whose range `boundary..child` contains exactly
|
|
24
|
+
* the child's own commits, for `git rebase --onto <parent> <boundary> <child>`.
|
|
25
|
+
*
|
|
26
|
+
* Resolution order:
|
|
18
27
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
28
|
+
* 1. `"snapshot"` (issue #2) — the most recent snapshot whose recorded tip for
|
|
29
|
+
* the parent is no longer an ancestor of the parent's current tip: that
|
|
30
|
+
* snapshot predates a rewrite of the parent (typically `sync` rebasing it
|
|
31
|
+
* onto a new main), and its orphaned old tip is exactly where the child's
|
|
32
|
+
* own commits start. The recorded tip must also still be an ancestor of the
|
|
33
|
+
* child (issue #5) — a stale tip the child was already rebased past would
|
|
34
|
+
* replay a wildly wrong range; those candidates are skipped, not used.
|
|
23
35
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
36
|
+
* 2. `"snapshot-derived"` (issue #30) — a snapshot proves the parent was
|
|
37
|
+
* rewritten but its recorded tip is not an ancestor of the child, e.g. the
|
|
38
|
+
* child sat BEHIND the parent at snapshot time (committed to the parent,
|
|
39
|
+
* then synced, before the child got its first commit). The recorded tip
|
|
40
|
+
* still pins the parent's pre-rewrite history, and the child forked
|
|
41
|
+
* somewhere on it: `merge-base(child, recordedTip)` is that fork point.
|
|
42
|
+
* Only used when it is a DESCENDANT of `merge-base(child, parent)` — the
|
|
43
|
+
* higher boundary is always the safe one, since everything below it already
|
|
44
|
+
* exists in the new base's history. (A stale #5-shaped snapshot derives a
|
|
45
|
+
* fork point BELOW the current merge-base; using it would replay the
|
|
46
|
+
* parent's commits, so the current merge-base wins there.)
|
|
27
47
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* been rebased past this point (e.g., a previous restack already moved it),
|
|
31
|
-
* the recorded SHA is stale for this child and would cause `rebase --onto`
|
|
32
|
-
* to replay a wildly wrong range. We skip those candidates and keep walking.
|
|
48
|
+
* 3. `"merge-base"` — `merge-base(child, parent)`, correct whenever the parent
|
|
49
|
+
* has only had commits appended, never been rewritten.
|
|
33
50
|
*
|
|
34
|
-
* Returns null
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
51
|
+
* Returns null when nothing resolves (no snapshot evidence and no common
|
|
52
|
+
* history) — the caller must refuse rather than guess.
|
|
53
|
+
*
|
|
54
|
+
* If the child branch doesn't exist locally, snapshot validation degrades to
|
|
55
|
+
* parent-only (the newest rewritten tip is accepted without the child check).
|
|
38
56
|
*/
|
|
39
|
-
export async function
|
|
57
|
+
export async function resolveRestackBoundary(
|
|
40
58
|
meta: StackMetadata,
|
|
41
59
|
parentBranch: string,
|
|
42
|
-
childBranch
|
|
43
|
-
): Promise<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
let currentParentTip: string;
|
|
60
|
+
childBranch: string,
|
|
61
|
+
): Promise<RestackBoundary | null> {
|
|
62
|
+
let currentParentTip: string | null = null;
|
|
47
63
|
try {
|
|
48
64
|
currentParentTip = await git.revParse(parentBranch);
|
|
49
65
|
} catch {
|
|
50
|
-
|
|
66
|
+
currentParentTip = null;
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
let currentChildTip: string | null = null;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Child doesn't exist locally — fall back to parent-only validation
|
|
59
|
-
currentChildTip = null;
|
|
60
|
-
}
|
|
70
|
+
try {
|
|
71
|
+
currentChildTip = await git.revParse(childBranch);
|
|
72
|
+
} catch {
|
|
73
|
+
currentChildTip = null;
|
|
61
74
|
}
|
|
62
75
|
|
|
63
|
-
// Walk newest → oldest
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// past this point (or never branched off it) and using it as a base
|
|
77
|
-
// would replay unrelated history.
|
|
78
|
-
if (currentChildTip) {
|
|
79
|
-
const stillAncestorOfChild = await git.isAncestor(recorded, currentChildTip);
|
|
80
|
-
if (!stillAncestorOfChild) continue;
|
|
76
|
+
// Walk snapshots newest → oldest for evidence the parent was rewritten.
|
|
77
|
+
let accepted: string | null = null;
|
|
78
|
+
let rejected: string | null = null; // newest rewritten tip that failed the child check
|
|
79
|
+
if (currentParentTip && meta.snapshots) {
|
|
80
|
+
for (let i = meta.snapshots.length - 1; i >= 0; i--) {
|
|
81
|
+
const recorded = meta.snapshots[i]!.branches[parentBranch];
|
|
82
|
+
if (!recorded || recorded === currentParentTip) continue;
|
|
83
|
+
if (await git.isAncestor(recorded, currentParentTip)) continue; // appended-to, not rewritten
|
|
84
|
+
if (!currentChildTip || (await git.isAncestor(recorded, currentChildTip))) {
|
|
85
|
+
accepted = recorded;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
rejected ??= recorded;
|
|
81
89
|
}
|
|
90
|
+
}
|
|
82
91
|
|
|
83
|
-
|
|
92
|
+
if (accepted) return { sha: accepted, source: "snapshot" };
|
|
93
|
+
|
|
94
|
+
const mbCurrent = await git.mergeBase(childBranch, parentBranch);
|
|
95
|
+
|
|
96
|
+
if (rejected && currentChildTip) {
|
|
97
|
+
const derived = await git.mergeBase(childBranch, rejected);
|
|
98
|
+
if (derived) {
|
|
99
|
+
if (!mbCurrent) return { sha: derived, source: "snapshot-derived" };
|
|
100
|
+
if (derived !== mbCurrent && (await git.isAncestor(mbCurrent, derived))) {
|
|
101
|
+
return { sha: derived, source: "snapshot-derived" };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
return null;
|
|
106
|
+
return mbCurrent ? { sha: mbCurrent, source: "merge-base" } : null;
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
/**
|
|
@@ -96,7 +116,7 @@ export async function findPreRewriteSha(
|
|
|
96
116
|
* `git rebase --onto <new-base> <baseTip> <root>` drops the already-merged
|
|
97
117
|
* prefix instead of replaying it.
|
|
98
118
|
*
|
|
99
|
-
* Unlike `
|
|
119
|
+
* Unlike `resolveRestackBoundary`, this does NOT require the base branch to still
|
|
100
120
|
* exist locally — that's the whole point: after a squash-merge deletes the base
|
|
101
121
|
* branch, its tip survives only in these snapshots. The ancestor-of-root check
|
|
102
122
|
* rejects stale candidates (an older base tip the root no longer sits on).
|
|
@@ -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