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
13 changes: 8 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr-number>`). 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
Expand Down Expand Up @@ -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.
Expand All @@ -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 <pr>`
- Confirm with `gh pr view <n> --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.
Expand Down
19 changes: 9 additions & 10 deletions docs/fork-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
22 changes: 22 additions & 0 deletions scripts/fork-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
planLocalSyncWithRemote,
registerPullRequest,
registerIntegrationOverlay,
resolveFeaturePullRequestBaseBranch,
shouldRetargetPullRequestBase,
stackParentBranch,
uniqueLocalCommitsFromCherry,
Expand Down Expand Up @@ -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({
Expand Down
63 changes: 59 additions & 4 deletions scripts/fork-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -340,6 +357,31 @@ function fetchBaseHistory(sourceRoot: string): ReadonlyArray<string> {
return parseBaseHistory(stripAnsi(blob.stdout));
}

function fetchPullRequestHeadHistory(
sourceRoot: string,
pullRequestNumber: number,
): ReadonlyArray<string> {
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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -431,15 +471,30 @@ 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 =
runAllowFailure("git", ["merge-base", "--is-ancestor", baseRef, "HEAD"], sourceRoot).status ===
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,
Expand Down
168 changes: 168 additions & 0 deletions scripts/rebase-pr-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RebaseConflictError,
resumeStack,
selectOpenFeaturePullRequests,
selectOpenFeaturePullRequestTree,
StackError,
syncStack,
type PullRequestSnapshot,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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));
});
});
Loading
Loading