diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 55badfa4178..8e579b4714d 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -76,18 +76,20 @@ pnpm fork:stack update `update` will: -1. fetch latest `origin/fork/changes`; -2. **rebase** when the branch already descends from that tip but is behind; -3. when history diverged (normal after a stack rewrite of `fork/changes`): transplant only commits - that `git cherry` marks **unique by patch-id**, oldest-first — not the full GitHub PR commit - list. Large conflicting commits (>30 files) are treated as rewritten-layer noise and skipped; - small conflicts still fail loudly; +1. fetch latest `origin/fork/changes` and the durable base-history ref + (`refs/t3/stack/base-history/fork-changes`); +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; 5. **force-with-lease push** when `--push` is set; 6. print `gh pr view` mergeability JSON. -The stack cascade uses the same strategy: prefer `rebase --onto` when the previous -`fork/changes` tip is still an ancestor, otherwise fall back to patch-id unique cherry-picks. +The stack cascade records each `fork/changes` tip into that base-history ref before rebasing open +feature PRs the same way (`rebase --onto` from the recovered old base). Do not use GitHub “Update branch” merge commits for these feature PRs; prefer this rebase/replay path so history stays linear and reviewable. diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts index 268e42fea9e..c355651b138 100644 --- a/scripts/fork-stack.test.ts +++ b/scripts/fork-stack.test.ts @@ -1,19 +1,24 @@ import { describe, expect, it } from "vite-plus/test"; -import { parseManifest, StackError, type StackManifest } from "./rebase-pr-stack.ts"; +import { + appendBaseHistory, + parseBaseHistory, + parseManifest, + recoverOldBaseTip, + selectOpenFeaturePullRequests, + StackError, + type StackManifest, +} from "./rebase-pr-stack.ts"; import { featurePullRequestBaseBranch, - orderUniqueCommitsOldestFirst, planFeatureBranchUpdate, planLocalSyncWithRemote, registerPullRequest, - shouldAutoSkipConflictingTransplant, shouldRetargetPullRequestBase, stackParentBranch, uniqueLocalCommitsFromCherry, unregisterTopPullRequest, } from "./fork-stack.ts"; -import { selectOpenFeaturePullRequests } from "./rebase-pr-stack.ts"; const manifest: StackManifest = { upstreamRemote: "upstream", @@ -38,54 +43,69 @@ describe("fork stack helpers", () => { it("plans a simple rebase when behind an ancestor base", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: true, + newBaseIsAncestorOfHead: true, behindCount: 3, - aheadCount: 1, - uniquePatchOidsOldestFirst: ["abc"], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "rebase", replayOids: [] }); + ).toEqual({ action: "rebase", oldBaseOid: null }); }); it("is a noop when already up to date with the base tip", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: true, + newBaseIsAncestorOfHead: true, behindCount: 0, - aheadCount: 2, - uniquePatchOidsOldestFirst: ["abc", "def"], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "noop", replayOids: [] }); + ).toEqual({ action: "noop", oldBaseOid: null }); }); - it("cherry-picks only patch-id unique commits when history diverged after a base rewrite", () => { + it("plans rebase --onto when the old base tip is recovered after a rewrite", () => { expect( planFeatureBranchUpdate({ - baseIsAncestorOfHead: false, + newBaseIsAncestorOfHead: false, behindCount: 50, - aheadCount: 600, - uniquePatchOidsOldestFirst: ["only-feature-commit"], + recoveredOldBaseOid: "oldbase123", }), - ).toEqual({ action: "cherry-pick-unique", replayOids: ["only-feature-commit"] }); + ).toEqual({ action: "rebase-onto", oldBaseOid: "oldbase123" }); }); - it("is a noop when diverged but every patch already exists on the new base", () => { - expect( + it("throws when diverged and no old base tip can be recovered", () => { + expect(() => planFeatureBranchUpdate({ - baseIsAncestorOfHead: false, + newBaseIsAncestorOfHead: false, behindCount: 10, - aheadCount: 10, - uniquePatchOidsOldestFirst: [], + recoveredOldBaseOid: null, }), - ).toEqual({ action: "noop", replayOids: [] }); + ).toThrow(StackError); }); - it("orders unique commits oldest-first from rev-list order", () => { - expect(orderUniqueCommitsOldestFirst(["a", "b", "c", "d"], ["d", "b"])).toEqual(["b", "d"]); + it("recovers the newest historical base tip that is still an ancestor of head", () => { + const ancestors = new Set(["aaa", "bbb"]); + expect( + recoverOldBaseTip({ + historicalBaseTipsNewestFirst: ["ccc", "bbb", "aaa"], + isAncestorOfHead: (tip) => ancestors.has(tip), + }), + ).toBe("bbb"); }); - it("auto-skips only large conflicting transplants", () => { - expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 7 })).toBe(false); - expect(shouldAutoSkipConflictingTransplant({ changedFileCount: 31 })).toBe(true); + it("returns null when no historical base tip is an ancestor", () => { + expect( + recoverOldBaseTip({ + historicalBaseTipsNewestFirst: ["ccc", "ddd"], + isAncestorOfHead: () => false, + }), + ).toBeNull(); + }); + + it("appends base history newest-first without duplicates", () => { + expect(parseBaseHistory("aaa1111\nbbb2222\n")).toEqual(["aaa1111", "bbb2222"]); + expect(appendBaseHistory(["bbb2222", "aaa1111"], ["ccc3333", "bbb2222"], 10)).toEqual([ + "ccc3333", + "bbb2222", + "aaa1111", + ]); }); it("resets local to remote when git cherry has no unique patches", () => { diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts index d27de44600f..6b503355044 100755 --- a/scripts/fork-stack.ts +++ b/scripts/fork-stack.ts @@ -10,12 +10,24 @@ import * as NodeURL from "node:url"; const FORK_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3code"; import { + appendBaseHistory, + FORK_CHANGES_BASE_HISTORY_REF, + parseBaseHistory, readManifest, + recoverOldBaseTip, StackError, type StackManifest, type StackPullRequest, } from "./rebase-pr-stack.ts"; +export { + appendBaseHistory, + FORK_CHANGES_BASE_HISTORY_MAX, + FORK_CHANGES_BASE_HISTORY_REF, + parseBaseHistory, + recoverOldBaseTip, +} from "./rebase-pr-stack.ts"; + const MANIFEST_PATH = NodePath.join(".github", "pr-stack.json"); interface PullRequestView { @@ -105,66 +117,36 @@ export function shouldRetargetPullRequestBase( return currentBase !== expectedBase; } -/** Default: conflicting transplants larger than this are treated as rewritten layer noise. */ -export const FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES = 30 as const; - /** * Plan how to bring a feature PR branch up to date with `fork/changes`. * - * - `rebase` when the base tip is already an ancestor (normal one-generation drift). - * - `cherry-pick-unique` when history diverged (base rewrite / multi-generation): only - * commits that `git cherry` marks unique by patch-id, oldest-first — never the full - * GitHub PR commit list (that re-applies obsolete private-layer bootstraps). - * - `noop` when already current, or when diverged but every patch already exists on base. + * - `rebase` when the new base tip is already an ancestor (simple behind). + * - `rebase-onto` when history diverged: replay only `oldBase..head` onto `newBase` + * (oldBase recovered from historical fork/changes tips). + * - `noop` when already current. */ export function planFeatureBranchUpdate(input: { - readonly baseIsAncestorOfHead: boolean; + readonly newBaseIsAncestorOfHead: boolean; readonly behindCount: number; - readonly aheadCount: number; - /** Unique-by-patch-id commits on the feature tip, oldest first. */ - readonly uniquePatchOidsOldestFirst: ReadonlyArray; + readonly recoveredOldBaseOid: string | null; }): { - readonly action: "noop" | "rebase" | "cherry-pick-unique"; - readonly replayOids: ReadonlyArray; + readonly action: "noop" | "rebase" | "rebase-onto"; + readonly oldBaseOid: string | null; } { - if (input.baseIsAncestorOfHead) { + if (input.newBaseIsAncestorOfHead) { if (input.behindCount <= 0) { - return { action: "noop", replayOids: [] }; + return { action: "noop", oldBaseOid: null }; } - return { action: "rebase", replayOids: [] }; + return { action: "rebase", oldBaseOid: null }; } - // Diverged from base (typical after fork/changes rewrite). Prefer patch-id unique commits. - if (input.uniquePatchOidsOldestFirst.length === 0) { - // No unique patches left — content is already on base; nothing to transplant. - return { action: "noop", replayOids: [] }; + if (input.recoveredOldBaseOid !== null) { + return { action: "rebase-onto", oldBaseOid: input.recoveredOldBaseOid }; } - return { - action: "cherry-pick-unique", - replayOids: input.uniquePatchOidsOldestFirst, - }; -} - -/** - * Preserve ancestry order: keep only unique oids in oldest-first rev-list order. - */ -export function orderUniqueCommitsOldestFirst( - revListOldestFirst: ReadonlyArray, - uniqueOids: ReadonlyArray, -): ReadonlyArray { - const unique = new Set(uniqueOids); - return revListOldestFirst.filter((oid) => unique.has(oid)); -} - -/** - * Whether a conflicting cherry-pick can be auto-skipped as a rewritten-layer artifact. - * Small feature commits that conflict must still fail loudly. - */ -export function shouldAutoSkipConflictingTransplant(input: { - readonly changedFileCount: number; - readonly maxFiles?: number; -}): boolean { - const max = input.maxFiles ?? FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES; - return input.changedFileCount > max; + throw new StackError( + "Cannot recover the old fork/changes tip this branch was built on " + + "(no known historical base tip is an ancestor of HEAD). " + + "Re-cut with `pnpm fork:stack start ` after the cascade records base history.", + ); } export function registerPullRequest( @@ -296,70 +278,19 @@ function resolveOpenPullRequestForBranch( return rows[0] ?? null; } -function countCommitChangedFiles(sourceRoot: string, oid: string): number { - const output = run("git", ["show", "--pretty=format:", "--name-only", oid], sourceRoot); - return output.split("\n").filter((line) => line.trim() !== "").length; -} - -/** - * Cherry-pick unique commits oldest-first. Large conflicting commits (rewritten private - * layer bootstraps) are skipped; small conflicts fail hard. - */ -export function transplantUniqueCommits( - sourceRoot: string, - oidsOldestFirst: ReadonlyArray, - options: { - readonly onSkip?: (oid: string, reason: string) => void; - readonly maxAutoSkipFiles?: number; - } = {}, -): { - readonly appliedOids: ReadonlyArray; - readonly skippedOids: ReadonlyArray; - readonly hardConflictOid: string | null; - readonly hardConflictMessage: string | null; -} { - const applied: string[] = []; - const skipped: string[] = []; - for (const oid of oidsOldestFirst) { - const result = runAllowFailure( - "git", - ["-c", "commit.gpgsign=false", "cherry-pick", oid], - sourceRoot, - ); - if (result.status === 0) { - applied.push(oid); - continue; - } - runAllowFailure("git", ["cherry-pick", "--abort"], sourceRoot); - const fileCount = countCommitChangedFiles(sourceRoot, oid); - if ( - shouldAutoSkipConflictingTransplant({ - changedFileCount: fileCount, - ...(options.maxAutoSkipFiles === undefined ? {} : { maxFiles: options.maxAutoSkipFiles }), - }) - ) { - skipped.push(oid); - options.onSkip?.( - oid, - `conflicting transplant touches ${fileCount} files; treating as rewritten-layer noise`, - ); - continue; - } - return { - appliedOids: applied, - skippedOids: skipped, - hardConflictOid: oid, - hardConflictMessage: - stripAnsi(result.stderr.trim() || result.stdout.trim()) || - `conflict while cherry-picking (${fileCount} files)`, - }; +function fetchBaseHistory(sourceRoot: string): ReadonlyArray { + const fetched = runAllowFailure( + "git", + ["fetch", "origin", `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`], + sourceRoot, + ); + if (fetched.status !== 0) { + // Ref may not exist yet (first cascade after this lands). + return []; } - return { - appliedOids: applied, - skippedOids: skipped, - hardConflictOid: null, - hardConflictMessage: null, - }; + const blob = runAllowFailure("git", ["show", FORK_CHANGES_BASE_HISTORY_REF], sourceRoot); + if (blob.status !== 0 || !blob.stdout) return []; + return parseBaseHistory(stripAnsi(blob.stdout)); } /** @@ -454,32 +385,40 @@ function updateFeatureBranch( } const baseRef = `origin/${expectedBase}`; - const ancestorCheck = runAllowFailure( - "git", - ["merge-base", "--is-ancestor", baseRef, "HEAD"], - sourceRoot, - ); - const baseIsAncestorOfHead = ancestorCheck.status === 0; + const newBaseOid = run("git", ["rev-parse", baseRef], sourceRoot); + const newBaseIsAncestorOfHead = + runAllowFailure("git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], sourceRoot).status === + 0; const behindCount = Number(run("git", ["rev-list", "--count", `HEAD..${baseRef}`], sourceRoot)); - const aheadCount = Number(run("git", ["rev-list", "--count", `${baseRef}..HEAD`], sourceRoot)); - // Patch-id uniqueness vs base (handles multi-generation fork/changes rewrites). - const cherryOutput = run("git", ["cherry", baseRef, "HEAD"], sourceRoot); - const uniqueUnordered = uniqueLocalCommitsFromCherry(cherryOutput); - const revOldestFirst = run( - "git", - ["rev-list", "--reverse", "--no-merges", `${baseRef}..HEAD`], - sourceRoot, - ) - .split("\n") - .filter(Boolean); - const uniqueOldestFirst = orderUniqueCommitsOldestFirst(revOldestFirst, uniqueUnordered); + // Historical fork/changes tips (newest first), plus the current tip as a candidate. + const history = fetchBaseHistory(sourceRoot); + const historicalTips = appendBaseHistory(history, [newBaseOid]); + const recoveredOldBaseOid = recoverOldBaseTip({ + historicalBaseTipsNewestFirst: historicalTips, + isAncestorOfHead: (tip) => + runAllowFailure("git", ["merge-base", "--is-ancestor", tip, "HEAD"], sourceRoot).status === 0, + }); + + // If current base is already an ancestor, recovery is not needed for --onto. + // If diverged, recovered tip must be a *previous* base still in this branch's history + // (not the new tip, which is never an ancestor when diverged). + const recoveredForOnto = + recoveredOldBaseOid !== null && recoveredOldBaseOid.toLowerCase() !== newBaseOid.toLowerCase() + ? recoveredOldBaseOid + : recoverOldBaseTip({ + historicalBaseTipsNewestFirst: history.filter( + (tip) => tip.toLowerCase() !== newBaseOid.toLowerCase(), + ), + isAncestorOfHead: (tip) => + runAllowFailure("git", ["merge-base", "--is-ancestor", tip, "HEAD"], sourceRoot) + .status === 0, + }); const plan = planFeatureBranchUpdate({ - baseIsAncestorOfHead, + newBaseIsAncestorOfHead, behindCount, - aheadCount, - uniquePatchOidsOldestFirst: uniqueOldestFirst, + recoveredOldBaseOid: recoveredForOnto, }); if (plan.action === "rebase") { @@ -495,51 +434,27 @@ function updateFeatureBranch( ); } console.log(`Rebased ${branch} onto ${expectedBase}.`); - } else if (plan.action === "cherry-pick-unique") { - const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); - const leaseTip = tipBefore; - run("git", ["reset", "--hard", baseRef], sourceRoot); - const picked = transplantUniqueCommits(sourceRoot, plan.replayOids, { - onSkip: (oid, reason) => { - console.log(`Skipped ${oid.slice(0, 12)} (${reason}).`); - }, - }); - if (picked.hardConflictOid !== null) { - run("git", ["reset", "--hard", leaseTip], sourceRoot); + } else if (plan.action === "rebase-onto") { + const oldBase = plan.oldBaseOid!; + const featureCount = Number( + run("git", ["rev-list", "--count", `${oldBase}..HEAD`], sourceRoot), + ); + const result = runAllowFailure( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", baseRef, oldBase], + sourceRoot, + ); + if (result.status !== 0) { + runAllowFailure("git", ["rebase", "--abort"], sourceRoot); throw new StackError( - `Transplant onto ${expectedBase} conflicted on small commit ${picked.hardConflictOid.slice(0, 12)} (${picked.hardConflictMessage}). Resolve manually or re-cut the branch with fork:stack start.`, - ); - } - if (picked.appliedOids.length === 0) { - // All unique commits were large rewrite artifacts — leave tip at base only if - // the PR would become empty; still force-with-lease to clear dead history. - console.log( - `No portable unique commits left vs ${expectedBase} (skipped ${picked.skippedOids.length} large/conflicting layer commit(s)). Branch tip matches base.`, - ); - } else { - console.log( - `Cherry-picked ${picked.appliedOids.length} unique commit(s) onto ${expectedBase}` + - (picked.skippedOids.length > 0 - ? ` (skipped ${picked.skippedOids.length} rewritten-layer commit(s))` - : "") + - ".", + `rebase --onto ${expectedBase} (old base ${oldBase.slice(0, 12)}, ${featureCount} feature commit(s)) failed:\n${result.stderr.trim() || result.stdout.trim()}`, ); } + console.log( + `Rebased ${featureCount} feature commit(s) onto ${expectedBase} (recovered old base ${oldBase.slice(0, 12)}).`, + ); } else { - if (!baseIsAncestorOfHead && uniqueOldestFirst.length === 0) { - // Diverged SHAs but every patch already on base — reset tip to base to become mergeable. - const tipBefore = run("git", ["rev-parse", "HEAD"], sourceRoot); - if (tipBefore !== run("git", ["rev-parse", baseRef], sourceRoot)) { - run("git", ["reset", "--hard", baseRef], sourceRoot); - console.log( - `${branch} had no unique patches vs ${expectedBase}; reset tip to base (empty PR / already landed).`, - ); - } else { - console.log(`${branch} is already up to date with ${expectedBase}.`); - } - } else { - console.log(`${branch} is already up to date with ${expectedBase}.`); - } + console.log(`${branch} is already up to date with ${expectedBase}.`); } if (prNumber !== null && shouldRetargetPullRequestBase(prBaseRefName, expectedBase)) { diff --git a/scripts/rebase-pr-stack.ts b/scripts/rebase-pr-stack.ts index 85fdfcdefe4..d1c2a53874b 100644 --- a/scripts/rebase-pr-stack.ts +++ b/scripts/rebase-pr-stack.ts @@ -13,6 +13,52 @@ const EXPECTED_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3cod const STATE_FILE = "rebase-pr-stack-state.json"; const ZERO_SHA = "0000000000000000000000000000000000000000"; +/** + * Git ref (blob) listing historical `fork/changes` tips, newest first. + * Written by the stack cascade so feature PRs can recover the exact base they + * were built on after rewrites (`oldBase..head` is the PR's own commits). + */ +export const FORK_CHANGES_BASE_HISTORY_REF = "refs/t3/stack/base-history/fork-changes"; +export const FORK_CHANGES_BASE_HISTORY_MAX = 100 as const; + +export function parseBaseHistory(text: string): ReadonlyArray { + return text + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^[0-9a-f]{7,40}$/i.test(line)); +} + +export function appendBaseHistory( + existingNewestFirst: ReadonlyArray, + tipsNewestFirst: ReadonlyArray, + max: number = FORK_CHANGES_BASE_HISTORY_MAX, +): ReadonlyArray { + const seen = new Set(); + const out: string[] = []; + for (const tip of [...tipsNewestFirst, ...existingNewestFirst]) { + const key = tip.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(tip); + if (out.length >= max) break; + } + return out; +} + +/** + * Newest known historical base tip that is still an ancestor of `head`. + * Feature commits are exactly `recoveredBase..head`. + */ +export function recoverOldBaseTip(input: { + readonly historicalBaseTipsNewestFirst: ReadonlyArray; + readonly isAncestorOfHead: (tip: string) => boolean; +}): string | null { + for (const tip of input.historicalBaseTipsNewestFirst) { + if (input.isAncestorOfHead(tip)) return tip; + } + return null; +} + export interface StackPullRequest { readonly number: number; readonly branch: string; @@ -958,6 +1004,21 @@ export async function rebaseOpenFeaturePullRequests(options: { "origin", ...branchesToFetch.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), ]); + // Historical fork/changes tips for multi-generation recovery. + run( + "git", + [ + "fetch", + "--quiet", + "origin", + `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`, + ], + { cwd: repoDir, allowFailure: true }, + ); + const historyBlob = git(repoDir, ["show", FORK_CHANGES_BASE_HISTORY_REF], { + allowFailure: true, + }); + const baseHistoryTips = historyBlob ? parseBaseHistory(historyBlob) : []; // Prefer the post-sync origin tip; fall back to the in-memory rewritten tip if present. const fetchedForkTip = git(repoDir, [ @@ -999,128 +1060,60 @@ export async function rebaseOpenFeaturePullRequests(options: { continue; } - // Prefer one-step rebase --onto when the previous fork/changes tip is still an ancestor. - const hasOldBase = run( - "git", - ["merge-base", "--is-ancestor", options.oldForkChangesTip, remoteTip], - { cwd: repoDir, allowFailure: true }, - ); - - let newTip: string | null = null; - - if (hasOldBase.status === 0) { - git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); - const rebaseResult = run( - "git", - ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, options.oldForkChangesTip], - { + // 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. + const historicalTips = appendBaseHistory(baseHistoryTips, [options.oldForkChangesTip, newBase]); + const recoveredOldBase = recoverOldBaseTip({ + historicalBaseTipsNewestFirst: historicalTips.filter( + (tip) => tip.toLowerCase() !== newBase.toLowerCase(), + ), + isAncestorOfHead: (tip) => + run("git", ["merge-base", "--is-ancestor", tip, remoteTip], { cwd: repoDir, allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - }, - ); - if (rebaseResult.status !== 0) { - if (rebaseInProgress(repoDir)) { - run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); - } - // Fall through to patch-id transplant below. - } else { - newTip = git(repoDir, ["rev-parse", "HEAD"]); - } - } - - // Multi-generation / rewritten-base fallback: transplant only git-cherry unique patches. - if (newTip === null) { - const cherryOutput = git(repoDir, ["cherry", newBase, remoteTip], { allowFailure: true }); - const uniqueLines = cherryOutput - ? cherryOutput - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("+")) - .map( - (line) => - line - .replace(/^\+\s*/, "") - .trim() - .split(/\s+/)[0] ?? "", - ) - .filter(Boolean) - : []; - const revOldestFirst = git( - repoDir, - ["rev-list", "--reverse", "--no-merges", `${newBase}..${remoteTip}`], - { allowFailure: true }, - ) - .split("\n") - .filter(Boolean); - const uniqueSet = new Set(uniqueLines); - const uniqueOldestFirst = revOldestFirst.filter((oid) => uniqueSet.has(oid)); - - if (uniqueOldestFirst.length === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: - hasOldBase.status === 0 - ? "rebase --onto failed and no unique patches vs new base" - : "no unique patches vs new fork/changes (already landed or empty)", - }); - continue; - } + }).status === 0, + }); - git(repoDir, ["checkout", "--quiet", "--detach", newBase]); - const applied: string[] = []; - let hardConflict: string | null = null; - for (const oid of uniqueOldestFirst) { - const pick = run("git", ["-c", "commit.gpgsign=false", "cherry-pick", oid], { - cwd: repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true" }, - }); - if (pick.status === 0) { - applied.push(oid); - continue; - } - const cherryHead = git(repoDir, ["rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"], { - allowFailure: true, - }); - if (cherryHead || rebaseInProgress(repoDir)) { - run("git", ["cherry-pick", "--abort"], { cwd: repoDir, allowFailure: true }); - } - const nameOnly = git(repoDir, ["show", "--pretty=format:", "--name-only", oid], { - allowFailure: true, - }); - const fileCount = nameOnly - ? nameOnly.split("\n").filter((line) => line.trim() !== "").length - : 0; - // Match fork-stack FEATURE_TRANSPLANT_AUTO_SKIP_MAX_FILES (30). - if (fileCount > 30) { - continue; - } - hardConflict = oid; - break; - } + if (recoveredOldBase === null) { + 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)", + }); + continue; + } - if (hardConflict !== null) { - conflicts.push({ - number: feature.number, - branch: feature.branch, - message: `cherry-pick conflict on ${hardConflict.slice(0, 12)} (portable unique commit)`, - }); - continue; - } - if (applied.length === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "only non-portable rewritten-layer commits remained unique", - }); - continue; + git(repoDir, ["checkout", "--quiet", "--detach", remoteTip]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, recoveredOldBase], + { + cwd: repoDir, + allowFailure: true, + env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, + }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], { cwd: repoDir, allowFailure: true }); } - newTip = git(repoDir, ["rev-parse", "HEAD"]); + const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { + allowFailure: true, + }); + conflicts.push({ + number: feature.number, + branch: feature.branch, + message: conflictPaths + ? `conflict rebasing onto new base from ${recoveredOldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` + : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"), + }); + continue; } - if (newTip === null || newTip === remoteTip) { + const newTip = git(repoDir, ["rev-parse", "HEAD"]); + if (newTip === remoteTip) { skipped.push({ number: feature.number, branch: feature.branch, @@ -1164,13 +1157,14 @@ export async function syncStack(options: StackRunOptions): Promise, +): void { + const repoDir = sourceRoot; + run( + "git", + ["fetch", "origin", `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`], + { cwd: repoDir, allowFailure: true }, + ); + const existingBlob = git(repoDir, ["show", FORK_CHANGES_BASE_HISTORY_REF], { + allowFailure: true, + }); + const existing = existingBlob ? parseBaseHistory(existingBlob) : []; + const next = appendBaseHistory(existing, tipsNewestFirst); + const body = `${next.join("\n")}\n`; + const tmp = NodePath.join(NodeOS.tmpdir(), `fork-changes-base-history-${process.pid}.txt`); + NodeFS.writeFileSync(tmp, body, "utf8"); + try { + const blobOid = git(repoDir, ["hash-object", "-w", tmp]); + git(repoDir, ["update-ref", FORK_CHANGES_BASE_HISTORY_REF, blobOid]); + git(repoDir, ["push", "origin", FORK_CHANGES_BASE_HISTORY_REF]); + console.log( + `Updated ${FORK_CHANGES_BASE_HISTORY_REF} (${next.length} tip(s); newest ${next[0]?.slice(0, 12) ?? "none"}).`, + ); + } finally { + try { + NodeFS.unlinkSync(tmp); + } catch { + // ignore + } + } +} + function appendFeatureRebaseSummary(result: FeaturePullRequestRebaseResult): void { const summaryPath = process.env.GITHUB_STEP_SUMMARY; if (!summaryPath) return;