gh-stack 0.19.2 → 0.19.4
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 +10 -6
- package/package.json +4 -4
- package/src/commands/merge.ts +58 -12
- package/src/commands/sync.ts +110 -61
- package/src/lib/advance.ts +62 -6
- package/src/lib/github.ts +4 -2
- package/src/lib/using-gh-stack.md +8 -4
package/README.md
CHANGED
|
@@ -262,11 +262,14 @@ sync [--dry-run] [--no-push|--push] [--resume | --fresh] [--overwrite-remote]
|
|
|
262
262
|
Fetch main, rebase the base branch onto main, push it, then restack
|
|
263
263
|
all children (every branch above it, in one resumable run). Snapshots every branch's pre-sync tip so children can be
|
|
264
264
|
correctly rebased even though their parent's history was rewritten.
|
|
265
|
-
If the bottom PR(s) already merged
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
265
|
+
If the bottom PR(s) already merged outside gh-stack (e.g. the GitHub
|
|
266
|
+
web UI, or the base of a `merge --approved` landing with the PRs
|
|
267
|
+
collapsed into it), sync detects this and advances the stack past
|
|
268
|
+
them: it drops every merged branch and re-roots only the first
|
|
269
|
+
unmerged one onto main with `--onto`, so only its own commits replay,
|
|
270
|
+
instead of replaying the now-squashed commits and conflicting. It
|
|
271
|
+
warns about a merged PR whose changes never reached main (merged
|
|
272
|
+
into a branch after that branch's own PR had landed).
|
|
270
273
|
|
|
271
274
|
--no-push / --push (and their defaults): as for restack. Also skips
|
|
272
275
|
retargeting a PR's base after advancing past a merged bottom;
|
|
@@ -323,7 +326,8 @@ merge --base [--dry-run] [-d|--delete-branch] [--no-push|--push]
|
|
|
323
326
|
just its own commits). One PR per run — run it again to
|
|
324
327
|
land the next. Approval is enforced by GitHub branch
|
|
325
328
|
protection, not gh-stack. A base PR already merged outside
|
|
326
|
-
gh-stack is detected and advanced past
|
|
329
|
+
gh-stack is detected and advanced past, along with
|
|
330
|
+
PRs already collapsed into it. The re-root waits
|
|
327
331
|
until origin's main shows the merge commit, and stops (exit
|
|
328
332
|
2) if it never does. When a re-root stops (a conflict, or
|
|
329
333
|
main not showing the merge yet), finish it by re-running
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gh-stack",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.4",
|
|
4
4
|
"description": "Stacked PR manager for squash-merge workflows",
|
|
5
5
|
"author": "Kiliman <kiliman@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
]
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@types/bun": "1.
|
|
54
|
+
"@types/bun": "1.4.4",
|
|
55
55
|
"husky": "^9.1.7",
|
|
56
56
|
"lint-staged": "^17.6.0",
|
|
57
|
-
"oxfmt": "^0.
|
|
58
|
-
"oxlint": "^1.
|
|
57
|
+
"oxfmt": "^0.75.0",
|
|
58
|
+
"oxlint": "^1.90.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"typescript": "^5"
|
package/src/commands/merge.ts
CHANGED
|
@@ -423,11 +423,11 @@ OPTIONS
|
|
|
423
423
|
: "Single branch stack — enabling auto-merge on GitHub.",
|
|
424
424
|
);
|
|
425
425
|
if (!dryRun) {
|
|
426
|
-
|
|
427
|
-
|
|
426
|
+
const auto = await $`gh pr merge ${basePr} --squash --auto`.nothrow().quiet();
|
|
427
|
+
if (auto.exitCode === 0) {
|
|
428
428
|
p.log.success(`Auto-merge enabled for PR #${basePr}`);
|
|
429
|
-
}
|
|
430
|
-
|
|
429
|
+
} else {
|
|
430
|
+
autoMergeFallback(basePr, auto.stderr.toString());
|
|
431
431
|
}
|
|
432
432
|
}
|
|
433
433
|
} else {
|
|
@@ -721,6 +721,7 @@ OPTIONS
|
|
|
721
721
|
|
|
722
722
|
// verdict is "merged", "ready", or "blocked" (pending checks). All are safe to
|
|
723
723
|
// archive: the PR is either already merged or will merge once checks pass.
|
|
724
|
+
let autoMerging = true;
|
|
724
725
|
if (verdict === "merged") {
|
|
725
726
|
autoSpinner.stop(`PR #${basePr} already merged into main`);
|
|
726
727
|
} else {
|
|
@@ -740,7 +741,8 @@ OPTIONS
|
|
|
740
741
|
} else {
|
|
741
742
|
autoSpinner.stop(pc.yellow(`Could not enable auto-merge for PR #${basePr}`));
|
|
742
743
|
if (auto.stderr) p.log.error(auto.stderr);
|
|
743
|
-
|
|
744
|
+
autoMergeFallback(basePr!, auto.stderr);
|
|
745
|
+
autoMerging = false;
|
|
744
746
|
}
|
|
745
747
|
}
|
|
746
748
|
|
|
@@ -764,7 +766,7 @@ OPTIONS
|
|
|
764
766
|
if (tail.length > 0) {
|
|
765
767
|
p.outro(
|
|
766
768
|
pc.green(
|
|
767
|
-
`Approved run collapsed into PR #${basePr} → auto-merging to main. ` +
|
|
769
|
+
`Approved run collapsed into PR #${basePr}${autoMerging ? " → auto-merging to main" : ""}. ` +
|
|
768
770
|
`After it lands, run \`gh-stack sync\` to re-root the ${tail.length} remaining branch(es).`,
|
|
769
771
|
),
|
|
770
772
|
);
|
|
@@ -960,6 +962,36 @@ async function collapsedChildren(
|
|
|
960
962
|
return { collapsed, unknown: null };
|
|
961
963
|
}
|
|
962
964
|
|
|
965
|
+
/**
|
|
966
|
+
* What to do when `gh pr merge --auto` failed. A repo that doesn't allow
|
|
967
|
+
* auto-merge fails the same way every time, so don't suggest retrying it (#54).
|
|
968
|
+
*/
|
|
969
|
+
function autoMergeFallback(prNumber: number, stderr: string): void {
|
|
970
|
+
if (/auto.?merge is not allowed/i.test(stderr)) {
|
|
971
|
+
p.log.info(
|
|
972
|
+
`This repo doesn't allow auto-merge. Merge PR #${prNumber} once its checks pass: ${pc.dim(`gh pr merge ${prNumber} --squash`)}`,
|
|
973
|
+
);
|
|
974
|
+
} else {
|
|
975
|
+
p.log.info(`Enable manually: ${pc.dim(`gh pr merge ${prNumber} --squash --auto`)}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* The branches right above the base (`ordered[0]`) whose PRs already merged,
|
|
981
|
+
* bottom-up, stopping at the first that hasn't: PRs collapsed into the base,
|
|
982
|
+
* or landed on their own. A PR that can't be read stops the run, so it's
|
|
983
|
+
* re-rooted rather than dropped on a guess.
|
|
984
|
+
*/
|
|
985
|
+
async function mergedRunAbove(stack: Stack, ordered: string[]): Promise<string[]> {
|
|
986
|
+
const run: string[] = [];
|
|
987
|
+
for (const branch of ordered.slice(1)) {
|
|
988
|
+
const pr = stack.branches[branch]?.pr;
|
|
989
|
+
if (pr == null || (await getPrMergeState(pr))?.state !== "MERGED") break;
|
|
990
|
+
run.push(branch);
|
|
991
|
+
}
|
|
992
|
+
return run;
|
|
993
|
+
}
|
|
994
|
+
|
|
963
995
|
/**
|
|
964
996
|
* Fetch the trunk and confirm it holds PR `prNumber`'s merge commit (#51).
|
|
965
997
|
* The re-root rebases onto `origin/<trunk>`; a trunk fetched before the squash
|
|
@@ -1071,7 +1103,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1071
1103
|
const baseState = await getPrMergeState(basePr);
|
|
1072
1104
|
const baseAction: MergeAction = baseState?.state === "MERGED" ? "advance-only" : "merge";
|
|
1073
1105
|
const plan = { steps: [{ branch: baseBranch, pr: basePr, action: baseAction }] };
|
|
1074
|
-
|
|
1106
|
+
// PRs already merged above the base (collapsed into it, or landed on their
|
|
1107
|
+
// own) go with it: dropped, never re-rooted, since replaying their commits
|
|
1108
|
+
// onto a trunk that holds them squashed conflicts (#54).
|
|
1109
|
+
const mergedAbove = await mergedRunAbove(stack, ordered);
|
|
1110
|
+
const nextChild = ordered[1 + mergedAbove.length] ?? null;
|
|
1075
1111
|
|
|
1076
1112
|
console.log();
|
|
1077
1113
|
console.log(` ${pc.bold("Plan:")}`);
|
|
@@ -1082,6 +1118,11 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1082
1118
|
: `${pc.green("squash-merge")} → ${pc.green(trunk)}`
|
|
1083
1119
|
}`,
|
|
1084
1120
|
);
|
|
1121
|
+
if (mergedAbove.length > 0) {
|
|
1122
|
+
console.log(
|
|
1123
|
+
` ${pc.dim(`drop ${mergedAbove.join(", ")} (already merged into the branch below)`)}`,
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1085
1126
|
if (nextChild) {
|
|
1086
1127
|
console.log(
|
|
1087
1128
|
` ${pc.dim(`then re-root ${nextChild} onto ${trunk} — the rest stays a stack`)}`,
|
|
@@ -1151,7 +1192,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1151
1192
|
const overwrite = args.includes("--overwrite-remote");
|
|
1152
1193
|
const verified = await guardRemoteContent(
|
|
1153
1194
|
meta,
|
|
1154
|
-
ordered.slice(1).filter((b) => !collapsed.includes(b)),
|
|
1195
|
+
ordered.slice(1).filter((b) => !collapsed.includes(b) && !mergedAbove.includes(b)),
|
|
1155
1196
|
{ command: "merge --base", overwrite, dryRun: false },
|
|
1156
1197
|
);
|
|
1157
1198
|
|
|
@@ -1164,7 +1205,7 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1164
1205
|
for (let i = 0; i < plan.steps.length; i++) {
|
|
1165
1206
|
const step = plan.steps[i]!;
|
|
1166
1207
|
const mergedBranch = step.branch;
|
|
1167
|
-
const child =
|
|
1208
|
+
const child = nextChild;
|
|
1168
1209
|
const childPr = child ? (stack.branches[child]?.pr ?? null) : null;
|
|
1169
1210
|
|
|
1170
1211
|
console.log();
|
|
@@ -1232,10 +1273,13 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1232
1273
|
process.exit(2);
|
|
1233
1274
|
}
|
|
1234
1275
|
|
|
1276
|
+
// The child sits on the last branch of the merged run.
|
|
1277
|
+
const mergedParent = stack.branches[child]!.parent;
|
|
1235
1278
|
const res = await advanceNewBottom({
|
|
1236
1279
|
meta,
|
|
1237
1280
|
stackName,
|
|
1238
|
-
mergedBranch,
|
|
1281
|
+
mergedBranch: mergedParent,
|
|
1282
|
+
alsoMerged: [mergedBranch, ...mergedAbove].filter((b) => b !== mergedParent),
|
|
1239
1283
|
child,
|
|
1240
1284
|
childPr,
|
|
1241
1285
|
targetRef,
|
|
@@ -1267,8 +1311,10 @@ async function mergeBaseOnly(deleteFlag: boolean, dryRun: boolean, args: string[
|
|
|
1267
1311
|
process.exit(2);
|
|
1268
1312
|
}
|
|
1269
1313
|
} else {
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1314
|
+
// Nothing unmerged rides above it. Just drop the merged run.
|
|
1315
|
+
for (const branch of [mergedBranch, ...mergedAbove]) {
|
|
1316
|
+
await removeBranchFromStack(meta, stackName, branch);
|
|
1317
|
+
}
|
|
1272
1318
|
}
|
|
1273
1319
|
}
|
|
1274
1320
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -14,8 +14,16 @@ import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib
|
|
|
14
14
|
import { resolveFlag } from "../lib/config.ts";
|
|
15
15
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
16
16
|
import { guardRemoteContent } from "../lib/remote-guard.ts";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { getPrMergeState } from "../lib/github.ts";
|
|
18
|
+
import {
|
|
19
|
+
planMerges,
|
|
20
|
+
advanceNewBottom,
|
|
21
|
+
strandedMerges,
|
|
22
|
+
type BranchReview,
|
|
23
|
+
type MergePlan,
|
|
24
|
+
type MergedRunEntry,
|
|
25
|
+
type StrandedMerge,
|
|
26
|
+
} from "../lib/advance.ts";
|
|
19
27
|
import type { StackMetadata } from "../types.ts";
|
|
20
28
|
import restack, {
|
|
21
29
|
notePendingRun,
|
|
@@ -117,7 +125,7 @@ adds on GitHub). Pull or cherry-pick them first; see \`gh-stack restack --help\`
|
|
|
117
125
|
// Bottoms whose PRs already merged are skipped: the advance drops them
|
|
118
126
|
// rather than rewriting them, and after `merge --approved` they're exactly
|
|
119
127
|
// where the collapse's squash commits live.
|
|
120
|
-
const mergedBranches = new Set(merged.steps.map((step) => step.branch));
|
|
128
|
+
const mergedBranches = new Set(merged.plan.steps.map((step) => step.branch));
|
|
121
129
|
const verified = await guardRemoteContent(
|
|
122
130
|
meta,
|
|
123
131
|
ordered.filter((b) => !mergedBranches.has(b)),
|
|
@@ -251,60 +259,83 @@ interface AdvanceOutcome {
|
|
|
251
259
|
drained: boolean;
|
|
252
260
|
}
|
|
253
261
|
|
|
254
|
-
/**
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
* Returns what happened so the caller can re-derive the root or short-circuit.
|
|
261
|
-
* In dry-run it reports and makes no changes.
|
|
262
|
-
*/
|
|
262
|
+
/** The contiguous merged bottom branches, and how each one's PR merged. */
|
|
263
|
+
interface MergedBottoms {
|
|
264
|
+
plan: MergePlan;
|
|
265
|
+
run: MergedRunEntry[];
|
|
266
|
+
}
|
|
267
|
+
|
|
263
268
|
/**
|
|
264
269
|
* The contiguous bottom branches whose PRs already merged (outside gh-stack,
|
|
265
|
-
* or the base of a `merge --approved`
|
|
266
|
-
* first that isn't.
|
|
270
|
+
* or the base of a `merge --approved` and the PRs collapsed into it), walking
|
|
271
|
+
* bottom-up and stopping at the first that isn't.
|
|
267
272
|
*/
|
|
268
|
-
async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<
|
|
273
|
+
async function findMergedBottoms(meta: StackMetadata, stackName: string): Promise<MergedBottoms> {
|
|
269
274
|
const stack = meta.stacks[stackName]!;
|
|
270
275
|
const reviews: BranchReview[] = [];
|
|
276
|
+
const run: MergedRunEntry[] = [];
|
|
271
277
|
for (const branch of getOrderedBranches(stack)) {
|
|
272
278
|
const pr = stack.branches[branch]?.pr ?? null;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const info = await getPrInfo(pr);
|
|
276
|
-
prState = info?.state ?? null;
|
|
277
|
-
}
|
|
279
|
+
const state = pr != null ? await getPrMergeState(pr) : null;
|
|
280
|
+
const prState = state?.state ?? null;
|
|
278
281
|
reviews.push({ branch, pr, prState, reviewDecision: null });
|
|
279
282
|
if (prState !== "MERGED") break;
|
|
283
|
+
run.push({
|
|
284
|
+
branch,
|
|
285
|
+
parent: stack.branches[branch]!.parent,
|
|
286
|
+
pr,
|
|
287
|
+
baseRefName: state?.baseRefName ?? null,
|
|
288
|
+
mergedAt: state?.mergedAt ?? null,
|
|
289
|
+
});
|
|
280
290
|
}
|
|
281
|
-
return planMerges(reviews, { approveAndMerge: false });
|
|
291
|
+
return { plan: planMerges(reviews, { approveAndMerge: false }), run };
|
|
282
292
|
}
|
|
283
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Advance the stack past bottom PR(s) that already merged: drop them all and
|
|
296
|
+
* re-root the first unmerged branch onto the trunk with `--onto`, from its
|
|
297
|
+
* merged parent's pre-merge tip, so only its own commits replay. The merged
|
|
298
|
+
* branches themselves are never re-rooted: their commits are already on the
|
|
299
|
+
* trunk, squashed, and replaying them conflicts (#54). Shares the re-root
|
|
300
|
+
* mechanics with `merge` (see ./lib/advance.ts).
|
|
301
|
+
*
|
|
302
|
+
* Returns what happened so the caller can re-derive the root or short-circuit.
|
|
303
|
+
* In dry-run it reports and makes no changes.
|
|
304
|
+
*/
|
|
284
305
|
async function advanceMergedBottoms(
|
|
285
306
|
meta: StackMetadata,
|
|
286
307
|
stackName: string,
|
|
287
308
|
trunk: string,
|
|
288
309
|
dryRun: boolean,
|
|
289
310
|
noPush: boolean,
|
|
290
|
-
|
|
311
|
+
merged: MergedBottoms,
|
|
291
312
|
verified: Map<string, string>,
|
|
292
313
|
): Promise<AdvanceOutcome> {
|
|
293
314
|
const stack = meta.stacks[stackName]!;
|
|
294
315
|
const ordered = getOrderedBranches(stack);
|
|
316
|
+
const { plan, run } = merged;
|
|
295
317
|
|
|
296
318
|
if (plan.steps.length === 0) {
|
|
297
319
|
return { detected: false, advanced: false, drained: false };
|
|
298
320
|
}
|
|
299
321
|
|
|
300
|
-
|
|
322
|
+
const mergedBranches = plan.steps.map((step) => step.branch);
|
|
323
|
+
// The first unmerged branch. Ordered parents-first, so its parent is in the run.
|
|
324
|
+
const survivor = ordered[mergedBranches.length] ?? null;
|
|
325
|
+
|
|
326
|
+
p.log.warn(`${plan.steps.length} bottom PR(s) already merged:`);
|
|
301
327
|
for (const step of plan.steps) {
|
|
302
328
|
console.log(` ${pc.dim("✓")} ${pc.yellow(step.branch)}${step.pr ? ` (#${step.pr})` : ""}`);
|
|
303
329
|
}
|
|
304
330
|
console.log(
|
|
305
|
-
` ${pc.dim(
|
|
331
|
+
` ${pc.dim(
|
|
332
|
+
survivor
|
|
333
|
+
? `Dropping them and re-rooting ${survivor} onto ${trunk}, so only its own commits replay.`
|
|
334
|
+
: `Dropping them: nothing is left above them.`,
|
|
335
|
+
)}`,
|
|
306
336
|
);
|
|
307
337
|
console.log();
|
|
338
|
+
warnStranded(strandedMerges(run, trunk), trunk);
|
|
308
339
|
|
|
309
340
|
if (dryRun) {
|
|
310
341
|
p.log.warn("[DRY RUN] Would advance the stack past the merged bottom PR(s)");
|
|
@@ -316,45 +347,41 @@ async function advanceMergedBottoms(
|
|
|
316
347
|
await takeSnapshot(meta, stackName, "sync-advance");
|
|
317
348
|
const originalStack = structuredClone(stack);
|
|
318
349
|
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
console.log();
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
console.log(
|
|
350
|
-
` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <merged-tip> ${child}`)}`,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
process.exit(2);
|
|
350
|
+
if (survivor) {
|
|
351
|
+
const mergedParent = stack.branches[survivor]!.parent;
|
|
352
|
+
const res = await advanceNewBottom({
|
|
353
|
+
meta,
|
|
354
|
+
stackName,
|
|
355
|
+
mergedBranch: mergedParent,
|
|
356
|
+
alsoMerged: mergedBranches.filter((b) => b !== mergedParent),
|
|
357
|
+
child: survivor,
|
|
358
|
+
childPr: stack.branches[survivor]?.pr ?? null,
|
|
359
|
+
targetRef,
|
|
360
|
+
recordedBase: trunk,
|
|
361
|
+
push: !noPush,
|
|
362
|
+
expect: verified.get(survivor),
|
|
363
|
+
});
|
|
364
|
+
if (!res.ok) {
|
|
365
|
+
console.log();
|
|
366
|
+
if (res.reason === "conflict") {
|
|
367
|
+
p.log.error(`Rebase conflict re-rooting ${pc.yellow(survivor)} onto ${trunk}.`);
|
|
368
|
+
console.log(` Resolve it, then finish the sync:`);
|
|
369
|
+
console.log(` ${pc.green("git rebase --continue")}`);
|
|
370
|
+
console.log(
|
|
371
|
+
` ${pc.green("gh-stack sync")} ${pc.dim(`(picks up the re-rooted ${survivor}; not restack, which would put it back on ${mergedParent})`)}`,
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
p.log.error(
|
|
375
|
+
`Could not determine a safe rebase base for ${pc.yellow(survivor)} (${res.reason}).`,
|
|
376
|
+
);
|
|
377
|
+
console.log(
|
|
378
|
+
` Re-root manually:\n ${pc.green(`git rebase --onto ${targetRef} <${mergedParent}'s pre-merge tip> ${survivor}`)}`,
|
|
379
|
+
);
|
|
354
380
|
}
|
|
355
|
-
|
|
356
|
-
await removeBranchFromStack(meta, stackName, mergedBranch);
|
|
381
|
+
process.exit(2);
|
|
357
382
|
}
|
|
383
|
+
} else {
|
|
384
|
+
for (const branch of mergedBranches) await removeBranchFromStack(meta, stackName, branch);
|
|
358
385
|
}
|
|
359
386
|
|
|
360
387
|
// Whole stack drained? Archive the original topology and land on the trunk.
|
|
@@ -376,3 +403,25 @@ async function advanceMergedBottoms(
|
|
|
376
403
|
|
|
377
404
|
return { detected: true, advanced: true, drained: false };
|
|
378
405
|
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Say which merged PRs' changes never reached the trunk. The advance drops
|
|
409
|
+
* their branches either way (re-rooting one would replay its commits onto a
|
|
410
|
+
* trunk they were never squashed into, under the wrong PR), so the user has
|
|
411
|
+
* to land those changes again themselves.
|
|
412
|
+
*/
|
|
413
|
+
function warnStranded(stranded: StrandedMerge[], trunk: string): void {
|
|
414
|
+
if (stranded.length === 0) return;
|
|
415
|
+
for (const s of stranded) {
|
|
416
|
+
const pr = s.pr != null ? `PR #${s.pr}` : pc.yellow(s.branch);
|
|
417
|
+
p.log.warn(
|
|
418
|
+
s.landedPr != null
|
|
419
|
+
? `${pr} merged into ${s.into} after PR #${s.landedPr} had landed, so its changes never reached ${trunk}.`
|
|
420
|
+
: `${pr} merged into ${s.into}, which isn't ${trunk} or a branch below it, so its changes may not be on ${trunk}.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
console.log(
|
|
424
|
+
` ${pc.dim(`Sync drops them from the stack anyway. To ship those changes, open a new PR for them against ${trunk}.`)}`,
|
|
425
|
+
);
|
|
426
|
+
console.log();
|
|
427
|
+
}
|
package/src/lib/advance.ts
CHANGED
|
@@ -94,6 +94,53 @@ export function planMerges(ordered: BranchReview[], opts: { approveAndMerge: boo
|
|
|
94
94
|
return { steps, stop: null };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** One branch of a contiguous merged run, with how its PR merged. */
|
|
98
|
+
export interface MergedRunEntry {
|
|
99
|
+
branch: string;
|
|
100
|
+
/** Its stack parent. */
|
|
101
|
+
parent: string;
|
|
102
|
+
pr: number | null;
|
|
103
|
+
/** The branch its PR merged into. */
|
|
104
|
+
baseRefName: string | null;
|
|
105
|
+
mergedAt: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A merged PR whose changes never reached the trunk. */
|
|
109
|
+
export interface StrandedMerge {
|
|
110
|
+
branch: string;
|
|
111
|
+
pr: number | null;
|
|
112
|
+
into: string;
|
|
113
|
+
/** The PR of the branch it merged into, which had already landed; null when that branch isn't in the run. */
|
|
114
|
+
landedPr: number | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The merged PRs in `run` (bottom-up) whose changes never reached `trunk`.
|
|
119
|
+
* Advancing past a merged run drops its branches without replaying them, which
|
|
120
|
+
* is only right if each one's changes are on the trunk: merged into the trunk,
|
|
121
|
+
* or into a branch of the run before that branch's own PR merged (a collapse).
|
|
122
|
+
* One merged into its parent after the parent landed, or into a branch outside
|
|
123
|
+
* the run, is stranded. Missing facts never claim stranded.
|
|
124
|
+
*/
|
|
125
|
+
export function strandedMerges(run: MergedRunEntry[], trunk: string): StrandedMerge[] {
|
|
126
|
+
const byBranch = new Map(run.map((entry) => [entry.branch, entry]));
|
|
127
|
+
const stranded: StrandedMerge[] = [];
|
|
128
|
+
for (const entry of run) {
|
|
129
|
+
const into = entry.baseRefName;
|
|
130
|
+
if (!into || into === trunk) continue;
|
|
131
|
+
const container = byBranch.get(into);
|
|
132
|
+
if (!container) {
|
|
133
|
+
stranded.push({ branch: entry.branch, pr: entry.pr, into, landedPr: null });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!entry.mergedAt || !container.mergedAt) continue;
|
|
137
|
+
if (Date.parse(entry.mergedAt) > Date.parse(container.mergedAt)) {
|
|
138
|
+
stranded.push({ branch: entry.branch, pr: entry.pr, into, landedPr: container.pr });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return stranded;
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
function reviewReason(decision: string | null): string {
|
|
98
145
|
switch (decision) {
|
|
99
146
|
case "CHANGES_REQUESTED":
|
|
@@ -142,9 +189,10 @@ export type AdvanceResult =
|
|
|
142
189
|
|
|
143
190
|
/**
|
|
144
191
|
* Re-root `child` (the branch that becomes the new bottom) onto `targetRef`,
|
|
145
|
-
* dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch`
|
|
146
|
-
* the stack metadata (re-parenting `child` and any siblings
|
|
147
|
-
* push the rebased child, and repoint its PR base to
|
|
192
|
+
* dropping `mergedBranch`'s now-squashed commits, then drop `mergedBranch` (and
|
|
193
|
+
* `alsoMerged`) from the stack metadata (re-parenting `child` and any siblings
|
|
194
|
+
* onto the trunk), push the rebased child, and repoint its PR base to
|
|
195
|
+
* `recordedBase`.
|
|
148
196
|
*
|
|
149
197
|
* The caller MUST have taken a snapshot first — on a rebase conflict this leaves
|
|
150
198
|
* the in-progress `git rebase` for the user to resolve and returns `conflict`.
|
|
@@ -155,7 +203,13 @@ export type AdvanceResult =
|
|
|
155
203
|
export async function advanceNewBottom(opts: {
|
|
156
204
|
meta: StackMetadata;
|
|
157
205
|
stackName: string;
|
|
206
|
+
/** The merged branch `child` sits on: its pre-merge tip is the fork point. */
|
|
158
207
|
mergedBranch: string;
|
|
208
|
+
/**
|
|
209
|
+
* The rest of a merged run below `mergedBranch` (#54). Their commits are in
|
|
210
|
+
* the trunk too, so they're dropped without being re-rooted themselves.
|
|
211
|
+
*/
|
|
212
|
+
alsoMerged?: string[];
|
|
159
213
|
child: string;
|
|
160
214
|
childPr: number | null;
|
|
161
215
|
targetRef: string;
|
|
@@ -227,10 +281,12 @@ export async function advanceNewBottom(opts: {
|
|
|
227
281
|
);
|
|
228
282
|
}
|
|
229
283
|
|
|
230
|
-
// Metadata last: re-parents child (and siblings)
|
|
231
|
-
//
|
|
284
|
+
// Metadata last: drop the merged run, which re-parents child (and siblings)
|
|
285
|
+
// onto the trunk, and write. Done after the git work succeeds so a failure
|
|
232
286
|
// never leaves metadata describing a state git didn't reach.
|
|
233
|
-
|
|
287
|
+
for (const merged of [...(opts.alsoMerged ?? []), mergedBranch]) {
|
|
288
|
+
await removeBranchFromStack(meta, stackName, merged);
|
|
289
|
+
}
|
|
234
290
|
|
|
235
291
|
// Record where the child's PR really points. Until it's retargeted it still
|
|
236
292
|
// targets the merged branch, and the next successful push of the child
|
package/src/lib/github.ts
CHANGED
|
@@ -215,16 +215,18 @@ export interface PrMergeResult {
|
|
|
215
215
|
baseRefName: string;
|
|
216
216
|
/** The commit merging it created on its base (for a squash merge, the squash commit). */
|
|
217
217
|
mergeCommit: string | null;
|
|
218
|
+
/** When it merged (ISO 8601); null if it hasn't. */
|
|
219
|
+
mergedAt: string | null;
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
export async function getPrMergeState(prNumber: number): Promise<PrMergeResult | null> {
|
|
221
223
|
try {
|
|
222
224
|
const data = JSON.parse(
|
|
223
225
|
(
|
|
224
|
-
await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit`.text()
|
|
226
|
+
await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit,mergedAt`.text()
|
|
225
227
|
).trim(),
|
|
226
228
|
);
|
|
227
|
-
return { ...data, mergeCommit: data.mergeCommit?.oid ?? null };
|
|
229
|
+
return { ...data, mergeCommit: data.mergeCommit?.oid ?? null, mergedAt: data.mergedAt || null };
|
|
228
230
|
} catch {
|
|
229
231
|
return null;
|
|
230
232
|
}
|
|
@@ -303,9 +303,11 @@ often the squash commit a `merge --collapse` added on GitHub. Run the
|
|
|
303
303
|
pass `--overwrite-remote` unless you were asked to drop those commits: it
|
|
304
304
|
deletes them from the PR.
|
|
305
305
|
|
|
306
|
-
**`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI,
|
|
307
|
-
|
|
308
|
-
|
|
306
|
+
**`sync` is self-correcting:** if you merged the bottom PR in the GitHub UI, or
|
|
307
|
+
the base of a `merge --approved` landed with the PRs collapsed into it, `sync`
|
|
308
|
+
detects it, drops every merged branch, and re-roots only the first unmerged one
|
|
309
|
+
onto trunk instead of replaying squashed commits and conflicting. It rebases
|
|
310
|
+
onto `origin/main` (fetched itself), so a stale local `main` doesn't matter.
|
|
309
311
|
|
|
310
312
|
### 5. Status
|
|
311
313
|
|
|
@@ -332,7 +334,9 @@ Merge is **top-down everywhere** (collapse PRn → PRn-1 → … → base PR, th
|
|
|
332
334
|
one commit on trunk. Approval is read from the base up — the first un-approved
|
|
333
335
|
PR caps the run (unapproved PR4 stops at PR3 even if PR5 is approved). The
|
|
334
336
|
unapproved tail is left in place; after the base lands, run `gh-stack sync` to
|
|
335
|
-
re-root the tail onto trunk (it does not block on CI).
|
|
337
|
+
re-root the tail onto trunk (it does not block on CI). If the repo doesn't
|
|
338
|
+
allow auto-merge, merge the base PR yourself once its checks pass
|
|
339
|
+
(`gh pr merge <N> --squash`), then `gh-stack sync`.
|
|
336
340
|
- **`merge --base`**: squash-merge **only the bottom PR** into trunk and re-root
|
|
337
341
|
the next branch onto trunk — one PR per run, re-run for the next. Use this to
|
|
338
342
|
land reviewed PRs one at a time. Auto-advances past a bottom PR already merged
|