diff --git a/AGENTS.md b/AGENTS.md index cdc60dd2529..7a7c822f26a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,8 @@ branches. report conflicts and a huge unrelated diff. Always base and retarget feature PRs on `fork/changes`. - Before handoff (and whenever a PR is CONFLICTING / behind), run `pnpm fork:stack update --push` (or `pnpm fork:stack update --push `). That rebases or - replays the feature commits onto latest `origin/fork/changes`, retargets a wrong PR base, and + replays the feature commits onto the PR's intended parent (`fork/changes` for ordinary features, + or the current parent branch for dependent/overlay-child PRs), retargets only an invalid base, and force-with-lease pushes so the PR stays mergeable. - After automation rebases your branch (or `fork/changes`), refresh a local checkout with `pnpm fork:stack pull`. It hard-resets to remote when local commits are patch-equivalent, and only @@ -62,8 +63,9 @@ branches. disabled at repository level so mirror updates do not run redundant CI or attempt upstream relay deployment. Do not re-enable or target those workflows for fork releases. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases - the provenance layers, rebuilds `fork/integration`, force-with-lease rebases open feature PRs that - target `fork/changes`, and dispatches CI for the exact integration SHA. + the provenance layers, rebuilds `fork/integration`, and parent-first force-with-lease rebases the + complete same-repository PR tree rooted at `fork/changes` (including overlay children and deeper + dependent PRs), then dispatches CI for the exact integration SHA. - Successful `fork/integration` CI classifies the complete tree diff from the previous approved integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. @@ -84,8 +86,9 @@ When implementation work for a user request is done (code, docs, config — not the first formatter, linter, or typechecker. - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` - - `baseRefName` must be `fork/changes` and `mergeable` should be `MERGEABLE` (CI may still be - `UNSTABLE` while checks run). + - `baseRefName` must be `fork/changes` for ordinary features or the intended parent branch for a + dependent/overlay-child PR. `mergeable` should be `MERGEABLE` (CI may still be `UNSTABLE` + while checks run). 4. **Before pushing follow-ups**, verify PR state with `gh pr view` (or equivalent): - If the PR is **open** → update that branch (prefer `fork:stack update --push`) and push. - If the PR is **merged** or **closed** → do **not** keep committing on that branch. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 236cc8e6927..e98da1a12ba 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -41,6 +41,8 @@ pnpm fork:stack overlay-promote 10 upstream/desktop-deep-links To change an overlay, commit directly to its branch or create a child PR with the overlay branch as its base and merge the child into the overlay PR. Do not put the same change into `fork/changes`. +When `fork/changes` rewrites, stack automation rebases the overlay first and then recursively rebases +its child and descendant PRs onto their rewritten parents. A conflict blocks only that PR's subtree. Landing an overlay is deliberate: remove its manifest entry in the same reviewed change that lands the implementation in `fork/changes`, then verify that the resulting `fork/integration` tree is unchanged. @@ -112,8 +114,8 @@ editing central metadata. ### Keeping feature PRs up to date -Feature branches drift when `fork/changes` moves (upstream mirror sync or merged siblings). Agents -must leave PRs mergeable at handoff: +Feature branches drift when their parent moves (`fork/changes` for ordinary features, or another +feature/overlay branch for dependent PRs). Agents must leave PRs mergeable at handoff: ```sh # Current branch + its open PR @@ -128,15 +130,12 @@ pnpm fork:stack update `update` will: -1. fetch latest `origin/fork/changes` and the durable base-history ref - (`refs/t3/stack/base-history/fork-changes`); +1. resolve and fetch the PR's intended parent branch; 2. **rebase** when the branch already descends from the new tip but is behind; -3. when history diverged (normal after a stack rewrite): recover the **old base tip** this PR was - built on — the newest recorded historical `fork/changes` tip that is still an ancestor of - HEAD — then `git rebase --onto newBase oldBase`. Feature commits are exactly `oldBase..HEAD` - (the commits that were on top of the old base), not a file-count guess and not the full GitHub - PR commit list; -4. **retarget** the PR base to `fork/changes` if it still points at `main` or another wrong branch; +3. when history diverged (normal after a stack rewrite), recover the **old parent tip** this PR was + built on—from the durable `fork/changes` history or the parent PR's force-push history—then run + `git rebase --onto newParent oldParent`. The replay contains only this PR's commits; +4. preserve intentional dependent/overlay-child bases and retarget only invalid bases; 5. **force-with-lease push** when `--push` is set; 6. print `gh pr view` mergeability JSON. diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index 5f311057303..0515c4f9894 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -15,6 +15,7 @@ import { planLocalSyncWithRemote, registerPullRequest, registerIntegrationOverlay, + resolveFeaturePullRequestBaseBranch, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, @@ -43,6 +44,27 @@ describe("fork stack helpers", () => { expect(shouldRetargetPullRequestBase("fork/changes", "fork/changes")).toBe(false); }); + it("preserves an intentional overlay parent for dependent PR updates", () => { + const withOverlay: StackManifest = { + ...manifest, + integrationOverlays: [{ number: 80, branch: "fork/discord" }], + }; + expect( + resolveFeaturePullRequestBaseBranch({ + manifest: withOverlay, + currentBase: "fork/discord", + baseHasOpenPullRequest: true, + }), + ).toBe("fork/discord"); + expect( + resolveFeaturePullRequestBaseBranch({ + manifest: withOverlay, + currentBase: "main", + baseHasOpenPullRequest: false, + }), + ).toBe("fork/changes"); + }); + it("plans a simple rebase when behind an ancestor base", () => { expect( planFeatureBranchUpdate({ diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index 5d5413f818e..c328068f48d 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -108,6 +108,23 @@ export function featurePullRequestBaseBranch(manifest: StackManifest): string { return manifest.forkChangesBranch; } +export function resolveFeaturePullRequestBaseBranch(input: { + readonly manifest: StackManifest; + readonly currentBase: string | null | undefined; + readonly baseHasOpenPullRequest: boolean; +}): string { + const currentBase = input.currentBase?.trim(); + if ( + currentBase && + (currentBase === input.manifest.forkChangesBranch || + input.manifest.integrationOverlays.some(({ branch }) => branch === currentBase) || + input.baseHasOpenPullRequest) + ) { + return currentBase; + } + return featurePullRequestBaseBranch(input.manifest); +} + export function shouldRetargetPullRequestBase( currentBase: string | null | undefined, expectedBase: string, @@ -340,6 +357,31 @@ function fetchBaseHistory(sourceRoot: string): ReadonlyArray { return parseBaseHistory(stripAnsi(blob.stdout)); } +function fetchPullRequestHeadHistory( + sourceRoot: string, + pullRequestNumber: number, +): ReadonlyArray { + const output = run( + "gh", + [ + "api", + "--paginate", + `repos/${FORK_REPOSITORY}/issues/${pullRequestNumber}/events`, + "--jq", + '.[] | select(.event == "head_ref_force_pushed") | .commit_id', + ], + sourceRoot, + ); + return appendBaseHistory( + [], + output + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .reverse(), + ); +} + /** * After a remote force-push rebase, decide how to update the local checkout. * @@ -397,8 +439,6 @@ function updateFeatureBranch( }, ): void { ensureClean(sourceRoot); - const expectedBase = featurePullRequestBaseBranch(manifest); - run("git", ["fetch", "origin", expectedBase], sourceRoot); let branch = currentBranchName(sourceRoot); let prNumber: number | null = options.pullRequestNumber ?? null; @@ -431,6 +471,14 @@ function updateFeatureBranch( } } + const basePullRequest = + prBaseRefName === null ? null : resolveOpenPullRequestForBranch(sourceRoot, prBaseRefName); + const expectedBase = resolveFeaturePullRequestBaseBranch({ + manifest, + currentBase: prBaseRefName, + baseHasOpenPullRequest: basePullRequest !== null, + }); + run("git", ["fetch", "origin", expectedBase], sourceRoot); const baseRef = `origin/${expectedBase}`; const newBaseOid = run("git", ["rev-parse", baseRef], sourceRoot); const newBaseIsAncestorOfHead = @@ -438,8 +486,15 @@ function updateFeatureBranch( 0; const behindCount = Number(run("git", ["rev-list", "--count", `HEAD..${baseRef}`], sourceRoot)); - // Historical fork/changes tips (newest first), plus the current tip as a candidate. - const history = fetchBaseHistory(sourceRoot); + // Historical tips of this PR's direct parent (newest first), plus the + // current parent tip. Overlay children recover from the parent PR's + // force-push timeline; ordinary features use the durable fork/changes ref. + const history = + expectedBase === manifest.forkChangesBranch + ? fetchBaseHistory(sourceRoot) + : basePullRequest === null + ? [] + : fetchPullRequestHeadHistory(sourceRoot, basePullRequest.number); const historicalTips = appendBaseHistory(history, [newBaseOid]); const recoveredOldBaseOid = recoverOldBaseTip({ historicalBaseTipsNewestFirst: historicalTips, diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts index 119f3ff0d1b..2ba1d2e79ea 100644 --- a/scripts/rebase-pr-stack.test.ts +++ b/scripts/rebase-pr-stack.test.ts @@ -12,6 +12,7 @@ import { RebaseConflictError, resumeStack, selectOpenFeaturePullRequests, + selectOpenFeaturePullRequestTree, StackError, syncStack, type PullRequestSnapshot, @@ -108,6 +109,54 @@ describe("selectOpenFeaturePullRequests", () => { ["overlay/desktop"], ); }); + + it("orders overlay children and grandchildren after their rewritten parent", () => { + const selected = selectOpenFeaturePullRequestTree({ + expectedRepository: "patroza/t3code", + manifest, + openPulls: [ + { + number: 98, + headBranch: "fix/discord-edit", + baseBranch: "overlay/discord", + headRepository: "patroza/t3code", + }, + { + number: 108, + headBranch: "fix/discord-edit-tests", + baseBranch: "fix/discord-edit", + headRepository: "patroza/t3code", + }, + { + number: 80, + headBranch: "overlay/discord", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + ], + }); + + assert.deepEqual(selected, [ + { + number: 80, + branch: "overlay/discord", + baseBranch: "fork/changes", + depth: 0, + }, + { + number: 98, + branch: "fix/discord-edit", + baseBranch: "overlay/discord", + depth: 1, + }, + { + number: 108, + branch: "fix/discord-edit-tests", + baseBranch: "fix/discord-edit", + depth: 2, + }, + ]); + }); }); interface Fixture { @@ -772,4 +821,123 @@ done isAncestor(fixture.origin, fixture.newForkTip, remoteTip(fixture.origin, "feature/flaky")), ); }); + + it("cascades an overlay rewrite through child and grandchild PRs", async () => { + const fixture = createFeatureRebaseFixture(); + NodeFS.unlinkSync(NodePath.join(fixture.origin, "hooks", "pre-receive")); + + runGit(fixture.work, [ + "checkout", + "--quiet", + "-b", + "feature/overlay-child", + "overlay/critical", + ]); + commitFile(fixture.work, "child.txt", "child\n", "overlay child"); + runGit(fixture.work, ["push", "--quiet", "origin", "feature/overlay-child"]); + runGit(fixture.work, [ + "checkout", + "--quiet", + "-b", + "feature/overlay-grandchild", + "feature/overlay-child", + ]); + commitFile(fixture.work, "grandchild.txt", "grandchild\n", "overlay grandchild"); + runGit(fixture.work, ["push", "--quiet", "origin", "feature/overlay-grandchild"]); + + const result = await rebaseOpenFeaturePullRequests({ + sourceRoot: fixture.work, + manifest: fixture.manifest, + push: true, + oldForkChangesTip: fixture.oldForkTip, + newForkChangesTip: fixture.newForkTip, + openPulls: [ + { + number: 10, + headBranch: "overlay/critical", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 98, + headBranch: "feature/overlay-child", + baseBranch: "overlay/critical", + headRepository: "patroza/t3code", + }, + { + number: 108, + headBranch: "feature/overlay-grandchild", + baseBranch: "feature/overlay-child", + headRepository: "patroza/t3code", + }, + ], + }); + + const overlayTip = remoteTip(fixture.origin, "overlay/critical"); + const childTip = remoteTip(fixture.origin, "feature/overlay-child"); + const grandchildTip = remoteTip(fixture.origin, "feature/overlay-grandchild"); + assert.equal(result.conflicts.length, 0); + assert.ok(isAncestor(fixture.origin, fixture.newForkTip, overlayTip)); + assert.ok(isAncestor(fixture.origin, overlayTip, childTip)); + assert.ok(isAncestor(fixture.origin, childTip, grandchildTip)); + }); + + it("recovers a stale overlay child from recorded parent force-push history", async () => { + const fixture = createFeatureRebaseFixture(); + NodeFS.unlinkSync(NodePath.join(fixture.origin, "hooks", "pre-receive")); + const oldOverlayTip = remoteTip(fixture.origin, "overlay/critical"); + + runGit(fixture.work, [ + "checkout", + "--quiet", + "-b", + "feature/stale-overlay-child", + oldOverlayTip, + ]); + commitFile(fixture.work, "child.txt", "child\n", "stale overlay child"); + runGit(fixture.work, ["push", "--quiet", "origin", "feature/stale-overlay-child"]); + + // Simulate an earlier cascade that rewrote only the overlay and missed its child. + runGit(fixture.work, ["checkout", "--quiet", "overlay/critical"]); + runGit(fixture.work, [ + "-c", + "commit.gpgsign=false", + "rebase", + "--onto", + fixture.newForkTip, + fixture.oldForkTip, + ]); + runGit(fixture.work, ["push", "--quiet", "--force", "origin", "overlay/critical"]); + + const result = await rebaseOpenFeaturePullRequests({ + sourceRoot: fixture.work, + manifest: fixture.manifest, + push: true, + oldForkChangesTip: fixture.oldForkTip, + newForkChangesTip: fixture.newForkTip, + baseHistoryByBranch: { + "overlay/critical": [oldOverlayTip], + }, + openPulls: [ + { + number: 10, + headBranch: "overlay/critical", + baseBranch: "fork/changes", + headRepository: "patroza/t3code", + }, + { + number: 98, + headBranch: "feature/stale-overlay-child", + baseBranch: "overlay/critical", + headRepository: "patroza/t3code", + }, + ], + }); + + const overlayTip = remoteTip(fixture.origin, "overlay/critical"); + const childTip = remoteTip(fixture.origin, "feature/stale-overlay-child"); + assert.equal(result.conflicts.length, 0); + assert.ok(result.updated.some(({ branch }) => branch === "feature/stale-overlay-child")); + assert.ok(isAncestor(fixture.origin, overlayTip, childTip)); + }); }); diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 6475404cb38..294df9e55a4 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -499,6 +499,57 @@ export async function fetchPullRequestSnapshots( }); } +async function fetchPullRequestHeadHistory( + pullRequestNumber: number, +): Promise> { + const tips: Array = []; + for (let page = 1; ; page += 1) { + const value = await githubRequest( + `/repos/${EXPECTED_REPOSITORY}/issues/${pullRequestNumber}/events?per_page=100&page=${page}`, + ); + if (!Array.isArray(value)) { + throw new StackError(`GitHub returned invalid events for PR #${pullRequestNumber}.`); + } + for (const event of value) { + if ( + typeof event === "object" && + event !== null && + "event" in event && + event.event === "head_ref_force_pushed" && + "commit_id" in event && + typeof event.commit_id === "string" + ) { + tips.unshift(event.commit_id); + } + } + if (value.length < 100) break; + } + return appendBaseHistory([], tips); +} + +async function fetchBaseHistoryByBranch( + openPulls: ReadonlyArray<{ + readonly number: number; + readonly headBranch: string; + }>, + features: ReadonlyArray, +): Promise>>> { + const pullByBranch = new Map(openPulls.map((pull) => [pull.headBranch, pull])); + const baseBranches = new Set( + features.filter(({ depth }) => depth > 0).map(({ baseBranch }) => baseBranch), + ); + const entries = await Promise.all( + [...baseBranches].map(async (branch) => { + const pull = pullByBranch.get(branch); + return [ + branch, + pull === undefined ? [] : await fetchPullRequestHeadHistory(pull.number), + ] as const; + }), + ); + return Object.fromEntries(entries); +} + async function validatePullRequests( manifest: StackManifest, supplied?: ReadonlyArray, @@ -959,47 +1010,84 @@ export function selectOpenFeaturePullRequests(input: { readonly manifest: StackManifest; readonly expectedRepository: string; }): ReadonlyArray<{ readonly number: number; readonly branch: string }> { + return selectOpenFeaturePullRequestTree(input).map(({ number, branch }) => ({ + number, + branch, + })); +} + +export interface OpenFeaturePullRequestTreeNode { + readonly number: number; + readonly branch: string; + readonly baseBranch: string; + readonly depth: number; +} + +/** + * Select the complete same-repository PR tree rooted at `fork/changes`. + * Parents always precede children so rewritten heads can cascade through + * overlay children and deeper dependent PRs. + */ +export function selectOpenFeaturePullRequestTree(input: { + readonly openPulls: ReadonlyArray<{ + readonly number: number; + readonly headBranch: string; + readonly baseBranch: string; + readonly headRepository?: string | null; + readonly draft?: boolean; + }>; + readonly manifest: StackManifest; + readonly expectedRepository: string; +}): ReadonlyArray { const stackBranches = new Set([ input.manifest.upstreamBranch, input.manifest.integrationBranch, ...input.manifest.pullRequests.map(({ branch }) => branch), ]); const overlayBranches = new Set(input.manifest.integrationOverlays.map(({ branch }) => branch)); - const selected = input.openPulls - .filter((pull) => { - if (pull.baseBranch !== input.manifest.forkChangesBranch) return false; - if (stackBranches.has(pull.headBranch)) return false; - if ( - pull.headRepository !== undefined && - pull.headRepository !== null && - pull.headRepository !== input.expectedRepository - ) { - return false; - } - return true; - }) - .map((pull) => ({ number: pull.number, branch: pull.headBranch })); - - const overlays: Array<{ readonly number: number; readonly branch: string }> = []; - const features: Array<{ readonly number: number; readonly branch: string }> = []; - for (const entry of selected) { - if (overlayBranches.has(entry.branch)) { - overlays.push(entry); - } else { - features.push(entry); + const eligible = input.openPulls.filter((pull) => { + if (stackBranches.has(pull.headBranch)) return false; + if ( + pull.headRepository !== undefined && + pull.headRepository !== null && + pull.headRepository !== input.expectedRepository + ) { + return false; } + return true; + }); + const byBase = new Map>(); + for (const pull of eligible) { + const children = byBase.get(pull.baseBranch) ?? []; + children.push(pull); + byBase.set(pull.baseBranch, children); } + const roots = byBase.get(input.manifest.forkChangesBranch) ?? []; + const overlays = roots.filter((entry) => overlayBranches.has(entry.headBranch)); + const features = roots.filter((entry) => !overlayBranches.has(entry.headBranch)); // Preserve manifest overlay order for deterministic composition inputs. overlays.sort((left, right) => { const leftIndex = input.manifest.integrationOverlays.findIndex( - (overlay) => overlay.branch === left.branch, + (overlay) => overlay.branch === left.headBranch, ); const rightIndex = input.manifest.integrationOverlays.findIndex( - (overlay) => overlay.branch === right.branch, + (overlay) => overlay.branch === right.headBranch, ); return leftIndex - rightIndex; }); - return [...overlays, ...features]; + const selected: Array = []; + const visit = (pull: (typeof eligible)[number], depth: number): void => { + selected.push({ + number: pull.number, + branch: pull.headBranch, + baseBranch: pull.baseBranch, + depth, + }); + const children = byBase.get(pull.headBranch) ?? []; + for (const child of children) visit(child, depth + 1); + }; + for (const root of [...overlays, ...features]) visit(root, 0); + return selected; } export interface FeaturePullRequestRebaseResult { @@ -1037,13 +1125,10 @@ export async function rebaseOpenFeaturePullRequests(options: { readonly baseBranch: string; readonly headRepository?: string | null; }>; + readonly baseHistoryByBranch?: Readonly>>; }): Promise { const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); const manifest = options.manifest ?? readManifest(sourceRoot); - if (options.oldForkChangesTip === options.newForkChangesTip) { - return { updated: [], conflicts: [], skipped: [] }; - } - const openPulls = options.openPulls ?? (await fetchPullRequestSnapshots(manifest)).map((snapshot) => ({ @@ -1055,11 +1140,14 @@ export async function rebaseOpenFeaturePullRequests(options: { : `${snapshot.headOwner}/${EXPECTED_REPOSITORY.split("/")[1] ?? "t3code"}`, })); - const features = selectOpenFeaturePullRequests({ + const features = selectOpenFeaturePullRequestTree({ openPulls, manifest, expectedRepository: EXPECTED_REPOSITORY, }); + const baseHistoryByBranch = + options.baseHistoryByBranch ?? + (options.openPulls === undefined ? await fetchBaseHistoryByBranch(openPulls, features) : {}); const updated: Array<{ number: number; branch: string }> = []; const conflicts: Array<{ number: number; branch: string; message: string }> = []; @@ -1079,7 +1167,10 @@ export async function rebaseOpenFeaturePullRequests(options: { git(repoDir, ["config", "commit.gpgsign", "false"]); git(repoDir, ["remote", "add", "origin", originUrl]); - const branchesToFetch = [manifest.forkChangesBranch, ...features.map(({ branch }) => branch)]; + const branchesToFetch = [ + manifest.forkChangesBranch, + ...new Set(features.flatMap(({ branch, baseBranch }) => [baseBranch, branch])), + ]; git(repoDir, [ "fetch", "--quiet", @@ -1108,7 +1199,7 @@ export async function rebaseOpenFeaturePullRequests(options: { "rev-parse", `refs/remotes/origin/${manifest.forkChangesBranch}`, ]); - const newBase = + const forkChangesBase = fetchedForkTip === options.newForkChangesTip || run("git", ["cat-file", "-e", `${options.newForkChangesTip}^{commit}`], { cwd: repoDir, @@ -1117,8 +1208,26 @@ export async function rebaseOpenFeaturePullRequests(options: { ? fetchedForkTip : options.newForkChangesTip; + const initialRemoteTips = new Map( + branchesToFetch.map((branch) => [ + branch, + git(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], { allowFailure: true }), + ]), + ); + const rewrittenTips = new Map([[manifest.forkChangesBranch, forkChangesBase]]); + const blockedBranches = new Set(); + for (const feature of features) { try { + if (blockedBranches.has(feature.baseBranch)) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: `parent branch ${feature.baseBranch} was not rebased`, + }); + blockedBranches.add(feature.branch); + continue; + } const remoteTip = git(repoDir, ["rev-parse", `refs/remotes/origin/${feature.branch}`], { allowFailure: true, }); @@ -1128,9 +1237,21 @@ export async function rebaseOpenFeaturePullRequests(options: { branch: feature.branch, reason: "missing remote branch", }); + blockedBranches.add(feature.branch); continue; } + const newBase = + rewrittenTips.get(feature.baseBranch) ?? initialRemoteTips.get(feature.baseBranch) ?? ""; + if (!newBase) { + skipped.push({ + number: feature.number, + branch: feature.branch, + reason: `missing base branch ${feature.baseBranch}`, + }); + blockedBranches.add(feature.branch); + continue; + } const hasNewBase = run("git", ["merge-base", "--is-ancestor", newBase, remoteTip], { cwd: repoDir, allowFailure: true, @@ -1139,20 +1260,21 @@ export async function rebaseOpenFeaturePullRequests(options: { skipped.push({ number: feature.number, branch: feature.branch, - reason: "already based on new fork/changes", + reason: `already based on ${feature.baseBranch}`, }); + rewrittenTips.set(feature.branch, remoteTip); continue; } - // Recover the old fork/changes tip this PR was built on: newest known historical - // tip that is still an ancestor of the feature head. Feature commits are then - // exactly oldBase..head. Multi-generation recovery walks base-history so a - // PR that missed several fork/changes merges still replays only its own - // commits (cherry-equivalent of rebase --onto). - const historicalTips = appendBaseHistory(baseHistoryTips, [ - options.oldForkChangesTip, - newBase, - ]); + // Recover the old tip of this PR's direct parent. For roots this is a + // historical fork/changes tip. Descendants first try the parent's + // pre-cascade remote tip, then recorded force-push history. + const historicalTips = + feature.baseBranch === manifest.forkChangesBranch + ? appendBaseHistory(baseHistoryTips, [options.oldForkChangesTip, forkChangesBase]) + : appendBaseHistory(baseHistoryByBranch[feature.baseBranch] ?? [], [ + initialRemoteTips.get(feature.baseBranch) ?? "", + ]); const recoveredOldBase = recoverOldBaseTip({ historicalBaseTipsNewestFirst: historicalTips.filter( (tip) => tip.toLowerCase() !== newBase.toLowerCase(), @@ -1168,9 +1290,9 @@ export async function rebaseOpenFeaturePullRequests(options: { skipped.push({ number: feature.number, branch: feature.branch, - reason: - "cannot recover old fork/changes tip (no known historical base tip is an ancestor of this head)", + reason: `cannot recover old ${feature.baseBranch} tip (no known historical base tip is an ancestor of this head)`, }); + blockedBranches.add(feature.branch); continue; } @@ -1198,6 +1320,7 @@ export async function rebaseOpenFeaturePullRequests(options: { ? `conflict rebasing onto new base from ${recoveredOldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"), }); + blockedBranches.add(feature.branch); continue; } @@ -1208,6 +1331,7 @@ export async function rebaseOpenFeaturePullRequests(options: { branch: feature.branch, reason: "rebase produced identical tip", }); + rewrittenTips.set(feature.branch, remoteTip); continue; } @@ -1247,8 +1371,9 @@ export async function rebaseOpenFeaturePullRequests(options: { skipped.push({ number: feature.number, branch: feature.branch, - reason: "remote already based on new fork/changes after concurrent update", + reason: `remote already based on ${feature.baseBranch} after concurrent update`, }); + rewrittenTips.set(feature.branch, latestRemote); continue; } conflicts.push({ @@ -1258,10 +1383,12 @@ export async function rebaseOpenFeaturePullRequests(options: { pushResult.stderr || pushResult.stdout || "force-with-lease rejected", )}`, }); + blockedBranches.add(feature.branch); continue; } } updated.push({ number: feature.number, branch: feature.branch }); + rewrittenTips.set(feature.branch, newTip); } catch (error) { if (rebaseInProgress(repoDir)) { run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); @@ -1271,6 +1398,7 @@ export async function rebaseOpenFeaturePullRequests(options: { branch: feature.branch, message: error instanceof Error ? error.message : String(error), }); + blockedBranches.add(feature.branch); } }