Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions docs/fork-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
76 changes: 48 additions & 28 deletions scripts/fork-stack.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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", () => {
Expand Down
Loading
Loading