From cb2cdd529492f1a5a5dc3813cad11ae8b4f7084e Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:26:12 +0000 Subject: [PATCH 1/2] chore(stack): remove the fork stack and overlay tooling Nothing drives this machinery any more. Overlays were drained and deregistered, fork/changes and fork/integration are frozen, and upstream is now merged straight into fork/dev -- a plain git merge, because upstream is append-only and the previous sync recorded its ancestry. Removed: fork-stack.ts, rebase-pr-stack.ts, compose-integration-overlays.ts, rebase-integration-overlays.ts and client-overlay-owner.ts with their tests; the compose-integration, rebase-pr-stack and force-update-overlay-tip workflows; client-overlay-ownership.json; the four fork:* package scripts; and the stack-ship-path and client-overlays docs. Kept, deliberately: classify-deployment-diff.sh is not stack tooling. It is the shared definition of what a diff means, run by both fork-release and the smart-host poller so the two cannot disagree about what ships. Its tests still pass. managed-pr-draft-lock.yml still guards #255, #1 and #27, which remain open drafts. Merging #255 would push fork CI plumbing into the upstream mirror, so the lock matters more now that nothing else watches those branches. pr-stack.json survives purely as that workflow's allowlist, trimmed to the three PRs and annotated to say so. The lock read its allowlist from fork/changes, a frozen branch, so edits to the manifest could never have taken effect. It now reads fork/dev. Agent guidance is rewritten rather than deleted: AGENTS.md referenced removed commands in a dozen places, which would have sent an agent to a script that no longer exists. fork-stack.md, stack-history-rewrite.md and fork-base.md are bannered or corrected -- the branches they describe still exist even though the workflow around them does not. Verified: full recursive typecheck clean, 2248 tests pass, classifier tests pass, and the draft lock's jq query still matches the managed PRs and rejects an unmanaged one. The single failing test predates this change. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .github/client-overlay-ownership.json | 40 - .github/pr-stack.json | 61 +- .github/workflows/compose-integration.yml | 113 - .../workflows/force-update-overlay-tip.yml | 72 - .github/workflows/managed-pr-draft-lock.yml | 2 +- .github/workflows/rebase-pr-stack.yml | 70 - AGENTS.md | 46 +- docs/client-overlays.md | 82 - docs/fork-base.md | 14 +- docs/fork-stack.md | 2 - docs/stable-dev-release-branch-handover.md | 16 +- docs/stack-history-rewrite.md | 7 + docs/stack-ship-path.md | 370 --- package.json | 6 +- scripts/client-overlay-owner.test.ts | 49 - scripts/client-overlay-owner.ts | 76 - scripts/compose-integration-overlays.test.ts | 13 - scripts/compose-integration-overlays.ts | 383 --- scripts/fork-stack.test.ts | 309 --- scripts/fork-stack.ts | 1046 -------- scripts/rebase-integration-overlays.test.ts | 83 - scripts/rebase-integration-overlays.ts | 406 --- scripts/rebase-pr-stack.test.ts | 1245 ---------- scripts/rebase-pr-stack.ts | 2204 ----------------- 24 files changed, 43 insertions(+), 6672 deletions(-) delete mode 100644 .github/client-overlay-ownership.json delete mode 100644 .github/workflows/compose-integration.yml delete mode 100644 .github/workflows/force-update-overlay-tip.yml delete mode 100644 .github/workflows/rebase-pr-stack.yml delete mode 100644 docs/client-overlays.md delete mode 100644 docs/stack-ship-path.md delete mode 100644 scripts/client-overlay-owner.test.ts delete mode 100644 scripts/client-overlay-owner.ts delete mode 100644 scripts/compose-integration-overlays.test.ts delete mode 100644 scripts/compose-integration-overlays.ts delete mode 100644 scripts/fork-stack.test.ts delete mode 100755 scripts/fork-stack.ts delete mode 100644 scripts/rebase-integration-overlays.test.ts delete mode 100644 scripts/rebase-integration-overlays.ts delete mode 100644 scripts/rebase-pr-stack.test.ts delete mode 100644 scripts/rebase-pr-stack.ts diff --git a/.github/client-overlay-ownership.json b/.github/client-overlay-ownership.json deleted file mode 100644 index 0c7cbcf9668..00000000000 --- a/.github/client-overlay-ownership.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "overlays": [ - { - "id": "desktop-links", - "branch": "t3-discord/f7d37879-desktop-deeplinks", - "pullRequest": 10, - "paths": [ - "apps/desktop/src/app/DesktopApp.ts", - "apps/desktop/src/app/DesktopClerk.test.ts", - "apps/desktop/src/app/DesktopClerk.ts", - "apps/desktop/src/app/DesktopDeepLinks.test.ts", - "apps/desktop/src/app/DesktopDeepLinks.ts", - "apps/desktop/src/backend/DesktopBackendPool.test.ts", - "apps/desktop/src/electron/ElectronProtocol.ts", - "apps/desktop/src/main.ts", - "apps/desktop/src/window/DesktopApplicationMenu.test.ts", - "apps/desktop/src/window/DesktopWindow.test.ts", - "apps/desktop/src/window/DesktopWindow.ts", - "scripts/build-desktop-artifact.ts" - ] - }, - { - "id": "discord", - "branch": "fork/discord", - "pullRequest": 80, - "paths": [ - "apps/discord-bot/**", - "docs/integrations/discord-bot.md", - "docs/architecture/discord-browser-automation.md", - "docs/examples/project-aliases.yaml" - ] - }, - { - "id": "vscode", - "branch": "fork/vscode", - "pullRequest": 79, - "paths": ["apps/vscode/**", ".vscode/launch.json", ".vscode/tasks.json"] - } - ] -} diff --git a/.github/pr-stack.json b/.github/pr-stack.json index 76e6344326d..f9cd6edeffb 100644 --- a/.github/pr-stack.json +++ b/.github/pr-stack.json @@ -1,8 +1,5 @@ { - "upstreamRemote": "upstream", - "upstreamBranch": "main", - "forkChangesBranch": "fork/changes", - "integrationBranch": "fork/integration", + "_comment": "Allowlist for .github/workflows/managed-pr-draft-lock.yml only. The stack tooling that consumed the rest was removed; upstream is merged directly into fork/dev. These three PRs stay draft so the provenance layers can never be merged into the upstream mirror by accident.", "pullRequests": [ { "number": 255, @@ -15,61 +12,7 @@ { "number": 27, "branch": "fork/candidates" - }, - { - "number": 2, - "branch": "fork/changes" } ], - "integrationOverlays": [], - "conflictResolutions": [ - { - "branch": "fork/integration", - "commit": "*", - "path": "pnpm-lock.yaml", - "strategy": "theirs" - }, - { - "branch": "fork/integration", - "commit": "286efa51172d3cbf46684c9923ca9d2b003d0967", - "path": "pnpm-lock.yaml", - "strategy": "theirs" - }, - { - "branch": "fork/integration", - "commit": "*", - "path": "AGENTS.md", - "strategy": "ours" - }, - { - "branch": "fork/integration", - "commit": "46c3697f207ea2de04c0a9ef72ca867d4d9f01da", - "path": "AGENTS.md", - "strategy": "ours" - }, - { - "branch": "fork/integration", - "commit": "*", - "path": "docs/fork-stack.md", - "strategy": "ours" - }, - { - "branch": "fork/changes", - "commit": "*", - "path": "AGENTS.md", - "strategy": "theirs" - }, - { - "branch": "fork/changes", - "commit": "206981716ef30b5fb58338e32653339ed958a7f7", - "path": "AGENTS.md", - "strategy": "theirs" - }, - { - "branch": "fork/changes", - "commit": "*", - "path": ".github/workflows/ci.yml", - "strategy": "theirs" - } - ] + "integrationOverlays": [] } diff --git a/.github/workflows/compose-integration.yml b/.github/workflows/compose-integration.yml deleted file mode 100644 index c73c38598a1..00000000000 --- a/.github/workflows/compose-integration.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Fast ship path: rebase registered overlays onto current fork/changes when -# needed, rebuild fork/integration from changes + every overlay tip, then -# dispatch Fork CI. Does not rewrite main / fork/tim / fork/candidates. -# -# Runs only when: -# - a PR is **merged** into fork/changes or a registered overlay base -# - manual workflow_dispatch -# -# Deliberately does **not** run on branch pushes (force-push rebases, deploy-key -# tip updates, compose's own overlay force-with-lease). Those must not attach a -# "Compose and dispatch integration CI" check to permanent fork/* draft PRs or -# make layer status look red. Layer green is Fork CI only (Check / Test / Mobile -# Native Static Analysis / Release Smoke). -# -# Full upstream / Tim / candidates restacks stay local and manual -# (`node scripts/rebase-pr-stack.ts sync --push`). Do not re-enable -# rebase-pr-stack.yml automation. -# -# Overlay branch names below must stay in sync with integrationOverlays in -# .github/pr-stack.json (workflow `on:` cannot load that file dynamically). -name: Compose fork integration - -on: - # Only real merges into product bases — not every tip push / rebase. - pull_request: - types: [closed] - branches: - - fork/changes - # Registered integration overlays (keep in sync with .github/pr-stack.json) - - t3-discord/f7d37879-desktop-deeplinks - - fork/discord - - fork/vscode - - fork/identity - workflow_dispatch: - -concurrency: - # Serialize force-pushes to fork/integration (and overlay auto-rebase). - group: fork-integration-compose - cancel-in-progress: false - -permissions: - contents: write - actions: write - pull-requests: read - -jobs: - compose: - name: Compose and dispatch integration CI - # Merged PRs only (closed without merge is a no-op). Manual dispatch always runs. - # This job is an integration rebuild, not a product-layer quality gate — do not - # add it to required status checks for fork/changes or overlay draft PRs. - if: > - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && github.event.pull_request.merged == true) - runs-on: ubuntu-24.04 - # Overlay rebase + lock regenerate can exceed a short window after large merges. - timeout-minutes: 60 - steps: - - name: Checkout fork/changes (compose scripts + manifest) - uses: actions/checkout@v6 - with: - ref: fork/changes - fetch-depth: 1 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Configure protected stack push key - env: - FORK_STACK_DEPLOY_KEY: ${{ secrets.FORK_STACK_DEPLOY_KEY }} - run: | - if [[ -z "${FORK_STACK_DEPLOY_KEY}" ]]; then - echo "error: FORK_STACK_DEPLOY_KEY secret is required to push fork/integration and overlays" >&2 - exit 1 - fi - key_path="${RUNNER_TEMP}/fork-stack-deploy-key" - printf '%s\n' "${FORK_STACK_DEPLOY_KEY}" > "${key_path}" - chmod 600 "${key_path}" - ssh-keyscan -H github.com >> "${RUNNER_TEMP}/github-known-hosts" - echo "GIT_SSH_COMMAND=ssh -i ${key_path} -o IdentitiesOnly=yes -o UserKnownHostsFile=${RUNNER_TEMP}/github-known-hosts" >> "${GITHUB_ENV}" - git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git" - - - name: Rebase registered overlays onto current fork/changes - run: | - set -euo pipefail - # No-op when already based. Force-with-lease updates stale overlay tips so - # compose no longer fails after ordinary merges to fork/changes. - # Real conflicts hard-fail with the overlay branch + paths (fix that - # overlay locally, then re-run this workflow). - # Overlay force-pushes here must not re-trigger this workflow (no push: on). - node scripts/rebase-integration-overlays.ts - - - name: Compose registered integration overlays - env: - COMPOSE_WORK_ROOT: ${{ runner.temp }}/compose-work - run: | - set -euo pipefail - # Fetches origin/fork/changes + every registered overlay tip after the - # auto-rebase step. Still fails if an overlay has no commits above changes. - node scripts/compose-integration-overlays.ts - tip="$(git ls-remote origin "refs/heads/fork/integration" | awk '{print $1}')" - echo "integration_sha=${tip}" >> "${GITHUB_ENV}" - echo "Composed fork/integration tip: ${tip}" - - - name: Dispatch integration CI - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh workflow run fork-ci.yml --repo "$GITHUB_REPOSITORY" --ref fork/integration - echo "Dispatched Fork CI for fork/integration (tip ${integration_sha:-unknown})." diff --git a/.github/workflows/force-update-overlay-tip.yml b/.github/workflows/force-update-overlay-tip.yml deleted file mode 100644 index e9945834bce..00000000000 --- a/.github/workflows/force-update-overlay-tip.yml +++ /dev/null @@ -1,72 +0,0 @@ -# One-shot helper for maintainers: force-with-lease an overlay branch tip -# (e.g. fork/identity after a local conflict resolve) using FORK_STACK_DEPLOY_KEY. -# Not a product CI gate. Safe to leave; only runs on workflow_dispatch. -name: Force update overlay tip - -on: - workflow_dispatch: - inputs: - target_branch: - description: Overlay branch to update (e.g. fork/identity) - required: true - type: string - source_ref: - description: Source ref that already contains the rebased tip - required: true - type: string - expected_old_tip: - description: Optional expected current tip for force-with-lease (empty = no lease) - required: false - type: string - default: "" - -permissions: - contents: write - -jobs: - push-tip: - name: Force-update overlay tip - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Configure protected stack push key - env: - FORK_STACK_DEPLOY_KEY: ${{ secrets.FORK_STACK_DEPLOY_KEY }} - run: | - set -euo pipefail - if [[ -z "${FORK_STACK_DEPLOY_KEY}" ]]; then - echo "error: FORK_STACK_DEPLOY_KEY secret is required" >&2 - exit 1 - fi - key_path="${RUNNER_TEMP}/fork-stack-deploy-key" - printf '%s\n' "${FORK_STACK_DEPLOY_KEY}" > "${key_path}" - chmod 600 "${key_path}" - ssh-keyscan -H github.com >> "${RUNNER_TEMP}/github-known-hosts" - echo "GIT_SSH_COMMAND=ssh -i ${key_path} -o IdentitiesOnly=yes -o UserKnownHostsFile=${RUNNER_TEMP}/github-known-hosts" >> "${GITHUB_ENV}" - - - name: Fetch source tip and push target branch - env: - TARGET_BRANCH: ${{ inputs.target_branch }} - SOURCE_REF: ${{ inputs.source_ref }} - EXPECTED_OLD_TIP: ${{ inputs.expected_old_tip }} - run: | - set -euo pipefail - git init --quiet - git remote add origin "git@github.com:${GITHUB_REPOSITORY}.git" - git fetch --quiet --no-tags origin \ - "+refs/heads/${SOURCE_REF}:refs/remotes/origin/source" \ - "+refs/heads/${TARGET_BRANCH}:refs/remotes/origin/target" || true - new_tip="$(git rev-parse refs/remotes/origin/source)" - old_tip="$(git rev-parse refs/remotes/origin/target 2>/dev/null || true)" - echo "source ${SOURCE_REF} => ${new_tip}" - echo "target ${TARGET_BRANCH} currently ${old_tip:-missing}" - if [[ -n "${EXPECTED_OLD_TIP}" && -n "${old_tip}" && "${EXPECTED_OLD_TIP}" != "${old_tip}" ]]; then - echo "error: expected old tip ${EXPECTED_OLD_TIP} but remote is ${old_tip}" >&2 - exit 1 - fi - lease_arg=() - if [[ -n "${old_tip}" ]]; then - lease_arg=( "--force-with-lease=refs/heads/${TARGET_BRANCH}:${old_tip}" ) - fi - git push "${lease_arg[@]}" origin "${new_tip}:refs/heads/${TARGET_BRANCH}" - echo "Updated ${TARGET_BRANCH} -> ${new_tip}" diff --git a/.github/workflows/managed-pr-draft-lock.yml b/.github/workflows/managed-pr-draft-lock.yml index 0ed1a8e5a9c..d8e5c25bc41 100644 --- a/.github/workflows/managed-pr-draft-lock.yml +++ b/.github/workflows/managed-pr-draft-lock.yml @@ -23,7 +23,7 @@ jobs: manifest="$( gh api \ -H 'Accept: application/vnd.github.raw+json' \ - "repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/changes" + "repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/dev" )" if ! jq -e --argjson number "${PR_NUMBER}" \ '([.pullRequests[], .integrationOverlays[]] | any(.number == $number))' \ diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml deleted file mode 100644 index 50b0b9ee215..00000000000 --- a/.github/workflows/rebase-pr-stack.yml +++ /dev/null @@ -1,70 +0,0 @@ -# Slow path only: full main → tim → candidates → changes rewrite + overlay cascade. -# Day-to-day merges onto fork/changes use "Compose fork integration" instead. -# -# This workflow is intentionally left DISABLED at repository level (disabled_manually). -# Do not enable it for schedule/push automation. Prefer local: -# node scripts/rebase-pr-stack.ts sync --push -# if a full provenance restack is required. -name: Rebase fork PR stack - -on: - workflow_dispatch: - -concurrency: - group: fork-pr-stack - cancel-in-progress: false - -permissions: - contents: write - pull-requests: read - actions: write - -jobs: - rebase: - name: Rebase stack and compose integration - runs-on: ubuntu-24.04 - timeout-minutes: 90 - steps: - - name: Checkout canonical fork changes - uses: actions/checkout@v6 - with: - ref: fork/changes - fetch-depth: 1 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Configure protected stack push key - env: - FORK_STACK_DEPLOY_KEY: ${{ secrets.FORK_STACK_DEPLOY_KEY }} - run: | - if [[ -z "${FORK_STACK_DEPLOY_KEY}" ]]; then - echo "error: FORK_STACK_DEPLOY_KEY secret is required to rewrite stack branches" >&2 - exit 1 - fi - key_path="${RUNNER_TEMP}/fork-stack-deploy-key" - printf '%s\n' "${FORK_STACK_DEPLOY_KEY}" > "${key_path}" - chmod 600 "${key_path}" - ssh-keyscan -H github.com >> "${RUNNER_TEMP}/github-known-hosts" - echo "GIT_SSH_COMMAND=ssh -i ${key_path} -o IdentitiesOnly=yes -o UserKnownHostsFile=${RUNNER_TEMP}/github-known-hosts" >> "${GITHUB_ENV}" - git remote set-url origin "git@github.com:${GITHUB_REPOSITORY}.git" - - - name: Add upstream remote - run: git remote add upstream https://github.com/pingdotgg/t3code.git - - - name: Rebase and atomically update stack - env: - GH_TOKEN: ${{ github.token }} - run: node scripts/rebase-pr-stack.ts sync --push - - - name: Compose registered integration overlays - env: - COMPOSE_WORK_ROOT: ${{ runner.temp }}/compose-work - run: node scripts/compose-integration-overlays.ts - - - name: Dispatch integration CI - env: - GH_TOKEN: ${{ github.token }} - run: gh workflow run fork-ci.yml --repo "$GITHUB_REPOSITORY" --ref fork/integration diff --git a/AGENTS.md b/AGENTS.md index 4fa12f19674..27741b1e312 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,14 @@ ## Downstream fork branches and pull requests -Read [docs/fork-stack.md](./docs/fork-stack.md) before creating, rebasing, merging, or retargeting -branches. - -Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/stack-ship-path.md). +Branch from `fork/dev`, open every PR against `fork/dev`, and let it merge by squash. That is the +whole branching model. See +[docs/stable-dev-release-branch-handover.md](./docs/stable-dev-release-branch-handover.md). - Before the documented one-time cutover, implementation PRs continue to target `main`. - After cutover, `main` is an upstream mirror. Never merge downstream fork work into it. -- Update `main` only via a **local** provenance restack (`node scripts/rebase-pr-stack.ts sync ---push` or hand-applied layer rewrites), never via GitHub's **Sync fork** button, a PR into - `main`, or a casual force-push. The GitHub Actions workflow **Rebase fork PR stack** is +- Update `main` only by fast-forwarding it to the upstream tip, never via GitHub's **Sync fork** + button, a PR into `main`, or a casual force-push. The stack-rewrite workflow is **`disabled_manually` — leave it disabled.** Do not enable or dispatch it. Local restacks that must move protected tips use the repository-scoped `FORK_STACK_DEPLOY_KEY` (or an allowed bypass actor) only for that intentional rewrite; agents must never print or reuse that credential. @@ -45,15 +43,10 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st the upstream mirror; the other two are frozen and superseded by `fork/dev`. All three produce a huge unrelated diff, and `fork/changes` is rebased, which silently invalidates a PR based on it. See [docs/stable-dev-release-branch-handover.md](./docs/stable-dev-release-branch-handover.md). -- 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 the PR's intended parent (`fork/dev` for ordinary features, or the - current parent branch for dependent 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 - rebases when you have unique unpushed work. -- Independent features use parallel PRs based on `fork/changes`. Chain PRs only when one change +- When a PR is CONFLICTING or behind, rebase it onto `fork/dev` yourself and force-with-lease. + There is no stack automation to run: upstream is merged straight into `fork/dev`, so a PR is + only ever behind ordinary commits. +- Independent features use parallel PRs based on `fork/dev`. Chain PRs only when one change genuinely depends on another, and merge that chain bottom-up. - Treat external forks and open upstream PRs as selective import sources. Tim Smart imports land as one reviewed commit per source PR on `fork/tim`; selected unmerged upstream work lands as one @@ -66,10 +59,8 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st Never share a migration ledger between upstream and fork histories. Full rules: [docs/fork-stack.md](./docs/fork-stack.md) ("Migration namespaces during provenance imports"). - Run and deploy from `fork/integration`, never from a temporary feature or import branch. -- All features must land in `fork/changes`, including upstreamable work. After its downstream PR - merges, use `pnpm fork:stack promote ` to extract a clean - projection onto - upstream `main`. Use `adopt` only for work that began upstream-first, and `demote` to close an +- All features land in `fork/dev`, including upstreamable work. To send something upstream, open a + PR from a branch cut against upstream `main` in the usual GitHub way. Use `adopt` only for work that began upstream-first, and `demote` to close an upstream projection without removing the canonical downstream implementation. ### Automatic integration and deployment @@ -96,9 +87,8 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st `.github/pr-stack.json`, also add its branch to the `on.pull_request` base list in `compose-integration.yml` (and to `fork-ci.yml` PR bases). - **Slow path (upstream / Tim / candidates):** **manual / local only.** Run - `node scripts/rebase-pr-stack.ts sync --push` (or layer-by-layer hand restack). The Actions - workflow **Rebase fork PR stack** stays **`disabled_manually`** — do **not** enable it, schedule - it, or `gh workflow run` it. Pushes to `main` / `fork/tim` / `fork/candidates` must not auto-restack + merge `upstream/main` into `fork/dev` directly; `main` is then fast-forwarded to the upstream tip. + There is no restack workflow any more. Pushes to `main` / `fork/tim` / `fork/candidates` must not auto-restack or auto-compose. Local restacks mirror `pingdotgg/t3code:main`, rebuild provenance layers with stop-the-line green gates, rebase overlays, then compose integration via `workflow_dispatch` / local compose scripts. Deploy key (if used) is only for intentional @@ -146,7 +136,7 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st (“Per-layer full CI after stack rebase”). - Fix **all** failures on that layer, commit, force-with-lease push if the layer is shared, then and only then advance. - - Feature / overlay-child PRs after `pnpm fork:stack update`: rebase onto the fixed parent, then + - Feature PRs: rebase onto `fork/dev`, then let the automated agent ship gate validate the tip — a ready-PR push runs it, or publish with `pnpm pr:ready`. Only stack-layer rewrites (protected `fork/*` tips, not PR pushes) run the fuller per-layer manual gate below. @@ -163,8 +153,7 @@ Day-to-day ship path (compose, not restack): [docs/stack-ship-path.md](./docs/st fixed inside the related provenance/feature commit (or one product-named commit during rewrite), not as permanent tip patches. Same rule for CI format/typecheck recovery on **`fork/changes` and overlay tips**: amend/rewrite the offending commit when you have stack push bypass; do not leave - a forever-forward `style(docs):` / `fix(stack):` tip. Use - `node scripts/rebase-pr-stack.ts sync --verify-each-commit` so each replayed commit typechecks. + a forever-forward `style(docs):` / `fix(stack):` tip. See [docs/fork-stack.md](./docs/fork-stack.md) (“Commit-green during stack rewrite”, “Permanent draft PRs”) and [docs/stack-history-rewrite.md](./docs/stack-history-rewrite.md). - **Fork product changes need existence/behavior tests:** every user-visible or behavioral fork @@ -199,14 +188,13 @@ When implementation work for a user request is done (code, docs, config — not can’t be resolved. - **Same gate for overlay-child PRs.** Base = overlay does **not** relax it; the gate keys off the PR’s ready state, not its base. Compose success or draft-lock green is **not** the gate. - - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` to - rebase/retarget; the ensuing push runs the appropriate gate scope. + - Rebase onto `fork/dev` and force-with-lease; the ensuing push runs the appropriate gate scope. - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` - `baseRefName` must be `fork/changes` for ordinary features or the intended overlay/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; the + - If the PR is **open** → update that branch and push; the gate re-runs for that HEAD (static if draft, full if ready). - If the PR is **merged** or **closed** → do **not** keep committing on that branch. Start a new branch, re-apply unmerged work, and open a **new PR** against the same intended diff --git a/docs/client-overlays.md b/docs/client-overlays.md deleted file mode 100644 index c86b50ea253..00000000000 --- a/docs/client-overlays.md +++ /dev/null @@ -1,82 +0,0 @@ -# Client integration overlays - -> [!IMPORTANT] -> **Superseded. Do not follow this for new work.** -> -> Contributors branch from and target **`fork/dev`** — every kind of work, including Discord, VS Code, -> identity and desktop. The integration overlays are drained and deregistered, and `fork/changes` and -> `fork/integration` are frozen. See -> [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). -> -> Kept as a record of how the fork operated before 2026-08-06, and because the provenance stack -> (`main` → `fork/base` → `fork/tim` → `fork/candidates`) it describes is still current. - -Discord and VS Code are long-lived product integrations rather than anonymous files in -`fork/changes`. Their complete client implementations live in parallel draft PRs based on -`fork/changes` and are composed into `fork/integration` like the desktop-link overlay. - -Path ownership is recorded in -[`client-overlay-ownership.json`](../.github/client-overlay-ownership.json). Before choosing a base -branch, run: - -```sh -pnpm fork:overlay-owner [changed-path...] -``` - -- `fork/changes` means no extracted client owns the path. -- A PR number means start a child with - `pnpm fork:stack overlay-start ` and merge that child into the overlay. -- Overlay **child** PRs (base = the overlay branch) require the **same** local pre-push gate and - GitHub required checks (Check, Test, Mobile Native Static Analysis, Release Smoke) as PRs into - `fork/changes`. Do not merge on Compose / draft-lock green alone. Fork CI runs for those bases; - agents must still run `vp check` + full monorepo typecheck locally before ready handoff. -- `extraction pending` is used only during the reviewed cutover. Do not add new implementation to - `fork/changes`; finish or update the extraction first. - -Shared contracts and runtime behavior stay in `fork/changes` unless they exist solely for one -integration. A feature spanning shared code and an extracted client is split into two PRs: the -shared prerequisite targets `fork/changes`, and the client child targets its overlay. The client PR -may temporarily depend on the shared PR and is rebased once that prerequisite lands. - -The overlay PRs remain **draft** so they cannot be merged accidentally while still receiving normal -CI. Each permanent overlay draft **must** have the **`OVERLAY`** label. Register their real PR -numbers under `integrationOverlays` in `pr-stack.json` and replace temporary `null` ownership -entries as part of the final cutover. - -### Closed overlay PR recovery - -If a permanent overlay PR is closed by mistake: - -1. Fix the overlay **branch** (rebase onto current `fork/changes`, force-with-lease). -2. **`gh pr reopen `** — keep the same number; restore draft + **`OVERLAY`**. -3. Only if reopen is impossible: create a new draft PR for that branch, label **`OVERLAY`**, and - update `pr-stack.json` `integrationOverlays[].number` in the same change. - -Do not mint a replacement overlay PR as the default path. See -[fork-stack.md](./fork-stack.md) (“Permanent draft PRs — reopen first”). - -### Fixing overlay tips - -When the bug is on the overlay tip itself (reapply strip, typecheck, format), **amend or rewrite** -the commit that introduced it and force-with-lease the overlay branch. Prefer that over stacking -tip-only recovery commits. Feature work still uses child PRs that merge into the overlay. - -## Build and deployment ownership - -Each overlay owns the code and repository-local build metadata required to produce its client: - -- Discord owns `apps/discord-bot/**` and its operator-facing integration documentation. -- VS Code owns `apps/vscode/**` and the repository launch configuration in `.vscode/launch.json`. -- The shared lockfile retains the extracted clients' existing importer metadata so the parallel - overlays can compose without both rewriting the same file. Future dependency changes still - belong to the owning overlay and must pass the integration composition check. - -Cross-client classification remains shared in `scripts/classify-deployment-diff.sh`; it cannot live -in either client overlay because it decides between server, Discord, VS Code, mobile, and desktop. - -Fleet installation, credentials, systemd units, host names, and artifact distribution remain in the -private `aaaomega/ops` repository. In particular, `scripts/deploy-fork-integration.sh`, -`scripts/build-and-deploy-vscode.sh`, `scripts/publish-fork-workstation-artifacts.sh`, and the guest -Discord service configuration consume the tested, composed `fork/integration` tree. They are -deployment infrastructure, not public client implementation, and therefore are not duplicated into -the product overlays. diff --git a/docs/fork-base.md b/docs/fork-base.md index c0647104b5c..2152f6eb504 100644 --- a/docs/fork-base.md +++ b/docs/fork-base.md @@ -10,13 +10,13 @@ main → fork/base → fork/tim → fork/candidates → fork/changes → overlay **Only** repository adaptations for this fork — not Tim imports, not candidates, not product UI. -| Area | Examples | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Workflows | `fork-ci.yml`, `compose-integration.yml`, `managed-pr-draft-lock.yml`, `rebase-pr-stack.yml`, Blacksmith-free `ci.yml`, fork EAS/release tweaks; drop upstream `pr-vouch` / `pr-size` when unused | -| Stack manifests | `.github/pr-stack.json`, `client-overlay-ownership.json`, `upstream-candidates.json` | -| Stack tools | `scripts/fork-stack.ts`, `rebase-pr-stack.ts`, `compose-integration-overlays.ts`, `rebase-integration-overlays.ts`, `client-overlay-owner.ts`, `classify-deployment-diff.sh` | -| Agent / ops docs | `AGENTS.md`, `docs/fork-stack.md`, `docs/stack-ship-path.md`, `docs/stack-history-rewrite.md`, `docs/client-overlays.md`, this file | -| Root scripts | `package.json` `fork:*` entries | +| Area | Examples | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workflows | `fork-ci.yml`, `fork-release.yml`, `managed-pr-draft-lock.yml`, Blacksmith-free `ci.yml`, fork EAS/release tweaks; drop upstream `pr-vouch` / `pr-size` when unused | +| Stack manifests | `.github/pr-stack.json` (managed-PR allowlist only), `upstream-candidates.json` | +| Stack tools | `scripts/classify-deployment-diff.sh` | +| Agent / ops docs | `AGENTS.md`, `docs/stable-dev-release-branch-handover.md`, this file | +| Root scripts | `package.json` `fork:*` entries | ## What does **not** belong here diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 7161e85b371..906b582c903 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -11,8 +11,6 @@ > Kept as a record of how the fork operated before 2026-08-06, and because the provenance stack > (`main` → `fork/base` → `fork/tim` → `fork/candidates`) it describes is still current. -Day-to-day merge → compose → deploy: [stack-ship-path.md](./stack-ship-path.md). - This repository separates upstream history, downstream changes, temporary review branches, and the runnable build: diff --git a/docs/stable-dev-release-branch-handover.md b/docs/stable-dev-release-branch-handover.md index d0adcf2b7c1..70362a5e6ce 100644 --- a/docs/stable-dev-release-branch-handover.md +++ b/docs/stable-dev-release-branch-handover.md @@ -39,14 +39,14 @@ The intended outcome, all of which now holds: ## What Remains -| Work | State | -| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | -| PRs still based on `fork/changes` | #317, #226, #185 conflict on rebase; #237 and #238 live in an external fork and need their author | -| Retire `fork/changes` and `fork/integration` | blocked on the above | -| Overlay machinery (`compose-integration`, `rebase-integration-overlays`, `force-update-overlay-tip`, `client-overlay-ownership`) | still present and passing its tests with an empty manifest; removal is ~20 files and a separate decision | -| Automated provenance synchronization | manual, and fine at the current upstream cadence | -| Clean downstream projection | deferred indefinitely; nothing depends on it | -| Per-target release cadence | still immediate for everything | +| Work | State | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| PRs still based on `fork/changes` | #317, #226, #185 conflict on rebase; #237 and #238 live in an external fork and need their author | +| Retire `fork/changes` and `fork/integration` | blocked on the above | +| Overlay and stack machinery | **removed.** `.github/pr-stack.json` survives only as the managed-PR allowlist for the draft lock | +| Automated provenance synchronization | manual, and fine at the current upstream cadence | +| Clean downstream projection | deferred indefinitely; nothing depends on it | +| Per-target release cadence | still immediate for everything | The rebased provenance stack (`main → fork/base → fork/tim → fork/candidates`) works exactly as it did before and is unaffected by any of the above. diff --git a/docs/stack-history-rewrite.md b/docs/stack-history-rewrite.md index 26d7894cbe6..e1f9306974b 100644 --- a/docs/stack-history-rewrite.md +++ b/docs/stack-history-rewrite.md @@ -1,5 +1,12 @@ # Stack history rewrite (fold tip-only `fix(stack)` debt) +> [!IMPORTANT] +> **Superseded. Do not follow this for new work.** +> +> The stack tooling this describes was removed. Upstream is merged straight into `fork/dev`, which is +> the only contributor target. See +> [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). + Goal: **layer tips green** and **replayed commits green**, without permanent product `fix(stack): rejoin…` commits. diff --git a/docs/stack-ship-path.md b/docs/stack-ship-path.md deleted file mode 100644 index 2a05825a251..00000000000 --- a/docs/stack-ship-path.md +++ /dev/null @@ -1,370 +0,0 @@ -# Stack ship path (planned operating model) - -> [!IMPORTANT] -> **Superseded. Do not follow this for new work.** -> -> Contributors branch from and target **`fork/dev`** — every kind of work, including Discord, VS Code, -> identity and desktop. The integration overlays are drained and deregistered, and `fork/changes` and -> `fork/integration` are frozen. See -> [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). -> -> Kept as a record of how the fork operated before 2026-08-06, and because the provenance stack -> (`main` → `fork/base` → `fork/tim` → `fork/candidates`) it describes is still current. - -**Goal:** make a product change, merge it, get a green `fork/integration`, and deploy — **without** -waiting for a full upstream / Tim / candidates restack. - -**Nothing in the runnable stack is optional.** Every layer below is required for a complete -deploy tip. The only choice is _when_ you advance provenance layers (slow path), not whether -overlays or Tim/candidates “count.” - -```text -pingdotgg/t3code:main required mirror - └── fork/base required fork-only CI / repo plumbing - └── fork/tim required Tim Smart integrations - └── fork/candidates required selected open upstream PRs - └── fork/changes required shared downstream product - ├── ordinary feature PRs → merge into fork/changes - ├── registered overlays required client layers (parallel drafts) - │ (desktop, discord, vscode, … as listed in the manifest) - └── compose → fork/integration required runnable / deploy tip - = fork/changes + every registered overlay in order -``` - -| Layer | Required? | What “required” means | -| ------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `main` | **Yes** | Upstream mirror only. | -| `fork/base` | **Yes** | Fork-only repo plumbing (Fork CI, non-Blacksmith runners). Parent of Tim. | -| `fork/tim` / `fork/candidates` | **Yes** | Permanent provenance parents of product. Rebuild on the **slow path** only. | -| `fork/changes` | **Yes** | Shared product default branch. Ordinary features merge here. | -| **Every registered overlay** | **Yes** | If it is in `integrationOverlays`, it **must** be based on current `fork/changes` and included in every compose of `fork/integration`. Skipping an overlay is not a valid ship. | -| `fork/integration` | **Yes** | Only tip you run and deploy. Never a feature/import branch. | - -Overlays are **not** “nice-to-have clients.” They are long-lived product slices kept out of -`fork/changes` so shared history stays clean — but the **runnable product always includes them**. - -This document is the **planned** split between a **fast ship path** and a **slow layer-rebuild path**. -It supersedes the older assumption that every merge to `fork/changes` must run the full -`Rebase fork PR stack` mega-job. - -Related: [fork-stack.md](./fork-stack.md) (topology, overlays, conflict resolutions), -[stack-history-rewrite.md](./stack-history-rewrite.md) (history hygiene). - ---- - -## Policy in one line - -> **Merging to `fork/changes` and composing `fork/integration` must not require a successful full -> stack rewrite.** Full stack rewrite (main → base → tim → candidates → changes → rebase every -> overlay → compose) is a separate, stop-the-line operation. -> **Composing without every registered overlay rebased onto current `fork/changes` is incomplete.** - ---- - -## Where you branch (and a rejected alternative) - -### Current rule (keep) - -```text -all work → branch from fork/dev → PR base fork/dev -run / deploy → always fork/integration (compose) -``` - -Why this graph stays simple: - -- One merge target for shared product (`fork/changes`). -- Overlays stay **parallel** (not stacked on each other), rebased when `fork/changes` moves. -- Compose is a pure function: `changes + ordered overlay tips → integration`. -- Ordinary PRs do not encode multi-parent dependencies on desktop+discord+vscode. - -### Alternative that feels nicer (not adopted) - -**Branch off `fork/integration`**, develop against the full product tree, then somehow land the PR -“on top of changes → overlays (aka integration).” - -Why it is attractive: - -- Local and CI see Discord / desktop / VS Code + shared code without a separate compose step. -- Matches “the product is integration” intuition. - -Why we **do not** make this the default model: - -1. **Merge target ambiguity.** GitHub PRs have one base. Landing into “the full product” either - means merging into a permanent open overlay/integration PR forest, or inventing multi-base - merges. That is hard to automate and easy to get wrong. -2. **Dependency explosion.** A change that touches shared code _and_ two clients becomes “this PR - depends on two other open PRs.” Stacks of N open PRs with cross edges are confusing for humans - and agents, and `fork:stack update` / rebase automation gets brittle. -3. **Permanent open PR tax.** Integration-as-base works only if every overlay (and often - integration itself) stays a permanent open PR surface. That is already painful for overlays; - expanding it to every feature is worse. -4. **Compose already defines integration.** The shipable tree is reproducible from manifest - branches. Branching from a composed tip couples you to a generated history and invites - tip-only fixes on integration. - -If we ever revisit this, the design bar is: **one clear base per PR**, **no multi-parent feature -graphs**, and **integration remains compose-generated** (not a merge destination for ordinary -features). Until then: **branch from `fork/changes` (or the owning overlay), compose for the full -product.** - ---- - -## Two paths - -### Fast path — every day (features / fixes) - -Unblocks “I just want to ship.” Still ends with **full** integration (all required overlays). - -```text -pnpm fork:stack start my-fix # from fork/changes - → implement + local gates (vp check, typecheck, focused tests) - → PR → fork/changes → merge - → Compose fork integration workflow (on merge only, not on push): - 1. auto-rebase every registered overlay onto current fork/changes - 2. compose overlays onto fork/changes → push fork/integration - 3. dispatch Fork CI on that SHA - → smart poller deploys (if enabled) -``` - -**Does not** rebuild `main`, `fork/tim`, or `fork/candidates`. -**Does not** run on force-pushes / tip rebases of product layers (those are manual compose). -**Does not** auto-rebase the entire open _feature_ PR forest (only **registered overlays** on merge). -**Does not** wait for a mega restack job. -**Does** require clean overlay rebases — real product conflicts still fail the job (fix that overlay, re-run compose). - -Layer PR status is **Fork CI only** (Check / Test / Mobile / Release Smoke). Compose is not a -layer quality signal and must not be a required check on permanent `fork/*` drafts. - -#### Compose (integration) - -After a PR **merges** into `fork/changes` or a registered overlay base, **Compose fork integration** -runs. Direct pushes to those tips do **not** compose — use `workflow_dispatch` or local scripts. -Locally: - -```sh -# From a clean checkout of fork/changes (with push credentials for stack branches) -node scripts/rebase-integration-overlays.ts # no-op when already based -node scripts/compose-integration-overlays.ts --push -gh workflow run "Fork CI" --repo patroza/t3code --ref fork/integration -``` - -Or one shot: - -```sh -gh workflow run compose-integration.yml --repo patroza/t3code --ref fork/changes -``` - -Compose pipeline: - -1. **Auto-rebase overlays** (`scripts/rebase-integration-overlays.ts`): for each registered - overlay not based on current `fork/changes`, `git rebase --onto` using the merge-base with the - new tip, then force-with-lease push. Skips when already based. -2. **Compose** (`scripts/compose-integration-overlays.ts`): current `fork/changes` + every - overlay tip (manifest order) → `fork/integration` (lockfile regen as needed). -3. **Dispatch Fork CI** on the composed tip. - -If an overlay **conflicts** during auto-rebase, the job fails with the branch + paths. Fix that -overlay tip (or add a durable product merge), push it based on current `fork/changes`, then re-run -compose — do not leave the overlay stale and expect a partial ship. - -Do **not** rebuild Tim/candidates to fix one overlay. Do **not** ship integration with a missing -or stale registered overlay. - -#### Feature PR maintenance (handoff hygiene, not the ship gate) - -Keeping open _feature_ PRs rebased onto `fork/changes` is handoff work for that PR: - -```sh -pnpm fork:stack update --push # current branch / its PR -pnpm fork:stack update --push # explicit PR -pnpm fork:stack pull # after remote rewrote your branch -``` - -Global “rebase every open feature PR on every parent move” is **not** part of the fast path. -Keeping **registered overlays** on current `fork/changes` **is** part of the ship path whenever -you compose. - ---- - -### Slow path — when parents must move (planned) - -Runs when you **choose** to take new upstream, Tim imports, or candidate imports — not on every -product merge. - -**GitHub Actions:** the `Rebase fork PR stack` workflow stays **`disabled_manually`**. Do **not** -enable it and do **not** `gh workflow run rebase-pr-stack.yml`. Restacks are **operator/local only**: - -```sh -export GH_TOKEN="$(gh auth token)" -# from a checkout of fork/changes with write access to stack branches: -node scripts/rebase-pr-stack.ts sync --dry-run # inspect first -node scripts/rebase-pr-stack.ts sync --push # only when intentional -# or rebuild layers by hand, green gate each tip before the next child -``` - -```text -mirror main (exact pingdotgg/t3code:main) - → rebuild fork/base fork-only CI plumbing green on tip (stop the line) - → rebuild fork/tim full local CI green on tip (stop the line) - → rebuild fork/candidates full local CI green on tip - → rebuild fork/changes full local CI green on tip - → rebase each registered overlay onto new fork/changes (each required) - → compose fork/integration - → Fork CI + deploy -``` - -Rules: - -1. **One red layer blocks the next.** Never stack “green later.” Overlays and integration are - layers in that sense after `fork/changes` is green. -2. Prefer **compose** for `fork/integration` after rewrites; do not rebase an old integration tip - onto rewritten history. -3. Record durable `conflictResolutions` in `.github/pr-stack.json` when the same path always takes - the same side — but **never** whole-file `ours`/`theirs` on shared product paths - (`ChatView`, VCS drivers, contracts RPC, etc.). Those need a real 3-way product merge. -4. Product-facing recovery belongs in product-named commits, not permanent tip-only `fix(stack)`. -5. Before green-gating Tim or candidates, rewrite every new or changed non-upstream migration into - the fork migration manifest and ledger. Preserve upstream migration IDs/names exactly; see - [fork-stack.md](./fork-stack.md#migration-namespaces-during-provenance-imports). - -While a slow path is in flight, the **default** is still: do not block unrelated product PRs unless -you intentionally freeze merges for a cutover window. - ---- - -## Current automation state - -| Job | Intended role | Status | -| ---------------------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Compose fork integration** | Integration tip from changes + **all** overlays | **Active** — merge into `fork/changes` / overlay bases, or `workflow_dispatch`. **No push trigger.** Not a required layer check. | -| **Fork CI** | Green gate on product PR tips / composed integration | **Active** — only quality signal for permanent layer drafts (incl. **tim** / **candidates** via dispatch or PR base) | -| **Upstream CI** (`ci.yml`) | Upstream Blacksmith runners | **`disabled_manually` — leave it that way.** No Blacksmith on this fork; enabling it leaves Tim/candidates checks queued forever. | -| **Rebase fork PR stack** | Full layer rebuild + PR cascade | **`disabled_manually` in GitHub Actions — leave it that way.** Do **not** enable or dispatch this workflow. Slow-path restacks are **local only** (see slow path section). | -| **Smart integration poller** | Deploy CI-approved integration SHA | On when fleet should track green integration | - -**Do not re-enable `Rebase fork PR stack`.** Operators who need a full upstream / Tim / candidates -rewrite run `node scripts/rebase-pr-stack.ts sync --push` (or equivalent) **locally** with appropriate -credentials, layer by layer, green gates first. GitHub Actions must not auto-restack the provenance -stack on pushes to `main` / `fork/tim` / `fork/candidates`. - ---- - -## Overlays (required client layers) - -Registered in `.github/pr-stack.json` → `integrationOverlays`. - -| Rule | Detail | -| ------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| Required for integration? | **Yes** — every registered overlay must be rebased onto current `fork/changes` and composed | -| Base | Always current `fork/changes` (never based on each other) | -| Source of truth | **Branch tip** in the manifest; draft PR is for review/tracking | -| Labels | **`OVERLAY` required** on every permanent draft overlay PR | -| Draft | Draft = “do not merge into `fork/changes`”; health CI can still be green | -| Closed by mistake | **Fix branch → reopen same PR** first; new PR only if reopen fails (then label OVERLAY + update `pr-stack.json` number) | -| Tip repairs | **Amend / rewrite** the bad commit on the overlay tip when stack bypass is available; avoid tip-only recovery noise | -| Ship impact | Behind overlay ⇒ rebase that overlay + compose; do not restack Tim; do not skip the overlay | - -**Closed permanent drafts:** agents must **not** default to “open PR #N+1”. Reopen the registered -number after the branch tip is healthy. New overlay PRs require **`OVERLAY`** and a same-change -manifest number update. Full procedure: [fork-stack.md](./fork-stack.md) (“Permanent draft PRs”). - -**Planned automation fix:** compose/stack tooling should key off **branch names + label**, not -“PR must be open.” Closed overlay PRs must not brick the ship path. Until tooling is fixed, keep -registered overlay PRs **open** (draft) so validation does not fail mid-ship. - ---- - -## Layer tip repairs (prefer amend) - -When fixing **`fork/changes`** or a **registered overlay** tip yourself (not via a normal feature -PR merge): - -| Do | Don't | -| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | -| Amend or fold into the commit that introduced the bug | Stack forever-forward `style:` / `fix(stack):` tip commits when rewrite is allowed | -| Force-with-lease the layer; rebase dependents | Leave format/typecheck debt for “CI will catch it” | -| Use a child PR when the work is a real feature | Rewrite someone else's open feature PR tip without coordination | - -Feature landings still use new commits and merges. Amend is for **operator/agent maintenance** of -shared stack tips. See [fork-stack.md](./fork-stack.md) (“Fixing layer tips — prefer amend”). - -## Lockfiles (no tip-only product lock debt) - -| Layer | Rule | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Feature PR / commit on `fork/changes` | If any workspace `package.json` changes, the **same** commit/PR updates `pnpm-lock.yaml` (`CI= pnpm install`). Frozen install must pass. | -| Tim / candidates replay | Commits that change manifests regenerate lock **in that commit** during a planned rebuild. | -| Overlay tips | Self-consistent for that overlay’s packages; lock-only commits may diverge by design. | -| Integration compose | Compose skips lock-only overlay commits and may commit **one** generated `chore(integration): regenerate pnpm-lock.yaml…` as a **compose artifact**, not as product history. | - -Tip-only lock fixes on **product** layers are process failures. Generated integration lock after multi-overlay compose is acceptable when product and overlay PRs were already self-consistent. - ---- - -## Day-to-day checklist (agents and humans) - -### Ship a fix/feature - -1. `pnpm fork:stack start ` from up-to-date `fork/changes` (not from integration). -2. Implement; run focused tests + package typecheck while iterating. -3. Before ready handoff: root `vp check` + full monorepo typecheck (see AGENTS.md). -4. Open/update PR against **`fork/changes` only** (never `main`, never `fork/integration` as merge base for ordinary features). -5. Merge when green. -6. Rebase **every** registered overlay onto the new `fork/changes` tip if needed. -7. Compose integration + dispatch Fork CI (or rely on compose-on-merge when enabled). -8. Confirm poller/deploy only if runtime-affecting and CI succeeded. - -### Overlay-only change - -1. Work on the overlay branch via a **child PR targeting the overlay** (preferred), or on the - overlay tip only when using an intentional bypass actor for stack maintenance. -2. Do **not** duplicate the change into `fork/changes`. -3. **Same local gate as any feature PR** before ready/merge (root `vp check` + full monorepo - typecheck + focused tests). Overlay-child bases are protected with the same required Fork CI - checks as `fork/changes`. “Compose only ran” or “draft permanent overlay PR is green” does - **not** mean the child tip was checked. -4. Rebase onto latest `fork/changes` if needed; merge only when Check/Test are green; compose - **full** integration (all overlays); confirm integration CI. - -### Taking new upstream / Tim / candidates - -1. Schedule a **slow path** rebuild; do not mix with unrelated feature landings if avoidable. -2. Stop the line per layer; product 3-way merges for conflicts (no blind whole-file product - `ours`/`theirs`). -3. Audit migration diffs in every imported/replayed commit. Rewrite Tim/candidate/overlay migrations - into `ForkMigrations.ts`; upstream-only migrations remain unchanged in `Migrations.ts`. -4. Rebase **all** registered overlays; compose; CI; then resume normal fast path. - ---- - -## Success criteria - -The stack model is “good enough” when: - -1. A normal product PR can merge to `fork/changes` and reach green `fork/integration` **the same day** - without running a full main→tim→candidates rewrite. -2. That integration tip always includes **every** registered overlay on current `fork/changes`. -3. A full restack is rare, deliberate, and fully green per layer (including each overlay tip, then - composed integration) before the next layer advances. -4. One closed overlay PR or one conflicted feature PR cannot block unrelated product deploys once - tooling keys off branches (until then: keep overlay drafts open). -5. `fork/tim` and `fork/candidates` remain permanent required parents of product — updated on the - slow path only. -6. We do **not** force ordinary features to branch from integration or encode multi-overlay PR - dependencies. - ---- - -## Implementation backlog (remaining) - -Compose-on-merge is **landed**. Remaining improvements: - -1. Keep **Rebase fork PR stack** **`disabled_manually` forever for automation.** Prefer local - `node scripts/rebase-pr-stack.ts …` for slow-path rewrites. Do not re-enable the workflow for - schedule/push, and do not treat `workflow_dispatch` as the default agent path. -2. **Overlay validation** in compose/stack scripts: branch existence + OVERLAY label; - do not require `state=open` as a hard gate for compose. -3. **Conflict resolution policy**: forbid durable whole-file product path strategies in - `conflictResolutions` for shared app/package sources (warn → error over time). -4. Optional later: local tooling helpers for per-layer rebuild (`tim` / `candidates` / `changes`) - with stop-the-line gates — still not a GitHub Actions mega-restack. diff --git a/package.json b/package.json index 0e648c1b654..1beac94e8d2 100644 --- a/package.json +++ b/package.json @@ -45,11 +45,7 @@ "release:smoke": "node scripts/release-smoke.ts", "connect:announce-ga": "node scripts/announce-connect-ga.ts", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .vite-plus apps/*/.vite-plus packages/*/.vite-plus", - "sync:repos": "node scripts/sync-reference-repos.ts", - "fork:stack": "node scripts/fork-stack.ts", - "fork:overlay-owner": "node scripts/client-overlay-owner.ts", - "fork:stack:sync": "node scripts/rebase-pr-stack.ts sync --dry-run", - "fork:rebase-overlays": "node scripts/rebase-integration-overlays.ts" + "sync:repos": "node scripts/sync-reference-repos.ts" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "7.28.6", diff --git a/scripts/client-overlay-owner.test.ts b/scripts/client-overlay-owner.test.ts deleted file mode 100644 index 8b59ff85678..00000000000 --- a/scripts/client-overlay-owner.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - ownersForPaths, - pathMatchesOwnershipPattern, - type ClientOverlayOwnership, -} from "./client-overlay-owner.ts"; - -const overlays: ReadonlyArray = [ - { - id: "discord", - branch: "fork/discord", - pullRequest: null, - paths: ["apps/discord-bot/**", "docs/integrations/discord-bot.md"], - }, - { - id: "vscode", - branch: "fork/vscode", - pullRequest: 99, - paths: ["apps/vscode/**"], - }, -]; - -describe("client overlay ownership", () => { - it("matches exact files and recursive directory patterns", () => { - expect(pathMatchesOwnershipPattern("apps/discord-bot/src/main.ts", "apps/discord-bot/**")).toBe( - true, - ); - expect( - pathMatchesOwnershipPattern( - "docs/integrations/discord-bot.md", - "docs/integrations/discord-bot.md", - ), - ).toBe(true); - expect(pathMatchesOwnershipPattern("apps/discord/src/main.ts", "apps/discord-bot/**")).toBe( - false, - ); - }); - - it("finds every overlay touched by a mixed change", () => { - expect( - ownersForPaths(overlays, [ - "packages/contracts/src/orchestration.ts", - "apps/discord-bot/src/main.ts", - "apps/vscode/src/extension.ts", - ]).map((owner) => owner.id), - ).toEqual(["discord", "vscode"]); - }); -}); diff --git a/scripts/client-overlay-owner.ts b/scripts/client-overlay-owner.ts deleted file mode 100644 index 912f8c2a841..00000000000 --- a/scripts/client-overlay-owner.ts +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalConsole:off - -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -export interface ClientOverlayOwnership { - readonly id: string; - readonly branch: string; - readonly pullRequest: number | null; - readonly paths: ReadonlyArray; -} - -interface ClientOverlayOwnershipManifest { - readonly overlays: ReadonlyArray; -} - -function normalizePath(value: string): string { - return value.replaceAll("\\", "/").replace(/^\.\/+/, ""); -} - -export function pathMatchesOwnershipPattern(path: string, pattern: string): boolean { - const normalizedPath = normalizePath(path); - const normalizedPattern = normalizePath(pattern); - if (normalizedPattern.endsWith("/**")) { - return normalizedPath.startsWith(normalizedPattern.slice(0, -2)); - } - return normalizedPath === normalizedPattern; -} - -export function ownersForPaths( - overlays: ReadonlyArray, - paths: ReadonlyArray, -): ReadonlyArray { - return overlays.filter((overlay) => - paths.some((path) => - overlay.paths.some((pattern) => pathMatchesOwnershipPattern(path, pattern)), - ), - ); -} - -export function readClientOverlayOwnership(sourceRoot: string): ClientOverlayOwnershipManifest { - const path = NodePath.join(sourceRoot, ".github", "client-overlay-ownership.json"); - return JSON.parse(NodeFS.readFileSync(path, "utf8")) as ClientOverlayOwnershipManifest; -} - -function main(args: ReadonlyArray): void { - if (args.length === 0) { - throw new Error("Usage: pnpm fork:overlay-owner [path...]"); - } - const sourceRoot = NodePath.resolve( - NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), - "..", - ); - const owners = ownersForPaths(readClientOverlayOwnership(sourceRoot).overlays, args); - if (owners.length === 0) { - console.log("fork/changes"); - return; - } - for (const owner of owners) { - if (owner.pullRequest === null) { - console.log(`${owner.id}: ${owner.branch} (extraction pending)`); - } else { - console.log( - `${owner.id}: PR #${owner.pullRequest} (${owner.branch}); start changes with ` + - `pnpm fork:stack overlay-start ${owner.pullRequest} `, - ); - } - } -} - -if (process.argv[1] && import.meta.url === NodeURL.pathToFileURL(process.argv[1]).href) { - main(process.argv.slice(2)); -} diff --git a/scripts/compose-integration-overlays.test.ts b/scripts/compose-integration-overlays.test.ts deleted file mode 100644 index f0d74273151..00000000000 --- a/scripts/compose-integration-overlays.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { overlayCommitList } from "./compose-integration-overlays.ts"; - -describe("integration overlay composition", () => { - it("keeps overlay commits in oldest-first rev-list order", () => { - expect(overlayCommitList("oldest\nmiddle\nnewest\n")).toEqual(["oldest", "middle", "newest"]); - }); - - it("handles an empty rev-list", () => { - expect(overlayCommitList("")).toEqual([]); - }); -}); diff --git a/scripts/compose-integration-overlays.ts b/scripts/compose-integration-overlays.ts deleted file mode 100644 index abe5fe68858..00000000000 --- a/scripts/compose-integration-overlays.ts +++ /dev/null @@ -1,383 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalConsole:off - -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import { readManifest, StackError } from "./rebase-pr-stack.ts"; - -function run( - command: string, - args: ReadonlyArray, - cwd: string, - options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv; stdioInherit?: boolean } = {}, -): { status: number | null; stdout: string; stderr: string } { - const result = NodeChildProcess.spawnSync(command, [...args], { - cwd, - encoding: "utf8", - stdio: options.stdioInherit ? "inherit" : "pipe", - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_EDITOR: "true", - ...options.env, - }, - }); - const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - if (!options.allowFailure && result.status !== 0) { - throw new StackError( - `${command} ${args.join(" ")} failed: ${stderr || stdout || `exit ${result.status}`}`, - ); - } - return { status: result.status, stdout, stderr }; -} - -function git( - cwd: string, - args: ReadonlyArray, - options: { allowFailure?: boolean } = {}, -): string { - return run("git", args, cwd, options).stdout; -} - -export function overlayCommitList(revListOutput: string): ReadonlyArray { - return revListOutput - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); -} - -export function isLockfileOnlyCommit(paths: ReadonlyArray): boolean { - return paths.length > 0 && paths.every((path) => path === "pnpm-lock.yaml"); -} - -/** Drop proxy vars so install hits the registry directly (agent sessions may inherit SOCKS). */ -export function envWithoutProxy(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { ...base, CI: "" }; - for (const key of Object.keys(env)) { - if (/^(https?|all|no)_?proxy$/i.test(key)) { - delete env[key]; - } - } - return env; -} - -/** - * Prefer a work directory on the same filesystem as warm `node_modules` so - * `cp --reflink=auto` can clone CoW extents (btrfs/xfs). `/tmp` is often tmpfs — - * never use it when a home-side cache dir exists. - */ -export function composeWorkRoot(sourceRoot: string): string { - const fromEnv = process.env.COMPOSE_WORK_ROOT?.trim(); - if (fromEnv) { - NodeFS.mkdirSync(fromEnv, { recursive: true }); - return fromEnv; - } - const home = process.env.HOME?.trim(); - if (home) { - const preferred = NodePath.join(home, ".t3", "compose-work"); - try { - NodeFS.mkdirSync(preferred, { recursive: true }); - return preferred; - } catch { - // fall through - } - } - const sourceParent = NodePath.dirname(NodePath.resolve(sourceRoot)); - try { - NodeFS.accessSync(sourceParent, NodeFS.constants.W_OK); - return sourceParent; - } catch { - return NodeOS.tmpdir(); - } -} - -export function candidateNodeModulesDirs(sourceRoot: string): ReadonlyArray { - const fromEnv = process.env.COMPOSE_NODE_MODULES_SOURCE?.trim(); - const candidates = [ - ...(fromEnv ? [fromEnv] : []), - NodePath.join(sourceRoot, "node_modules"), - NodePath.join(NodePath.resolve(sourceRoot, ".."), "node_modules"), - NodePath.join(NodeOS.homedir(), "pj", "t3code", "node_modules"), - NodePath.join(NodeOS.homedir(), "deploy", "t3code", "node_modules"), - ]; - return candidates.filter((dir, index) => candidates.indexOf(dir) === index); -} - -/** - * Seed `repoDir/node_modules` from a warm tree via `cp -a --reflink=auto` - * (btrfs/xfs CoW when same FS; falls back to full copy). - */ -export function seedNodeModules(repoDir: string, sourceRoot: string): string | undefined { - const dest = NodePath.join(repoDir, "node_modules"); - if (NodeFS.existsSync(dest)) return dest; - for (const source of candidateNodeModulesDirs(sourceRoot)) { - if (!NodeFS.existsSync(source) || !NodeFS.statSync(source).isDirectory()) continue; - console.log(`Seeding node_modules from ${source} (cp -a --reflink=auto)…`); - // performance.now is wall-clock-safe for duration logs; avoid Date.now (globalDate). - const started = performance.now(); - const result = run("cp", ["-a", "--reflink=auto", source, dest], repoDir, { - allowFailure: true, - }); - if (result.status === 0 && NodeFS.existsSync(dest)) { - console.log(`Seeded node_modules in ${((performance.now() - started) / 1000).toFixed(1)}s`); - return dest; - } - console.warn( - `Reflink/copy from ${source} failed (${result.stderr || result.stdout || `exit ${result.status}`}); trying next candidate.`, - ); - try { - NodeFS.rmSync(dest, { recursive: true, force: true }); - } catch { - // ignore - } - } - console.warn("No warm node_modules seed available; pnpm install will be cold."); - return undefined; -} - -function commitPaths(repoDir: string, commit: string): ReadonlyArray { - return git(repoDir, ["diff-tree", "--no-commit-id", "--name-only", "-r", commit]) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); -} - -function conflictingPaths(repoDir: string): ReadonlyArray { - return git(repoDir, ["diff", "--name-only", "--diff-filter=U"]) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); -} - -function cherryPickInProgress(repoDir: string): boolean { - return ( - NodeFS.existsSync(NodePath.join(repoDir, ".git", "CHERRY_PICK_HEAD")) || - NodeFS.existsSync(NodePath.join(repoDir, ".git", "sequencer", "todo")) - ); -} - -/** - * Cherry-pick overlay commits onto the integration base. - * Lockfile-only commits are skipped (combined tree is regenerated after compose). - * If a mixed commit conflicts only on `pnpm-lock.yaml`, keep the current lock and continue. - */ -export function cherryPickOverlayCommits( - repoDir: string, - commits: ReadonlyArray, -): { skippedLockfileOnly: number; deferredLockfileConflicts: number } { - let skippedLockfileOnly = 0; - let deferredLockfileConflicts = 0; - for (const commit of commits) { - const paths = commitPaths(repoDir, commit); - if (isLockfileOnlyCommit(paths)) { - console.log(`Skipping lockfile-only overlay commit ${commit.slice(0, 12)}`); - skippedLockfileOnly += 1; - continue; - } - const result = run("git", ["-c", "commit.gpgsign=false", "cherry-pick", commit], repoDir, { - allowFailure: true, - }); - if (result.status === 0) continue; - if (!cherryPickInProgress(repoDir)) { - throw new StackError( - `git cherry-pick ${commit.slice(0, 12)} failed: ${result.stderr || result.stdout}`, - ); - } - const conflicts = conflictingPaths(repoDir); - if (conflicts.length === 1 && conflicts[0] === "pnpm-lock.yaml") { - git(repoDir, ["checkout", "--ours", "--", "pnpm-lock.yaml"]); - git(repoDir, ["add", "--", "pnpm-lock.yaml"]); - const cont = run( - "git", - ["-c", "commit.gpgsign=false", "cherry-pick", "--continue"], - repoDir, - { allowFailure: true }, - ); - if (cont.status !== 0 && cherryPickInProgress(repoDir)) { - throw new StackError( - `Could not continue cherry-pick after deferring lockfile for ${commit.slice(0, 12)}: ${cont.stderr || cont.stdout}`, - ); - } - console.log( - `Deferred pnpm-lock.yaml conflict for ${commit.slice(0, 12)} (will regenerate after compose)`, - ); - deferredLockfileConflicts += 1; - continue; - } - throw new StackError( - `Overlay cherry-pick conflict on ${commit.slice(0, 12)}: ${conflicts.join(", ") || "(unknown paths)"}. ` + - `Record a durable resolution policy if this is a known product conflict, or fix the overlay tip.`, - ); - } - return { skippedLockfileOnly, deferredLockfileConflicts }; -} - -function resolvePnpmExecutable(repoDir: string): string { - const which = run("bash", ["-lc", "command -v pnpm || true"], repoDir, { - allowFailure: true, - env: envWithoutProxy(), - }); - if (which.stdout) return which.stdout.split("\n")[0]!.trim(); - // Stack workflow only sets up Node; enable packageManager from package.json via corepack. - run("corepack", ["enable"], repoDir, { allowFailure: true, env: envWithoutProxy() }); - const prepared = run( - "bash", - [ - "-lc", - `corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && command -v pnpm`, - ], - repoDir, - { allowFailure: true, env: envWithoutProxy() }, - ); - if (prepared.status === 0 && prepared.stdout) { - return prepared.stdout.split("\n").filter(Boolean).at(-1)!.trim(); - } - throw new StackError( - "pnpm is not available for lockfile regeneration (install pnpm or enable corepack).", - ); -} - -function regenerateIntegrationLockfile(repoDir: string, sourceRoot: string): boolean { - seedNodeModules(repoDir, sourceRoot); - console.log("Regenerating pnpm-lock.yaml for composed integration tree…"); - const pnpm = resolvePnpmExecutable(repoDir); - const install = run(pnpm, ["install", "--no-frozen-lockfile", "--prefer-offline"], repoDir, { - allowFailure: true, - env: envWithoutProxy(), - }); - if (install.status !== 0) { - throw new StackError( - `pnpm install --no-frozen-lockfile failed after overlay compose (exit ${install.status}): ${install.stderr || install.stdout}`, - ); - } - // A warm node_modules seed can make pnpm's first install preserve stale, - // unused snapshots even though every manifest is satisfied. Run a lock-only - // canonicalization pass so the committed artifact is also a fixed point for - // later pnpm commands in clean deployment checkouts. - const canonicalize = run( - pnpm, - ["install", "--lockfile-only", "--no-frozen-lockfile", "--prefer-offline"], - repoDir, - { - allowFailure: true, - env: envWithoutProxy(), - }, - ); - if (canonicalize.status !== 0) { - throw new StackError( - `pnpm lock-only canonicalization failed after overlay compose (exit ${canonicalize.status}): ${canonicalize.stderr || canonicalize.stdout}`, - ); - } - const dirty = run("git", ["status", "--porcelain", "--", "pnpm-lock.yaml"], repoDir, { - allowFailure: true, - }).stdout; - if (!dirty) { - console.log("pnpm-lock.yaml already matched the composed tree."); - return false; - } - git(repoDir, ["add", "--", "pnpm-lock.yaml"]); - git(repoDir, [ - "-c", - "commit.gpgsign=false", - "commit", - "-m", - "chore(integration): regenerate pnpm-lock.yaml after overlay compose", - ]); - console.log("Committed regenerated integration lockfile."); - return true; -} - -export function composeIntegration(sourceRoot = process.cwd(), push = true): string { - const manifest = readManifest(sourceRoot); - const originUrl = git(sourceRoot, ["remote", "get-url", "origin"]); - const workRoot = composeWorkRoot(sourceRoot); - const workDir = NodeFS.mkdtempSync(NodePath.join(workRoot, "compose-overlays-")); - const repoDir = NodePath.join(workDir, "repo"); - NodeFS.mkdirSync(repoDir); - console.log(`Compose work dir: ${workDir}`); - try { - git(repoDir, ["init", "--quiet"]); - git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); - git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); - git(repoDir, ["config", "commit.gpgsign", "false"]); - git(repoDir, ["remote", "add", "origin", originUrl]); - const branches = [ - manifest.forkChangesBranch, - manifest.integrationBranch, - ...manifest.integrationOverlays.map(({ branch }) => branch), - ]; - git(repoDir, [ - "fetch", - "--quiet", - "--no-tags", - "origin", - ...branches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), - ]); - const base = git(repoDir, ["rev-parse", `origin/${manifest.forkChangesBranch}`]); - const previous = git(repoDir, ["rev-parse", `origin/${manifest.integrationBranch}`]); - git(repoDir, ["checkout", "--quiet", "--detach", base]); - let needsLockfileRegen = false; - for (const overlay of manifest.integrationOverlays) { - const tip = git(repoDir, ["rev-parse", `origin/${overlay.branch}`]); - const ancestor = NodeChildProcess.spawnSync( - "git", - ["merge-base", "--is-ancestor", base, tip], - { cwd: repoDir, encoding: "utf8" }, - ); - if (ancestor.status !== 0) { - throw new StackError( - `Overlay PR #${overlay.number} (${overlay.branch}) is not based on current ${manifest.forkChangesBranch}.`, - ); - } - const commits = overlayCommitList( - git(repoDir, ["rev-list", "--reverse", "--no-merges", `${base}..${tip}`]), - ); - if (commits.length === 0) { - throw new StackError( - `Overlay PR #${overlay.number} has no commits above ${manifest.forkChangesBranch}.`, - ); - } - const result = cherryPickOverlayCommits(repoDir, commits); - if (result.skippedLockfileOnly > 0 || result.deferredLockfileConflicts > 0) { - needsLockfileRegen = true; - } - } - // Always regenerate when overlays land packages: product trees must match frozen CI. - if (needsLockfileRegen || manifest.integrationOverlays.length > 0) { - regenerateIntegrationLockfile(repoDir, sourceRoot); - } - const next = git(repoDir, ["rev-parse", "HEAD"]); - if (push && next !== previous) { - git(repoDir, [ - "push", - `--force-with-lease=refs/heads/${manifest.integrationBranch}:${previous}`, - "origin", - `${next}:refs/heads/${manifest.integrationBranch}`, - ]); - } - return next; - } finally { - NodeFS.rmSync(workDir, { recursive: true, force: true }); - } -} - -const isMain = - process.argv[1] !== undefined && - import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; - -if (isMain) { - const push = !process.argv.includes("--dry-run"); - try { - const tip = composeIntegration(process.cwd(), push); - console.log(`${push ? "Updated" : "Would update"} integration to ${tip}.`); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} diff --git a/scripts/fork-stack.test.ts b/scripts/fork-stack.test.ts deleted file mode 100644 index 0515c4f9894..00000000000 --- a/scripts/fork-stack.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - appendBaseHistory, - parseBaseHistory, - parseManifest, - recoverOldBaseTip, - selectOpenFeaturePullRequests, - StackError, - type StackManifest, -} from "./rebase-pr-stack.ts"; -import { - featurePullRequestBaseBranch, - planFeatureBranchUpdate, - planLocalSyncWithRemote, - registerPullRequest, - registerIntegrationOverlay, - resolveFeaturePullRequestBaseBranch, - shouldRetargetPullRequestBase, - stackParentBranch, - uniqueLocalCommitsFromCherry, - unregisterTopPullRequest, - unregisterIntegrationOverlay, -} from "./fork-stack.ts"; - -const manifest: StackManifest = { - upstreamRemote: "upstream", - upstreamBranch: "main", - forkChangesBranch: "fork/changes", - integrationBranch: "fork/integration", - pullRequests: [], - integrationOverlays: [], -}; - -describe("fork stack helpers", () => { - it("accepts an empty manifest before the one-time cutover", () => { - expect(parseManifest(JSON.stringify(manifest))).toEqual(manifest); - expect(stackParentBranch(manifest)).toBe("fork/changes"); - }); - - it("targets ordinary feature PRs at fork/changes", () => { - expect(featurePullRequestBaseBranch(manifest)).toBe("fork/changes"); - expect(shouldRetargetPullRequestBase("main", "fork/changes")).toBe(true); - 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({ - newBaseIsAncestorOfHead: true, - behindCount: 3, - recoveredOldBaseOid: null, - }), - ).toEqual({ action: "rebase", oldBaseOid: null }); - }); - - it("is a noop when already up to date with the base tip", () => { - expect( - planFeatureBranchUpdate({ - newBaseIsAncestorOfHead: true, - behindCount: 0, - recoveredOldBaseOid: null, - }), - ).toEqual({ action: "noop", oldBaseOid: null }); - }); - - it("plans rebase --onto when the old base tip is recovered after a rewrite", () => { - expect( - planFeatureBranchUpdate({ - newBaseIsAncestorOfHead: false, - behindCount: 50, - recoveredOldBaseOid: "oldbase123", - }), - ).toEqual({ action: "rebase-onto", oldBaseOid: "oldbase123" }); - }); - - it("throws when diverged and no old base tip can be recovered", () => { - expect(() => - planFeatureBranchUpdate({ - newBaseIsAncestorOfHead: false, - behindCount: 10, - recoveredOldBaseOid: null, - }), - ).toThrow(StackError); - }); - - 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("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", () => { - expect( - planLocalSyncWithRemote({ - uniqueLocalCommitOids: [], - remoteTipExists: true, - }), - ).toEqual({ action: "reset-to-remote", uniqueLocalCommitOids: [] }); - }); - - it("rebases unique local patches onto a force-pushed remote", () => { - expect( - planLocalSyncWithRemote({ - uniqueLocalCommitOids: ["local-only"], - remoteTipExists: true, - }), - ).toEqual({ - action: "rebase-onto-remote", - uniqueLocalCommitOids: ["local-only"], - }); - }); - - it("parses git cherry output for unique local commits", () => { - expect( - uniqueLocalCommitsFromCherry(`+ abc123 -- def456 -+ ghi789 -`), - ).toEqual(["abc123", "ghi789"]); - }); - - it("selects only open feature PRs targeting fork/changes", () => { - const withStack: StackManifest = { - ...manifest, - pullRequests: [ - { number: 1, branch: "fork/tim" }, - { number: 27, branch: "fork/candidates" }, - { number: 2, branch: "fork/changes" }, - ], - }; - expect( - selectOpenFeaturePullRequests({ - openPulls: [ - { - number: 41, - headBranch: "draft/restore-external-session-import", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 2, - headBranch: "fork/changes", - baseBranch: "fork/candidates", - headRepository: "patroza/t3code", - }, - { - number: 10, - headBranch: "t3-discord/f7d37879-desktop-deeplinks", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 99, - headBranch: "someone/else", - baseBranch: "fork/changes", - headRepository: "other/t3code", - }, - ], - manifest: withStack, - expectedRepository: "patroza/t3code", - }), - ).toEqual([ - { number: 41, branch: "draft/restore-external-session-import" }, - { number: 10, branch: "t3-discord/f7d37879-desktop-deeplinks" }, - ]); - }); - - it("registers the permanent fork changes PR first", () => { - const next = registerPullRequest(manifest, { - number: 201, - state: "OPEN", - headRefName: "fork/changes", - baseRefName: "main", - }); - expect(next.pullRequests).toEqual([{ number: 201, branch: "fork/changes" }]); - expect(stackParentBranch(next)).toBe("fork/changes"); - }); - - it("registers a clean dependent PR against the current top", () => { - const withForkChanges: StackManifest = { - ...manifest, - pullRequests: [{ number: 201, branch: "fork/changes" }], - }; - const next = registerPullRequest(withForkChanges, { - number: 202, - state: "OPEN", - headRefName: "import/tim-2026-07-24", - baseRefName: "fork/changes", - }); - expect(next.pullRequests.at(-1)).toEqual({ - number: 202, - branch: "import/tim-2026-07-24", - }); - }); - - it("rejects a first PR that is not the fork changes branch", () => { - expect(() => - registerPullRequest(manifest, { - number: 202, - state: "OPEN", - headRefName: "feature/wrong", - baseRefName: "main", - }), - ).toThrow(StackError); - }); - - it("rejects a PR based on the wrong parent", () => { - const withForkChanges: StackManifest = { - ...manifest, - pullRequests: [{ number: 201, branch: "fork/changes" }], - }; - expect(() => - registerPullRequest(withForkChanges, { - number: 202, - state: "OPEN", - headRefName: "feature/new", - baseRefName: "main", - }), - ).toThrow(/expected fork\/changes/); - }); - - it("only unregisters the top PR", () => { - const stacked: StackManifest = { - ...manifest, - pullRequests: [ - { number: 201, branch: "fork/changes" }, - { number: 202, branch: "feature/new" }, - ], - }; - expect(unregisterTopPullRequest(stacked, 202).pullRequests).toEqual([ - { number: 201, branch: "fork/changes" }, - ]); - expect(() => unregisterTopPullRequest(stacked, 201)).toThrow(/Only the top PR/); - }); - - it("registers only draft overlays based on fork/changes", () => { - const next = registerIntegrationOverlay(manifest, { - number: 10, - state: "OPEN", - headRefName: "feature/deep-links", - baseRefName: "fork/changes", - isDraft: true, - }); - expect(next.integrationOverlays).toEqual([{ number: 10, branch: "feature/deep-links" }]); - expect(() => - registerIntegrationOverlay(manifest, { - number: 11, - state: "OPEN", - headRefName: "feature/ready", - baseRefName: "fork/changes", - isDraft: false, - }), - ).toThrow(/must be a draft/); - expect(() => - registerIntegrationOverlay(manifest, { - number: 12, - state: "OPEN", - headRefName: "feature/wrong-base", - baseRefName: "main", - isDraft: true, - }), - ).toThrow(/expected fork\/changes/); - expect(unregisterIntegrationOverlay(next, 10).integrationOverlays).toEqual([]); - }); -}); diff --git a/scripts/fork-stack.ts b/scripts/fork-stack.ts deleted file mode 100755 index 7a8eeb76f17..00000000000 --- a/scripts/fork-stack.ts +++ /dev/null @@ -1,1046 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalConsole:off - -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -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 { - readonly number: number; - readonly state: string; - readonly headRefName: string; - readonly baseRefName: string; - readonly isDraft?: boolean; -} - -interface PullRequestCommitsView { - readonly state: string; - readonly baseRefName: string; - readonly commits: ReadonlyArray<{ readonly oid: string }>; -} - -/** Strip ANSI color / SGR sequences (agent hosts often set FORCE_COLOR). */ -function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); -} - -/** - * Parse JSON that may be ANSI-colored by the t3 `gh` wrapper under FORCE_COLOR hosts. - */ -export function parsePossiblyColoredJson(text: string): unknown { - const cleaned = stripAnsi(text).trim(); - try { - return JSON.parse(cleaned); - } catch (firstError) { - const match = cleaned.match(/(\[[\s\S]*\]|\{[\s\S]*\})/); - if (match) { - try { - return JSON.parse(match[1]!); - } catch { - // fall through - } - } - throw firstError; - } -} - -/** - * Subprocess env for git/gh. - * Keep FORCE_COLOR as-is: the t3 gh wrapper returns empty --head lists when - * FORCE_COLOR=0 / NO_COLOR is forced. Strip ANSI from stdout instead. - */ -function subprocessEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - }; -} - -function run(executable: string, args: ReadonlyArray, cwd: string): string { - const result = NodeChildProcess.spawnSync(executable, [...args], { - cwd, - encoding: "utf8", - env: subprocessEnv(), - }); - if (result.error) throw new StackError(`Unable to run ${executable}: ${result.error.message}`); - if (result.status !== 0) { - throw new StackError( - `${executable} ${args.join(" ")} failed: ${stripAnsi(result.stderr.trim() || result.stdout.trim())}`, - ); - } - return stripAnsi(result.stdout ?? "").trim(); -} - -export function stackParentBranch(manifest: StackManifest): string { - return manifest.pullRequests.at(-1)?.branch ?? manifest.forkChangesBranch; -} - -/** - * Ordinary feature/import PRs always target the downstream default branch, not the - * upstream mirror (`main`) and not intermediate stack provenance branches. - */ -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, -): boolean { - if (currentBase === null || currentBase === undefined || currentBase.trim() === "") { - return false; - } - return currentBase !== expectedBase; -} - -/** - * Plan how to bring a feature PR branch up to date with `fork/changes`. - * - * - `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 newBaseIsAncestorOfHead: boolean; - readonly behindCount: number; - readonly recoveredOldBaseOid: string | null; -}): { - readonly action: "noop" | "rebase" | "rebase-onto"; - readonly oldBaseOid: string | null; -} { - if (input.newBaseIsAncestorOfHead) { - if (input.behindCount <= 0) { - return { action: "noop", oldBaseOid: null }; - } - return { action: "rebase", oldBaseOid: null }; - } - if (input.recoveredOldBaseOid !== null) { - return { action: "rebase-onto", oldBaseOid: input.recoveredOldBaseOid }; - } - 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( - manifest: StackManifest, - pullRequest: PullRequestView, -): StackManifest { - if (pullRequest.state.toLowerCase() !== "open") { - throw new StackError(`PR #${pullRequest.number} is not open.`); - } - if (manifest.pullRequests.some(({ number }) => number === pullRequest.number)) { - throw new StackError(`PR #${pullRequest.number} is already registered.`); - } - if (manifest.pullRequests.some(({ branch }) => branch === pullRequest.headRefName)) { - throw new StackError(`Branch ${pullRequest.headRefName} is already registered.`); - } - - const expectedBranch = - manifest.pullRequests.length === 0 ? manifest.forkChangesBranch : pullRequest.headRefName; - if (manifest.pullRequests.length === 0 && pullRequest.headRefName !== expectedBranch) { - throw new StackError( - `The first PR must use ${manifest.forkChangesBranch}, got ${pullRequest.headRefName}.`, - ); - } - - const expectedBase = manifest.pullRequests.at(-1)?.branch ?? manifest.upstreamBranch; - if (pullRequest.baseRefName !== expectedBase) { - throw new StackError( - `PR #${pullRequest.number} is based on ${pullRequest.baseRefName}, expected ${expectedBase}.`, - ); - } - - return { - ...manifest, - pullRequests: [ - ...manifest.pullRequests, - { number: pullRequest.number, branch: pullRequest.headRefName }, - ], - }; -} - -export function unregisterTopPullRequest(manifest: StackManifest, number: number): StackManifest { - const top = manifest.pullRequests.at(-1); - if (!top || top.number !== number) { - throw new StackError( - `Only the top PR can be unregistered; expected #${top?.number ?? "none"}, got #${number}.`, - ); - } - return { ...manifest, pullRequests: manifest.pullRequests.slice(0, -1) }; -} - -export function registerIntegrationOverlay( - manifest: StackManifest, - pullRequest: PullRequestView, -): StackManifest { - if (pullRequest.state.toLowerCase() !== "open") { - throw new StackError(`PR #${pullRequest.number} is not open.`); - } - if (!pullRequest.isDraft) { - throw new StackError(`Integration overlay PR #${pullRequest.number} must be a draft.`); - } - if (pullRequest.baseRefName !== manifest.forkChangesBranch) { - throw new StackError( - `Integration overlay PR #${pullRequest.number} is based on ${pullRequest.baseRefName}, expected ${manifest.forkChangesBranch}.`, - ); - } - const managed = [...manifest.pullRequests, ...manifest.integrationOverlays]; - if (managed.some(({ number }) => number === pullRequest.number)) { - throw new StackError(`PR #${pullRequest.number} is already managed.`); - } - if (managed.some(({ branch }) => branch === pullRequest.headRefName)) { - throw new StackError(`Branch ${pullRequest.headRefName} is already managed.`); - } - return { - ...manifest, - integrationOverlays: [ - ...manifest.integrationOverlays, - { number: pullRequest.number, branch: pullRequest.headRefName }, - ], - }; -} - -export function unregisterIntegrationOverlay( - manifest: StackManifest, - number: number, -): StackManifest { - if (!manifest.integrationOverlays.some((overlay) => overlay.number === number)) { - throw new StackError(`PR #${number} is not a registered integration overlay.`); - } - return { - ...manifest, - integrationOverlays: manifest.integrationOverlays.filter( - (overlay) => overlay.number !== number, - ), - }; -} - -function writeManifest(sourceRoot: string, manifest: StackManifest): void { - NodeFS.writeFileSync( - NodePath.join(sourceRoot, MANIFEST_PATH), - `${JSON.stringify(manifest, undefined, 2)}\n`, - "utf8", - ); -} - -function readPullRequest(sourceRoot: string, number: number): PullRequestView { - const output = run( - "gh", - [ - "pr", - "view", - String(number), - "--repo", - FORK_REPOSITORY, - "--json", - "number,state,headRefName,baseRefName,isDraft", - ], - sourceRoot, - ); - return parsePossiblyColoredJson(output) as PullRequestView; -} - -function ensureClean(sourceRoot: string): void { - if (run("git", ["status", "--porcelain"], sourceRoot) !== "") { - throw new StackError("The working tree must be clean before starting a stack branch."); - } -} - -function runAllowFailure( - executable: string, - args: ReadonlyArray, - cwd: string, -): NodeChildProcess.SpawnSyncReturns { - return NodeChildProcess.spawnSync(executable, [...args], { - cwd, - encoding: "utf8", - env: subprocessEnv(), - }); -} - -function currentBranchName(sourceRoot: string): string { - const name = run("git", ["branch", "--show-current"], sourceRoot); - if (name === "") { - throw new StackError("Detached HEAD: check out the feature branch before updating."); - } - return name; -} - -function resolveOpenPullRequestForBranch( - sourceRoot: string, - branch: string, -): { readonly number: number; readonly baseRefName: string; readonly headRefName: string } | null { - const listed = run( - "gh", - [ - "pr", - "list", - "--repo", - FORK_REPOSITORY, - "--head", - branch, - "--state", - "open", - "--json", - "number,baseRefName,headRefName", - "--limit", - "1", - ], - sourceRoot, - ); - const rows = parsePossiblyColoredJson(listed) as ReadonlyArray<{ - readonly number: number; - readonly baseRefName: string; - readonly headRefName: string; - }>; - return rows[0] ?? null; -} - -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 []; - } - const blob = runAllowFailure("git", ["show", FORK_CHANGES_BASE_HISTORY_REF], sourceRoot); - if (blob.status !== 0 || !blob.stdout) return []; - 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) - .toReversed(), - ); -} - -/** - * After a remote force-push rebase, decide how to update the local checkout. - * - * Uses `git cherry` patch-ids: if every local commit is patch-equivalent to - * something already on the remote tip, hard-reset to remote (no unique work). - * If local has unique patches, rebase those onto the remote tip. - */ -export function planLocalSyncWithRemote(input: { - readonly uniqueLocalCommitOids: ReadonlyArray; - readonly remoteTipExists: boolean; -}): { - readonly action: "noop" | "reset-to-remote" | "rebase-onto-remote"; - readonly uniqueLocalCommitOids: ReadonlyArray; -} { - if (!input.remoteTipExists) { - throw new StackError("Remote tracking tip does not exist; fetch the branch first."); - } - if (input.uniqueLocalCommitOids.length === 0) { - return { action: "reset-to-remote", uniqueLocalCommitOids: [] }; - } - return { - action: "rebase-onto-remote", - uniqueLocalCommitOids: input.uniqueLocalCommitOids, - }; -} - -/** - * Parse `git cherry ` output into oids whose patches are NOT on remote (+). - */ -export function uniqueLocalCommitsFromCherry(cherryOutput: string): ReadonlyArray { - return cherryOutput - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("+ ") || line.startsWith("+")) - .map( - (line) => - line - .replace(/^\+\s*/, "") - .trim() - .split(/\s+/)[0] ?? "", - ) - .filter(Boolean); -} - -/** - * Rebase or replay the current feature branch onto latest `fork/changes`, retarget the - * open PR base if needed, and optionally force-with-lease push so the PR stays mergeable. - */ -function updateFeatureBranch( - sourceRoot: string, - manifest: StackManifest, - options: { - readonly pullRequestNumber?: number | undefined; - readonly push: boolean; - }, -): void { - ensureClean(sourceRoot); - - let branch = currentBranchName(sourceRoot); - let prNumber: number | null = options.pullRequestNumber ?? null; - let prBaseRefName: string | null = null; - - if (options.pullRequestNumber !== undefined) { - const pullRequest = readPullRequest(sourceRoot, options.pullRequestNumber); - if (pullRequest.state.toLowerCase() !== "open") { - throw new StackError( - `PR #${options.pullRequestNumber} is ${pullRequest.state}; only open feature PRs can be updated.`, - ); - } - branch = pullRequest.headRefName; - prNumber = pullRequest.number; - prBaseRefName = pullRequest.baseRefName; - run( - "git", - ["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`], - sourceRoot, - ); - run("git", ["switch", branch], sourceRoot); - // Prefer the remote tip when updating a named PR so local drift does not win. - const remoteTip = run("git", ["rev-parse", `origin/${branch}`], sourceRoot); - run("git", ["reset", "--hard", remoteTip], sourceRoot); - } else { - const open = resolveOpenPullRequestForBranch(sourceRoot, branch); - if (open !== null) { - prNumber = open.number; - prBaseRefName = open.baseRefName; - } - } - - 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 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, - 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({ - newBaseIsAncestorOfHead, - behindCount, - recoveredOldBaseOid: recoveredForOnto, - }); - - if (plan.action === "rebase") { - const result = runAllowFailure( - "git", - ["-c", "commit.gpgsign=false", "rebase", baseRef], - sourceRoot, - ); - if (result.status !== 0) { - runAllowFailure("git", ["rebase", "--abort"], sourceRoot); - throw new StackError( - `Rebase onto ${expectedBase} failed:\n${result.stderr.trim() || result.stdout.trim()}\nResolve conflicts, then re-run with a clean tree or finish manually.`, - ); - } - console.log(`Rebased ${branch} onto ${expectedBase}.`); - } 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( - `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 { - console.log(`${branch} is already up to date with ${expectedBase}.`); - } - - if (prNumber !== null && shouldRetargetPullRequestBase(prBaseRefName, expectedBase)) { - run( - "gh", - ["pr", "edit", String(prNumber), "--repo", FORK_REPOSITORY, "--base", expectedBase], - sourceRoot, - ); - console.log(`Retargeted PR #${prNumber} base ${prBaseRefName} → ${expectedBase}.`); - } - - if (options.push) { - run( - "git", - ["push", "--force-with-lease", "-u", "origin", `HEAD:refs/heads/${branch}`], - sourceRoot, - ); - console.log(`Pushed ${branch} with --force-with-lease.`); - } else { - console.log("Dry run complete (no push). Re-run with --push to update the remote PR branch."); - } - - if (prNumber !== null) { - const status = run( - "gh", - [ - "pr", - "view", - String(prNumber), - "--repo", - FORK_REPOSITORY, - "--json", - "url,baseRefName,mergeable,mergeStateStatus", - ], - sourceRoot, - ); - console.log(status); - } -} - -/** - * Safely update a local checkout after the remote branch was force-pushed - * (stack rebase / feature auto-rebase). - * - * If local commits are patch-id-equivalent to the remote tip (`git cherry` has - * no `+` lines), hard-reset to remote. If local has unique unpushed patches, - * rebase those onto the remote tip. - */ -function pullLocalBranch(sourceRoot: string, options: { readonly remote?: string }): void { - ensureClean(sourceRoot); - const remote = options.remote ?? "origin"; - const branch = currentBranchName(sourceRoot); - run("git", ["fetch", remote, branch], sourceRoot); - const remoteRef = `${remote}/${branch}`; - const remoteExists = runAllowFailure("git", ["rev-parse", "--verify", remoteRef], sourceRoot); - if (remoteExists.status !== 0) { - throw new StackError(`Remote tip ${remoteRef} not found after fetch.`); - } - const localTip = run("git", ["rev-parse", "HEAD"], sourceRoot); - const remoteTip = run("git", ["rev-parse", remoteRef], sourceRoot); - if (localTip === remoteTip) { - console.log(`${branch} already matches ${remoteRef}.`); - return; - } - const cherry = run("git", ["cherry", remoteRef, "HEAD"], sourceRoot); - const uniqueLocal = uniqueLocalCommitsFromCherry(cherry); - const plan = planLocalSyncWithRemote({ - uniqueLocalCommitOids: uniqueLocal, - remoteTipExists: true, - }); - if (plan.action === "reset-to-remote") { - run("git", ["reset", "--hard", remoteRef], sourceRoot); - console.log( - `No unique local patches (git cherry clean). Reset ${branch} to ${remoteRef} (${remoteTip.slice(0, 12)}).`, - ); - return; - } - const result = runAllowFailure( - "git", - ["-c", "commit.gpgsign=false", "rebase", remoteRef], - sourceRoot, - ); - if (result.status !== 0) { - runAllowFailure("git", ["rebase", "--abort"], sourceRoot); - throw new StackError( - `Local has ${plan.uniqueLocalCommitOids.length} unique commit(s) not on ${remoteRef}, but rebase failed:\n${stripAnsi(result.stderr.trim() || result.stdout.trim())}\nResolve manually, or stash/reset if you intended to discard local work.`, - ); - } - console.log( - `Rebased ${plan.uniqueLocalCommitOids.length} unique local commit(s) onto ${remoteRef}.`, - ); -} - -function usage(): string { - return `Usage: - node scripts/fork-stack.ts start - node scripts/fork-stack.ts start-upstream - node scripts/fork-stack.ts update [--push] [pr-number] - node scripts/fork-stack.ts pull - node scripts/fork-stack.ts promote - node scripts/fork-stack.ts adopt - node scripts/fork-stack.ts demote - node scripts/fork-stack.ts overlay-add - node scripts/fork-stack.ts overlay-start - node scripts/fork-stack.ts overlay-remove - node scripts/fork-stack.ts overlay-promote - node scripts/fork-stack.ts register - node scripts/fork-stack.ts unregister - node scripts/fork-stack.ts find - node scripts/fork-stack.ts find-upstream - node scripts/fork-stack.ts status`; -} - -async function main(args: ReadonlyArray): Promise { - const sourceRoot = process.cwd(); - const manifest = readManifest(sourceRoot); - const [command, value, ...extra] = args; - - if (command === "start" && value && extra.length === 0) { - ensureClean(sourceRoot); - const parent = featurePullRequestBaseBranch(manifest); - run("git", ["fetch", "origin", parent], sourceRoot); - run("git", ["switch", "-c", value, `origin/${parent}`], sourceRoot); - console.log(`Created ${value} from ${parent}. Open its PR against ${parent}.`); - return; - } - - if (command === "overlay-start" && value && extra.length === 1) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); - if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); - ensureClean(sourceRoot); - run("git", ["fetch", "origin", overlay.branch], sourceRoot); - run("git", ["switch", "-c", extra[0]!, `origin/${overlay.branch}`], sourceRoot); - console.log( - `Created ${extra[0]} from overlay PR #${number}. Open its PR against ${overlay.branch}; merge that child into #${number}.`, - ); - return; - } - - if (command === "update") { - const tokens = [value, ...extra].filter((token): token is string => token !== undefined); - let push = false; - let pullRequestNumber: number | undefined; - for (const token of tokens) { - if (token === "--push") { - push = true; - continue; - } - if (token === "--dry-run") { - push = false; - continue; - } - const number = Number(token); - if (Number.isSafeInteger(number) && number > 0 && pullRequestNumber === undefined) { - pullRequestNumber = number; - continue; - } - throw new StackError(usage()); - } - updateFeatureBranch(sourceRoot, manifest, { pullRequestNumber, push }); - return; - } - - if (command === "pull" && value === undefined && extra.length === 0) { - pullLocalBranch(sourceRoot, {}); - return; - } - - if (command === "start-upstream" && value && extra.length === 0) { - ensureClean(sourceRoot); - run( - "git", - [ - "fetch", - manifest.upstreamRemote, - `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - ], - sourceRoot, - ); - run( - "git", - ["switch", "-c", value, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], - sourceRoot, - ); - console.log( - `Created ${value} from ${manifest.upstreamRemote}/${manifest.upstreamBranch}. Open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, - ); - return; - } - - if (command === "promote" && value && extra.length === 1) { - const number = Number(value); - const upstreamBranch = extra[0]!; - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - ensureClean(sourceRoot); - const pullRequest = parsePossiblyColoredJson( - run( - "gh", - [ - "pr", - "view", - String(number), - "--repo", - FORK_REPOSITORY, - "--json", - "state,baseRefName,commits", - ], - sourceRoot, - ), - ) as PullRequestCommitsView; - if ( - pullRequest.state.toLowerCase() !== "merged" || - pullRequest.baseRefName !== manifest.forkChangesBranch || - pullRequest.commits.length === 0 - ) { - throw new StackError( - `Downstream PR #${number} must be merged into ${manifest.forkChangesBranch} before promotion.`, - ); - } - run( - "git", - ["fetch", "origin", `+refs/pull/${number}/head:refs/remotes/origin/pr/${number}`], - sourceRoot, - ); - run( - "git", - [ - "fetch", - manifest.upstreamRemote, - `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - ], - sourceRoot, - ); - run( - "git", - ["switch", "-c", upstreamBranch, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], - sourceRoot, - ); - run( - "git", - ["cherry-pick", "--no-commit", ...pullRequest.commits.map(({ oid }) => oid)], - sourceRoot, - ); - console.log( - `Extracted downstream PR #${number} onto ${upstreamBranch}. Remove downstream-only assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, - ); - return; - } - - if (command === "overlay-promote" && value && extra.length === 1) { - const number = Number(value); - const upstreamBranch = extra[0]!; - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - const overlay = manifest.integrationOverlays.find((entry) => entry.number === number); - if (!overlay) throw new StackError(`PR #${number} is not a registered integration overlay.`); - ensureClean(sourceRoot); - const pullRequest = parsePossiblyColoredJson( - run( - "gh", - [ - "pr", - "view", - String(number), - "--repo", - FORK_REPOSITORY, - "--json", - "state,baseRefName,commits", - ], - sourceRoot, - ), - ) as PullRequestCommitsView; - if ( - pullRequest.state.toLowerCase() !== "open" || - pullRequest.baseRefName !== manifest.forkChangesBranch || - pullRequest.commits.length === 0 - ) { - throw new StackError(`Overlay PR #${number} is not an open non-empty fork overlay.`); - } - run( - "git", - [ - "fetch", - manifest.upstreamRemote, - `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - ], - sourceRoot, - ); - run( - "git", - ["fetch", "origin", `+refs/pull/${number}/head:refs/remotes/origin/pr/${number}`], - sourceRoot, - ); - run( - "git", - ["switch", "-c", upstreamBranch, `${manifest.upstreamRemote}/${manifest.upstreamBranch}`], - sourceRoot, - ); - run( - "git", - ["cherry-pick", "--no-commit", ...pullRequest.commits.map(({ oid }) => oid)], - sourceRoot, - ); - console.log( - `Projected open overlay PR #${number} onto ${upstreamBranch}. Remove fork-only assumptions, test, commit, and open it to pingdotgg/t3code:${manifest.upstreamBranch}.`, - ); - return; - } - - if (command === "adopt" && value && extra.length === 1) { - const upstreamBranch = value; - const privateBranch = extra[0]!; - ensureClean(sourceRoot); - run( - "git", - [ - "fetch", - manifest.upstreamRemote, - `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - ], - sourceRoot, - ); - run( - "git", - ["fetch", "origin", `+refs/heads/${upstreamBranch}:refs/remotes/origin/${upstreamBranch}`], - sourceRoot, - ); - run("git", ["fetch", "origin", manifest.forkChangesBranch], sourceRoot); - const commits = run( - "git", - [ - "rev-list", - "--reverse", - "--no-merges", - `${manifest.upstreamRemote}/${manifest.upstreamBranch}..origin/${upstreamBranch}`, - ], - sourceRoot, - ) - .split("\n") - .filter(Boolean); - if (commits.length === 0) { - throw new StackError(`No portable commits found on origin/${upstreamBranch}.`); - } - run("git", ["switch", "-c", privateBranch, `origin/${manifest.forkChangesBranch}`], sourceRoot); - run("git", ["cherry-pick", ...commits], sourceRoot); - console.log( - `Adopted ${upstreamBranch} as ${privateBranch}. Open it against ${manifest.forkChangesBranch}.`, - ); - return; - } - - if (command === "demote" && value && extra.length === 1) { - const upstreamNumber = Number(value); - const privateNumber = Number(extra[0]); - if ( - !Number.isSafeInteger(upstreamNumber) || - upstreamNumber <= 0 || - !Number.isSafeInteger(privateNumber) || - privateNumber <= 0 - ) { - throw new StackError(usage()); - } - run( - "gh", - [ - "pr", - "close", - String(upstreamNumber), - "--repo", - "pingdotgg/t3code", - "--comment", - `Keeping this downstream implementation in ${FORK_REPOSITORY}#${privateNumber}.`, - ], - sourceRoot, - ); - run( - "gh", - [ - "pr", - "comment", - String(privateNumber), - "--repo", - FORK_REPOSITORY, - "--body", - `Upstream projection pingdotgg/t3code#${upstreamNumber} was closed; this downstream implementation remains canonical.`, - ], - sourceRoot, - ); - console.log( - `Demoted pingdotgg/t3code#${upstreamNumber}; downstream PR #${privateNumber} remains canonical.`, - ); - return; - } - - if (command === "register" && value && extra.length === 0) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - const next = registerPullRequest(manifest, readPullRequest(sourceRoot, number)); - writeManifest(sourceRoot, next); - console.log(`Registered PR #${number}. Commit the manifest change into fork/changes.`); - return; - } - - if (command === "unregister" && value && extra.length === 0) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - writeManifest(sourceRoot, unregisterTopPullRequest(manifest, number)); - console.log(`Unregistered PR #${number}. Commit the manifest change into fork/changes.`); - return; - } - - if (command === "overlay-add" && value && extra.length === 0) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - writeManifest( - sourceRoot, - registerIntegrationOverlay(manifest, readPullRequest(sourceRoot, number)), - ); - console.log(`Registered draft PR #${number} as an integration overlay.`); - return; - } - - if (command === "overlay-remove" && value && extra.length === 0) { - const number = Number(value); - if (!Number.isSafeInteger(number) || number <= 0) throw new StackError(usage()); - writeManifest(sourceRoot, unregisterIntegrationOverlay(manifest, number)); - console.log(`Removed integration overlay PR #${number} from the manifest.`); - return; - } - - if ((command === "find" || command === "find-upstream") && value && extra.length === 0) { - const repository = command === "find-upstream" ? "pingdotgg/t3code" : FORK_REPOSITORY; - const output = run( - "gh", - [ - "pr", - "list", - "--repo", - repository, - "--state", - "all", - "--search", - value, - "--limit", - "30", - "--json", - "number,title,state,headRefName,baseRefName,url", - ], - sourceRoot, - ); - console.log(output); - return; - } - - if (command === "status" && value === undefined && extra.length === 0) { - const rows: ReadonlyArray = manifest.pullRequests; - console.log( - JSON.stringify( - { - upstream: `${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - forkChangesBranch: manifest.forkChangesBranch, - integrationBranch: manifest.integrationBranch, - nextBaseBranch: stackParentBranch(manifest), - pullRequests: rows, - integrationOverlays: manifest.integrationOverlays, - }, - undefined, - 2, - ), - ); - return; - } - - throw new StackError(usage()); -} - -const isMain = - process.argv[1] !== undefined && - import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; - -if (isMain) { - main(process.argv.slice(2)).catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - }); -} diff --git a/scripts/rebase-integration-overlays.test.ts b/scripts/rebase-integration-overlays.test.ts deleted file mode 100644 index 3d6336a6e4e..00000000000 --- a/scripts/rebase-integration-overlays.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - assertOverlaysReadyForCompose, - planOverlayRebase, - type OverlayRebaseResult, -} from "./rebase-integration-overlays.ts"; -import { StackError } from "./rebase-pr-stack.ts"; - -describe("planOverlayRebase", () => { - it("skips when fork/changes is already an ancestor of the overlay tip", () => { - expect( - planOverlayRebase({ - number: 173, - branch: "fork/desktop", - tip: "tip1", - newBase: "base1", - isNewBaseAncestorOfTip: true, - mergeBaseWithNewBase: "base1", - }), - ).toMatchObject({ action: "skip-already-based" }); - }); - - it("plans a rebase using the merge-base with the new changes tip", () => { - expect( - planOverlayRebase({ - number: 174, - branch: "fork/discord", - tip: "tip2", - newBase: "base2", - isNewBaseAncestorOfTip: false, - mergeBaseWithNewBase: "oldBase2", - }), - ).toEqual({ - number: 174, - branch: "fork/discord", - tip: "tip2", - newBase: "base2", - oldBase: "oldBase2", - action: "rebase", - }); - }); - - it("errors when there is no usable merge-base", () => { - expect( - planOverlayRebase({ - number: 175, - branch: "fork/vscode", - tip: "tip3", - newBase: "base3", - isNewBaseAncestorOfTip: false, - mergeBaseWithNewBase: null, - }).action, - ).toBe("error"); - }); -}); - -describe("assertOverlaysReadyForCompose", () => { - it("accepts already-based skips", () => { - const result: OverlayRebaseResult = { - updated: [], - skipped: [ - { - number: 173, - branch: "fork/desktop", - reason: "already based on fork/changes", - }, - ], - conflicts: [], - }; - expect(() => assertOverlaysReadyForCompose(result, "fork/changes")).not.toThrow(); - }); - - it("throws on conflicts", () => { - const result: OverlayRebaseResult = { - updated: [], - skipped: [], - conflicts: [{ number: 174, branch: "fork/discord", message: "conflict: apps/x.ts" }], - }; - expect(() => assertOverlaysReadyForCompose(result, "fork/changes")).toThrow(StackError); - expect(() => assertOverlaysReadyForCompose(result, "fork/changes")).toThrow(/174/); - }); -}); diff --git a/scripts/rebase-integration-overlays.ts b/scripts/rebase-integration-overlays.ts deleted file mode 100644 index 15f70da7f79..00000000000 --- a/scripts/rebase-integration-overlays.ts +++ /dev/null @@ -1,406 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalConsole:off -/** - * Fast ship helper: force registered integration overlays onto current fork/changes. - * - * Used by Compose fork integration before compose so a merge to fork/changes does not - * hard-fail with "overlay is not based on current fork/changes" when rebases are clean. - * - * Does not rewrite main / tim / candidates. Does not rebase ordinary feature PRs. - */ - -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import { mkdtempDiskBacked } from "./lib/disk-backed-tmp.ts"; -import { - isSuccessfulFeatureRebaseSkip, - readManifest, - StackError, - type StackManifest, -} from "./rebase-pr-stack.ts"; - -function run( - command: string, - args: ReadonlyArray, - cwd: string, - options: { allowFailure?: boolean; env?: NodeJS.ProcessEnv } = {}, -): { status: number | null; stdout: string; stderr: string } { - const result = NodeChildProcess.spawnSync(command, [...args], { - cwd, - encoding: "utf8", - stdio: "pipe", - env: { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - GIT_EDITOR: "true", - GIT_SEQUENCE_EDITOR: "true", - ...options.env, - }, - }); - const stdout = typeof result.stdout === "string" ? result.stdout.trim() : ""; - const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; - if (!options.allowFailure && result.status !== 0) { - throw new StackError( - `${command} ${args.join(" ")} failed: ${stderr || stdout || `exit ${result.status}`}`, - ); - } - return { status: result.status, stdout, stderr }; -} - -function git( - cwd: string, - args: ReadonlyArray, - options: { allowFailure?: boolean } = {}, -): string { - return run("git", args, cwd, options).stdout; -} - -function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); -} - -export interface OverlayRebasePlan { - readonly number: number; - readonly branch: string; - readonly tip: string; - readonly newBase: string; - readonly oldBase: string | null; - readonly action: "skip-already-based" | "rebase" | "error"; - readonly reason?: string; -} - -/** - * Decide how to advance one overlay tip onto the current fork/changes tip. - * Pure helper for tests — no network. - */ -export function planOverlayRebase(input: { - readonly number: number; - readonly branch: string; - readonly tip: string; - readonly newBase: string; - readonly isNewBaseAncestorOfTip: boolean; - readonly mergeBaseWithNewBase: string | null; -}): OverlayRebasePlan { - if (input.isNewBaseAncestorOfTip) { - return { - number: input.number, - branch: input.branch, - tip: input.tip, - newBase: input.newBase, - oldBase: null, - action: "skip-already-based", - reason: "already based on fork/changes", - }; - } - const oldBase = input.mergeBaseWithNewBase; - if (!oldBase || oldBase === input.tip) { - return { - number: input.number, - branch: input.branch, - tip: input.tip, - newBase: input.newBase, - oldBase, - action: "error", - reason: - "cannot recover rebase range (no merge-base with current fork/changes, or tip is not a descendant of any shared ancestor)", - }; - } - return { - number: input.number, - branch: input.branch, - tip: input.tip, - newBase: input.newBase, - oldBase, - action: "rebase", - }; -} - -export interface OverlayRebaseResult { - readonly updated: ReadonlyArray<{ number: number; branch: string; from: string; to: string }>; - readonly skipped: ReadonlyArray<{ number: number; branch: string; reason: string }>; - readonly conflicts: ReadonlyArray<{ number: number; branch: string; message: string }>; -} - -function rebaseInProgress(repoDir: string): boolean { - return ( - NodeFS.existsSync(NodePath.join(repoDir, ".git", "rebase-merge")) || - NodeFS.existsSync(NodePath.join(repoDir, ".git", "rebase-apply")) - ); -} - -/** - * Paths where overlay vs fork/changes conflicts should keep the product base - * (fork/changes) during auto-rebase. These are ship/policy surfaces owned by - * changes, not by a single client overlay. - */ -const OVERLAY_REBASE_OURS_PATHS = new Set(["apps/mobile/app.config.ts"]); - -/** - * Rebase every registered integration overlay onto current origin/fork/changes. - * Force-with-lease pushes when `push` is true. - */ -export function rebaseIntegrationOverlays( - sourceRoot = process.cwd(), - options: { push?: boolean; manifest?: StackManifest } = {}, -): OverlayRebaseResult { - const push = options.push !== false; - const root = NodePath.resolve(sourceRoot); - const manifest = options.manifest ?? readManifest(root); - const originUrl = git(root, ["remote", "get-url", "origin"]); - const workDir = mkdtempDiskBacked("rebase-integration-overlays-", { - subdir: "rebase-work", - envVar: "T3_REBASE_WORK_ROOT", - }); - const repoDir = NodePath.join(workDir, "repo"); - NodeFS.mkdirSync(repoDir); - - const updated: Array<{ number: number; branch: string; from: string; to: string }> = []; - const skipped: Array<{ number: number; branch: string; reason: string }> = []; - const conflicts: Array<{ number: number; branch: string; message: string }> = []; - - try { - git(repoDir, ["init", "--quiet"]); - git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); - git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); - git(repoDir, ["config", "commit.gpgsign", "false"]); - git(repoDir, ["remote", "add", "origin", originUrl]); - - const branches = [ - manifest.forkChangesBranch, - ...manifest.integrationOverlays.map(({ branch }) => branch), - ]; - git(repoDir, [ - "fetch", - "--quiet", - "--no-tags", - "origin", - ...branches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), - ]); - - const newBase = git(repoDir, ["rev-parse", `origin/${manifest.forkChangesBranch}`]); - console.log( - `Rebase overlays onto ${manifest.forkChangesBranch} @ ${newBase.slice(0, 12)} (${manifest.integrationOverlays.length} registered)`, - ); - - for (const overlay of manifest.integrationOverlays) { - const tip = git(repoDir, ["rev-parse", `origin/${overlay.branch}`], { allowFailure: true }); - if (!tip) { - conflicts.push({ - number: overlay.number, - branch: overlay.branch, - message: "missing remote branch", - }); - continue; - } - - const isAncestor = - run("git", ["merge-base", "--is-ancestor", newBase, tip], repoDir, { - allowFailure: true, - }).status === 0; - const mergeBase = git(repoDir, ["merge-base", newBase, tip], { allowFailure: true }) || null; - const plan = planOverlayRebase({ - number: overlay.number, - branch: overlay.branch, - tip, - newBase, - isNewBaseAncestorOfTip: isAncestor, - mergeBaseWithNewBase: mergeBase, - }); - - if (plan.action === "skip-already-based") { - console.log(` #${overlay.number} ${overlay.branch}: already based (skip)`); - skipped.push({ - number: overlay.number, - branch: overlay.branch, - reason: `already based on ${manifest.forkChangesBranch}`, - }); - continue; - } - if (plan.action === "error" || !plan.oldBase) { - console.error(` #${overlay.number} ${overlay.branch}: ${plan.reason}`); - conflicts.push({ - number: overlay.number, - branch: overlay.branch, - message: plan.reason ?? "cannot plan rebase", - }); - continue; - } - - console.log( - ` #${overlay.number} ${overlay.branch}: rebase --onto ${newBase.slice(0, 12)} ${plan.oldBase.slice(0, 12)} (from ${tip.slice(0, 12)})`, - ); - git(repoDir, ["checkout", "--quiet", "--detach", tip]); - let rebaseResult = run( - "git", - ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, plan.oldBase], - repoDir, - { allowFailure: true }, - ); - // Auto-resolve known product-base drift files by keeping fork/changes - // ("ours" during rebase). Overlay product should not fight mobile - // runtimeVersion / OTA policy from the changes layer. - while (rebaseResult.status !== 0 && rebaseInProgress(repoDir)) { - const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - }) - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const autoResolvable = - conflictPaths.length > 0 && - conflictPaths.every((path) => OVERLAY_REBASE_OURS_PATHS.has(path)); - if (!autoResolvable) { - break; - } - for (const path of conflictPaths) { - run("git", ["checkout", "--ours", "--", path], repoDir); - run("git", ["add", "--", path], repoDir); - } - console.log( - ` #${overlay.number} ${overlay.branch}: auto-resolved (keep ${manifest.forkChangesBranch}): ${conflictPaths.join(", ")}`, - ); - rebaseResult = run("git", ["-c", "commit.gpgsign=false", "rebase", "--continue"], repoDir, { - allowFailure: true, - }); - } - if (rebaseResult.status !== 0) { - const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - }); - if (rebaseInProgress(repoDir)) { - run("git", ["rebase", "--abort"], repoDir, { allowFailure: true }); - } - const message = conflictPaths - ? `conflict rebasing onto ${manifest.forkChangesBranch} from ${plan.oldBase.slice(0, 12)}: ${conflictPaths.split("\n").join(", ")}` - : stripAnsi(rebaseResult.stderr || rebaseResult.stdout || "rebase --onto failed"); - console.error(` #${overlay.number} ${overlay.branch}: ${message}`); - conflicts.push({ number: overlay.number, branch: overlay.branch, message }); - continue; - } - - const newTip = git(repoDir, ["rev-parse", "HEAD"]); - if (newTip === tip) { - skipped.push({ - number: overlay.number, - branch: overlay.branch, - reason: "rebase produced identical tip", - }); - continue; - } - - if (push) { - const pushResult = run( - "git", - [ - "push", - `--force-with-lease=refs/heads/${overlay.branch}:${tip}`, - "origin", - `${newTip}:refs/heads/${overlay.branch}`, - ], - repoDir, - { allowFailure: true }, - ); - if (pushResult.status !== 0) { - // Concurrent updater may have already landed a based tip. - git(repoDir, [ - "fetch", - "--quiet", - "origin", - `+refs/heads/${overlay.branch}:refs/remotes/origin/${overlay.branch}`, - ]); - const latest = git(repoDir, ["rev-parse", `origin/${overlay.branch}`], { - allowFailure: true, - }); - const alreadyBased = - latest !== "" && - run("git", ["merge-base", "--is-ancestor", newBase, latest], repoDir, { - allowFailure: true, - }).status === 0; - if (alreadyBased) { - skipped.push({ - number: overlay.number, - branch: overlay.branch, - reason: `remote already based on ${manifest.forkChangesBranch} after concurrent update`, - }); - continue; - } - conflicts.push({ - number: overlay.number, - branch: overlay.branch, - message: `push failed: ${stripAnsi( - pushResult.stderr || pushResult.stdout || "force-with-lease rejected", - )}`, - }); - continue; - } - } - - console.log( - ` #${overlay.number} ${overlay.branch}: updated ${tip.slice(0, 12)} → ${newTip.slice(0, 12)}${push ? " (pushed)" : ""}`, - ); - updated.push({ - number: overlay.number, - branch: overlay.branch, - from: tip, - to: newTip, - }); - } - - return { updated, skipped, conflicts }; - } finally { - try { - NodeFS.rmSync(workDir, { recursive: true, force: true }); - } catch { - // ignore - } - } -} - -/** - * Fail if any overlay could not be made based on fork/changes. - * Successful skips (already based / identical tip / concurrent update) are OK. - */ -export function assertOverlaysReadyForCompose( - result: OverlayRebaseResult, - forkChangesBranch: string, -): void { - const hardSkips = result.skipped.filter( - (entry) => !isSuccessfulFeatureRebaseSkip(entry.reason, forkChangesBranch), - ); - if (result.conflicts.length === 0 && hardSkips.length === 0) { - return; - } - const details = [ - ...result.conflicts.map((entry) => `#${entry.number} (${entry.branch}): ${entry.message}`), - ...hardSkips.map((entry) => `#${entry.number} (${entry.branch}): ${entry.reason}`), - ].join("; "); - throw new StackError( - `Integration overlay auto-rebase incomplete (compose cannot proceed): ${details}`, - ); -} - -const isMain = - process.argv[1] !== undefined && - import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; - -if (isMain) { - const push = !process.argv.includes("--dry-run"); - try { - const manifest = readManifest(process.cwd()); - const result = rebaseIntegrationOverlays(process.cwd(), { push }); - console.log( - `Overlays: updated=${result.updated.length} skipped=${result.skipped.length} conflicts=${result.conflicts.length}`, - ); - assertOverlaysReadyForCompose(result, manifest.forkChangesBranch); - if (!push) { - console.log("Dry-run only (no push)."); - } - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; - } -} diff --git a/scripts/rebase-pr-stack.test.ts b/scripts/rebase-pr-stack.test.ts deleted file mode 100644 index b1b6f951678..00000000000 --- a/scripts/rebase-pr-stack.test.ts +++ /dev/null @@ -1,1245 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off - -import { assert, describe, it } from "@effect/vitest"; -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - -import { - assertSafeAutomaticConflictResolution, - baseHistoryPushArgs, - conflictResolutionManifestSnippet, - isProductConflictPath, - isSuccessfulFeatureRebaseSkip, - packagesForChangedPaths, - parseManifest, - rebaseOpenFeaturePullRequests, - RebaseConflictError, - rewriteInstallArgs, - resumeStack, - selectOpenFeaturePullRequests, - selectOpenFeaturePullRequestTree, - shouldAttemptConflictResolution, - StackError, - syncStack, - type PullRequestSnapshot, - type StackManifest, - validatePullRequestSnapshots, -} from "./rebase-pr-stack.ts"; - -describe("shouldAttemptConflictResolution", () => { - it("distinguishes merge conflicts from failed rebase exec gates", () => { - assert.equal(shouldAttemptConflictResolution(["apps/web/src/App.tsx"], "abc123"), true); - assert.equal(shouldAttemptConflictResolution([], ""), false); - assert.equal(shouldAttemptConflictResolution([], "abc123"), false); - assert.equal(shouldAttemptConflictResolution(["apps/web/src/App.tsx"], ""), false); - }); -}); - -describe("rewriteInstallArgs", () => { - it("prepares an isolated, lockfile-exact dependency tree", () => { - assert.deepEqual(rewriteInstallArgs(), ["install", "--frozen-lockfile", "--prefer-offline"]); - }); -}); - -describe("packagesForChangedPaths", () => { - it("maps package and app sources to pnpm filters", () => { - assert.deepEqual( - packagesForChangedPaths([ - "packages/client-runtime/src/state/vcs.ts", - "apps/server/src/ws.ts", - "apps/web/src/components/BranchToolbar.tsx", - "docs/fork-stack.md", - ".github/pr-stack.json", - ]), - ["@t3tools/client-runtime", "@t3tools/web", "t3"], - ); - }); - - it("returns empty for docs/manifest-only commits", () => { - assert.deepEqual( - packagesForChangedPaths([".github/pr-stack.json", "docs/fork-stack.md", "AGENTS.md"]), - [], - ); - }); -}); - -describe("isProductConflictPath", () => { - it("flags shared app and package sources", () => { - assert.equal(isProductConflictPath("apps/server/src/vcs/GitVcsDriverCore.ts"), true); - assert.equal(isProductConflictPath("packages/client-runtime/src/state/vcs.ts"), true); - assert.equal(isProductConflictPath("apps/future-client/src/App.tsx"), true); - assert.equal(isProductConflictPath("packages/future-runtime/src/index.ts"), true); - assert.equal(isProductConflictPath("infra/future-service/src/worker.ts"), true); - assert.equal(isProductConflictPath("scripts/rebase-pr-stack.ts"), true); - assert.equal(isProductConflictPath("package.json"), true); - assert.equal(isProductConflictPath("apps/web/package.json"), true); - assert.equal(isProductConflictPath(".github/pr-stack.json"), false); - assert.equal(isProductConflictPath("pnpm-lock.yaml"), false); - assert.equal(isProductConflictPath("docs/fork-stack.md"), false); - }); -}); - -describe("automatic conflict resolution safety", () => { - const manifest = { - upstreamRemote: "upstream", - upstreamBranch: "main", - forkChangesBranch: "fork/changes", - integrationBranch: "fork/integration", - pullRequests: [ - { number: 1, branch: "fork/tim" }, - { number: 2, branch: "fork/changes" }, - ], - integrationOverlays: [], - }; - - for (const commit of ["*", "a".repeat(40)]) { - it(`rejects ${commit === "*" ? "durable" : "exact"} whole-file product policies`, () => { - assert.throws( - () => - parseManifest( - JSON.stringify({ - ...manifest, - conflictResolutions: [ - { - branch: "fork/changes", - commit, - path: "apps/web/src/components/ChatView.tsx", - strategy: "theirs", - }, - ], - }), - ), - /unsafe.*Automatic whole-file conflict resolution is forbidden/i, - ); - }); - } - - it("allows automatic resolution for non-product stack metadata", () => { - assert.doesNotThrow(() => - parseManifest( - JSON.stringify({ - ...manifest, - conflictResolutions: [ - { - branch: "fork/integration", - commit: "*", - path: "pnpm-lock.yaml", - strategy: "theirs", - }, - ], - }), - ), - ); - }); - - it("defends application time against unsafe persisted product policies", () => { - assert.throws( - () => assertSafeAutomaticConflictResolution("apps/server/src/server.ts"), - /3-way merge/, - ); - }); - - it("never suggests manifest entries for product conflicts", () => { - const guidance = conflictResolutionManifestSnippet( - "fork/changes", - "a".repeat(40), - ["apps/web/src/components/ChatView.tsx"], - "theirs", - ); - assert.match(guidance, /Manual product resolution required/); - assert.match(guidance, /3-way merge/); - assert.notMatch(guidance, /"commit": "\*"/); - }); - - it("suggests durable entries only for non-product conflicts", () => { - const guidance = conflictResolutionManifestSnippet( - "fork/integration", - "a".repeat(40), - ["pnpm-lock.yaml"], - "theirs", - ); - assert.match(guidance, /"commit": "\*"/); - assert.notMatch(guidance, /Manual product resolution required/); - }); -}); - -describe("isSuccessfulFeatureRebaseSkip", () => { - it("treats actual already-based reason strings as success", () => { - // rebaseOpenFeaturePullRequests emits these exact strings when an overlay - // (or feature) already contains the new parent tip. The post-sync overlay - // gate must not treat them as incomplete — that bug hard-failed stack - // runs after #97 whenever overlays needed no rewrite. - assert.equal( - isSuccessfulFeatureRebaseSkip("already based on fork/changes", "fork/changes"), - true, - ); - assert.equal( - isSuccessfulFeatureRebaseSkip( - "remote already based on fork/changes after concurrent update", - "fork/changes", - ), - true, - ); - assert.equal( - isSuccessfulFeatureRebaseSkip("rebase produced identical tip", "fork/changes"), - true, - ); - }); - - it("does not accept the historical mistyped allowlist that never matched", () => { - assert.equal( - isSuccessfulFeatureRebaseSkip("already based on new fork/changes", "fork/changes"), - false, - ); - assert.equal( - isSuccessfulFeatureRebaseSkip( - "remote already based on new fork/changes after concurrent update", - "fork/changes", - ), - false, - ); - }); - - it("still fails incomplete recovery / missing-branch skips", () => { - assert.equal( - isSuccessfulFeatureRebaseSkip( - "cannot recover old fork/changes tip (no known historical base tip is an ancestor of this head)", - "fork/changes", - ), - false, - ); - assert.equal(isSuccessfulFeatureRebaseSkip("missing remote branch", "fork/changes"), false); - assert.equal( - isSuccessfulFeatureRebaseSkip("parent branch fork/changes was not rebased", "fork/changes"), - false, - ); - }); -}); - -describe("baseHistoryPushArgs", () => { - it("force-updates the blob ref while leasing its observed remote value", () => { - assert.deepEqual(baseHistoryPushArgs("abc123"), [ - "push", - "--force-with-lease=refs/t3/stack/base-history/fork-changes:abc123", - "origin", - "refs/t3/stack/base-history/fork-changes:refs/t3/stack/base-history/fork-changes", - ]); - }); - - it("leases non-existence when the remote history ref is absent", () => { - assert.include( - baseHistoryPushArgs(""), - "--force-with-lease=refs/t3/stack/base-history/fork-changes:", - ); - }); -}); - -describe("selectOpenFeaturePullRequests", () => { - const manifest: StackManifest = { - upstreamRemote: "upstream", - upstreamBranch: "main", - forkChangesBranch: "fork/changes", - integrationBranch: "fork/integration", - pullRequests: [ - { number: 1, branch: "fork/tim" }, - { number: 2, branch: "fork/changes" }, - ], - integrationOverlays: [ - { number: 10, branch: "overlay/desktop" }, - { number: 80, branch: "overlay/discord" }, - ], - }; - - it("puts registered integration overlays first in manifest order", () => { - const selected = selectOpenFeaturePullRequests({ - expectedRepository: "patroza/t3code", - manifest, - openPulls: [ - { - number: 96, - headBranch: "feat/recent-project-filter", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 80, - headBranch: "overlay/discord", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 10, - headBranch: "overlay/desktop", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - ], - }); - assert.deepEqual( - selected.map(({ branch }) => branch), - ["overlay/desktop", "overlay/discord", "feat/recent-project-filter"], - ); - }); - - it("excludes managed stack provenance branches", () => { - const selected = selectOpenFeaturePullRequests({ - expectedRepository: "patroza/t3code", - manifest, - openPulls: [ - { - number: 2, - headBranch: "fork/changes", - baseBranch: "main", - headRepository: "patroza/t3code", - }, - { - number: 10, - headBranch: "overlay/desktop", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - ], - }); - assert.deepEqual( - selected.map(({ branch }) => branch), - ["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 { - readonly root: string; - readonly work: string; - readonly origin: string; - readonly upstream: string; - readonly manifest: StackManifest; -} - -interface FixtureOptions { - readonly conflict?: boolean; - readonly extraCommitOnPr5?: boolean; - readonly updatePr5AfterDescendant?: boolean; - readonly landedPr4Upstream?: boolean; - readonly divergedMain?: boolean; - readonly emptyIntegration?: boolean; - readonly unchangedUpstream?: boolean; - readonly insertMiddleLayer?: boolean; - readonly advanceTopAfterIntegration?: boolean; -} - -/** - * Git's repository-scoping variables. Inherited from a parent git process — a - * hook, or anything the ship gate runs — they override `cwd` entirely, so these - * fixtures would operate on the developer's real checkout instead of the temp - * directory. `git init --bare ` then sets `core.bare = true` on the actual - * repository, which breaks `git status`, `add` and `commit` until someone - * notices and unsets it. - * - * This is the same precaution `.githooks/pre-push` takes with - * `git rev-parse --local-env-vars`; the list is hardcoded here so the fixture - * does not need a working git repository to discover it. - */ -const GIT_LOCAL_ENV_VARS = [ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", - "GIT_COMMON_DIR", - "GIT_NAMESPACE", - "GIT_CEILING_DIRECTORIES", - "GIT_PREFIX", - "GIT_SUPER_PREFIX", - "GIT_INTERNAL_SUPER_PREFIX", -] as const; - -const gitFixtureEnv = (): NodeJS.ProcessEnv => { - const env: NodeJS.ProcessEnv = { - ...process.env, - GIT_AUTHOR_NAME: "Stack Test", - GIT_AUTHOR_EMAIL: "stack-test@example.com", - GIT_COMMITTER_NAME: "Stack Test", - GIT_COMMITTER_EMAIL: "stack-test@example.com", - }; - for (const name of GIT_LOCAL_ENV_VARS) delete env[name]; - return env; -}; - -function runGit( - cwd: string, - args: ReadonlyArray, - options: { readonly allowFailure?: boolean } = {}, -): string { - const result = NodeChildProcess.spawnSync("git", [...args], { - cwd, - encoding: "utf8", - env: gitFixtureEnv(), - }); - if (!options.allowFailure && result.status !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); - } - return result.stdout.trim(); -} - -function write(path: string, contents: string): void { - NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); - NodeFS.writeFileSync(path, contents, "utf8"); -} - -function commitFile(work: string, path: string, contents: string, subject: string): string { - write(NodePath.join(work, path), contents); - runGit(work, ["add", path]); - runGit(work, ["commit", "--quiet", "-m", subject]); - return runGit(work, ["rev-parse", "HEAD"]); -} - -function remoteTip(remote: string, branch: string): string { - return runGit(remote, ["rev-parse", `refs/heads/${branch}`]); -} - -function remoteTips(fixture: Fixture): Record { - return Object.fromEntries( - [ - fixture.manifest.upstreamBranch, - ...fixture.manifest.pullRequests.map(({ branch }) => branch), - fixture.manifest.integrationBranch, - ].map((branch) => [branch, remoteTip(fixture.origin, branch)]), - ); -} - -function isAncestor(repository: string, parent: string, child: string): boolean { - const result = NodeChildProcess.spawnSync("git", ["merge-base", "--is-ancestor", parent, child], { - cwd: repository, - encoding: "utf8", - // Same scrub as the fixture writer: an inherited GIT_DIR would answer this - // question about the developer's repository rather than the fixture's. - env: gitFixtureEnv(), - }); - return result.status === 0; -} - -async function captureFailure(promise: Promise): Promise { - try { - await promise; - } catch (error) { - return error; - } - assert.fail("Expected the promise to reject."); -} - -function createFixture(options: FixtureOptions = {}): Fixture { - const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "pr-stack-test-")); - const work = NodePath.join(root, "work"); - const origin = NodePath.join(root, "origin.git"); - const upstream = NodePath.join(root, "upstream.git"); - NodeFS.mkdirSync(work); - runGit(root, ["init", "--bare", "--quiet", origin]); - runGit(root, ["init", "--bare", "--quiet", upstream]); - runGit(work, ["init", "--quiet", "--initial-branch=main"]); - runGit(work, ["config", "user.name", "Stack Test"]); - runGit(work, ["config", "user.email", "stack-test@example.com"]); - runGit(work, ["config", "commit.gpgsign", "false"]); - runGit(work, ["remote", "add", "origin", origin]); - runGit(work, ["remote", "add", "upstream", upstream]); - commitFile(work, "shared.txt", "base\n", "base"); - runGit(work, ["push", "--quiet", "origin", "main"]); - runGit(work, ["push", "--quiet", "upstream", "main"]); - - const manifest: StackManifest = { - upstreamRemote: "upstream", - upstreamBranch: "main", - forkChangesBranch: "feature/pr-6", - integrationBranch: "fork/integration", - pullRequests: [ - { number: 4, branch: "feature/pr-4" }, - ...(options.insertMiddleLayer ? [{ number: 45, branch: "feature/upstream-candidates" }] : []), - { number: 5, branch: "feature/pr-5" }, - { number: 6, branch: "feature/pr-6" }, - ], - integrationOverlays: [], - }; - write( - NodePath.join(work, ".github", "pr-stack.json"), - `${JSON.stringify(manifest, undefined, 2)}\n`, - ); - - runGit(work, ["checkout", "--quiet", "-b", "feature/pr-4", "main"]); - const pr4Tip = options.conflict - ? commitFile(work, "shared.txt", "from pr 4\n", "pr 4 conflicts") - : commitFile(work, "pr-4.txt", "four\n", "pr 4"); - runGit(work, ["push", "--quiet", "origin", "feature/pr-4"]); - - if (options.insertMiddleLayer) { - runGit(work, ["checkout", "--quiet", "-b", "feature/upstream-candidates"]); - commitFile(work, "candidate.txt", "candidate\n", "upstream candidate"); - runGit(work, ["push", "--quiet", "origin", "feature/upstream-candidates"]); - runGit(work, ["checkout", "--quiet", "feature/pr-4"]); - } - - runGit(work, ["checkout", "--quiet", "-b", "feature/pr-5"]); - commitFile(work, "pr-5.txt", "five\n", "pr 5"); - if (options.extraCommitOnPr5) { - commitFile(work, "pr-5-extra.txt", "new before sync\n", "new pr 5 commit"); - } - runGit(work, ["push", "--quiet", "origin", "feature/pr-5"]); - - runGit(work, ["checkout", "--quiet", "-b", "feature/pr-6"]); - commitFile(work, "pr-6.txt", "six\n", "pr 6"); - runGit(work, ["push", "--quiet", "origin", "feature/pr-6"]); - - runGit(work, ["checkout", "--quiet", "-b", "fork/integration"]); - if (!options.emptyIntegration) { - commitFile(work, "automation.txt", "automation\n", "stack automation"); - } - runGit(work, ["push", "--quiet", "origin", "fork/integration"]); - - if (options.advanceTopAfterIntegration) { - runGit(work, ["checkout", "--quiet", "feature/pr-6"]); - commitFile(work, "pr-6-late.txt", "merged after integration\n", "advance fork changes"); - runGit(work, ["push", "--quiet", "origin", "feature/pr-6"]); - } - - if (options.updatePr5AfterDescendant) { - runGit(work, ["checkout", "--quiet", "feature/pr-5"]); - commitFile(work, "pr-5-late.txt", "updated after pr 6\n", "late pr 5 update"); - runGit(work, ["push", "--quiet", "origin", "feature/pr-5"]); - } - - if (options.unchangedUpstream) { - // Keep upstream at the stack's original base. - } else if (options.landedPr4Upstream) { - runGit(work, ["checkout", "--quiet", "main"]); - runGit(work, ["cherry-pick", "--quiet", pr4Tip]); - runGit(work, ["push", "--quiet", "upstream", "main"]); - } else { - runGit(work, ["checkout", "--quiet", "main"]); - if (options.conflict) { - commitFile(work, "shared.txt", "from upstream\n", "upstream conflicts"); - } else { - commitFile(work, "upstream.txt", "upstream\n", "upstream advances"); - } - runGit(work, ["push", "--quiet", "upstream", "main"]); - } - - if (options.divergedMain) { - runGit(work, ["checkout", "--quiet", "main"]); - commitFile(work, "origin-only.txt", "origin divergence\n", "origin diverges"); - runGit(work, ["push", "--quiet", "origin", "main"]); - } - - return { root, work, origin, upstream, manifest }; -} - -describe("rebase-pr-stack", () => { - it("creates a clean linear cascade with no merge commits", async () => { - const fixture = createFixture(); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - let parent = remoteTip(fixture.upstream, "main"); - for (const { branch } of fixture.manifest.pullRequests) { - const child = remoteTip(fixture.origin, branch); - assert.ok(isAncestor(fixture.origin, parent, child)); - assert.equal( - runGit(fixture.origin, ["rev-list", "--count", "--merges", `${parent}..${child}`]), - "0", - ); - parent = child; - } - assert.ok( - isAncestor( - fixture.origin, - parent, - remoteTip(fixture.origin, fixture.manifest.integrationBranch), - ), - ); - assert.equal(remoteTip(fixture.origin, "main"), remoteTip(fixture.upstream, "main")); - }); - - it("inserts a new middle layer before a child that does not contain it yet", async () => { - const fixture = createFixture({ insertMiddleLayer: true }); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - const candidate = remoteTip(fixture.origin, "feature/upstream-candidates"); - const pr5 = remoteTip(fixture.origin, "feature/pr-5"); - const pr6 = remoteTip(fixture.origin, "feature/pr-6"); - assert.ok(isAncestor(fixture.origin, candidate, pr5)); - assert.ok(isAncestor(fixture.origin, pr5, pr6)); - assert.deepStrictEqual( - runGit(fixture.origin, ["log", "--reverse", "--format=%s", `${candidate}..${pr5}`]).split( - "\n", - ), - ["pr 5"], - ); - }); - - it("moves an integration branch with no unique commits to the rewritten stack tip", async () => { - const fixture = createFixture({ emptyIntegration: true }); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - assert.equal( - remoteTip(fixture.origin, fixture.manifest.integrationBranch), - remoteTip(fixture.origin, fixture.manifest.pullRequests.at(-1)!.branch), - ); - }); - - it("preserves exact layer tips when upstream has not changed", async () => { - const fixture = createFixture({ emptyIntegration: true, unchangedUpstream: true }); - const before = remoteTips(fixture); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - for (const { branch } of fixture.manifest.pullRequests) { - assert.equal(remoteTip(fixture.origin, branch), before[branch]); - } - assert.equal( - remoteTip(fixture.origin, fixture.manifest.integrationBranch), - before[fixture.manifest.pullRequests.at(-1)!.branch], - ); - }); - - it("replays only each PR's unique commits onto its rewritten parent", async () => { - const fixture = createFixture(); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - const pr4 = remoteTip(fixture.origin, "feature/pr-4"); - const pr5 = remoteTip(fixture.origin, "feature/pr-5"); - const pr6 = remoteTip(fixture.origin, "feature/pr-6"); - assert.deepStrictEqual( - runGit(fixture.origin, ["log", "--format=%s", `${pr4}..${pr5}`]).split("\n"), - ["pr 5"], - ); - assert.deepStrictEqual( - runGit(fixture.origin, ["log", "--format=%s", `${pr5}..${pr6}`]).split("\n"), - ["pr 6"], - ); - }); - - it("retains commits added to a PR before the run", async () => { - const fixture = createFixture({ extraCommitOnPr5: true }); - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - const pr4 = remoteTip(fixture.origin, "feature/pr-4"); - const pr5 = remoteTip(fixture.origin, "feature/pr-5"); - assert.deepStrictEqual( - runGit(fixture.origin, ["log", "--reverse", "--format=%s", `${pr4}..${pr5}`]).split("\n"), - ["pr 5", "new pr 5 commit"], - ); - }); - - it("restacks descendants after an earlier PR is updated", async () => { - const fixture = createFixture({ updatePr5AfterDescendant: true }); - const oldPr6 = remoteTip(fixture.origin, "feature/pr-6"); - assert.ok(!isAncestor(fixture.origin, remoteTip(fixture.origin, "feature/pr-5"), oldPr6)); - - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - const pr5 = remoteTip(fixture.origin, "feature/pr-5"); - const pr6 = remoteTip(fixture.origin, "feature/pr-6"); - assert.ok(isAncestor(fixture.origin, pr5, pr6)); - assert.deepStrictEqual( - runGit(fixture.origin, ["log", "--reverse", "--format=%s", `${pr5}..${pr6}`]).split("\n"), - ["pr 6"], - ); - }); - - it("rebases integration from its actual base after fork changes advances", async () => { - const fixture = createFixture({ advanceTopAfterIntegration: true }); - - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - const forkChanges = remoteTip(fixture.origin, fixture.manifest.forkChangesBranch); - const integration = remoteTip(fixture.origin, fixture.manifest.integrationBranch); - assert.ok(isAncestor(fixture.origin, forkChanges, integration)); - assert.deepStrictEqual( - runGit(fixture.origin, [ - "log", - "--reverse", - "--format=%s", - `${forkChanges}..${integration}`, - ]).split("\n"), - ["stack automation"], - ); - }); - - it("leaves every remote ref unchanged when a rebase conflicts", async () => { - const fixture = createFixture({ conflict: true }); - const before = remoteTips(fixture); - const error = await captureFailure( - syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }), - ); - assert.ok(error instanceof RebaseConflictError); - assert.deepStrictEqual(remoteTips(fixture), before); - }); - - it("applies an exact manifest conflict resolution and completes the atomic update", async () => { - const fixture = createFixture({ conflict: true }); - const conflictingCommit = remoteTip(fixture.origin, "feature/pr-4"); - const manifest: StackManifest = { - ...fixture.manifest, - conflictResolutions: [ - { - branch: "feature/pr-4", - commit: conflictingCommit, - path: "shared.txt", - strategy: "theirs", - }, - ], - }; - write( - NodePath.join(fixture.work, ".github", "pr-stack.json"), - `${JSON.stringify(manifest, undefined, 2)}\n`, - ); - - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - assert.equal(runGit(fixture.origin, ["show", "feature/pr-4:shared.txt"]), "from pr 4"); - assert.ok( - isAncestor( - fixture.origin, - remoteTip(fixture.upstream, "main"), - remoteTip(fixture.origin, "feature/pr-4"), - ), - ); - }); - - it("applies a durable any-commit (*) conflict resolution across rewrites", async () => { - const fixture = createFixture({ conflict: true }); - const manifest: StackManifest = { - ...fixture.manifest, - conflictResolutions: [ - { - branch: "feature/pr-4", - commit: "*", - path: "shared.txt", - strategy: "theirs", - }, - ], - }; - write( - NodePath.join(fixture.work, ".github", "pr-stack.json"), - `${JSON.stringify(manifest, undefined, 2)}\n`, - ); - - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - - assert.equal(runGit(fixture.origin, ["show", "feature/pr-4:shared.txt"]), "from pr 4"); - assert.ok( - isAncestor( - fixture.origin, - remoteTip(fixture.upstream, "main"), - remoteTip(fixture.origin, "feature/pr-4"), - ), - ); - }); - - it("aborts every ref update when a force-with-lease becomes stale", async () => { - const fixture = createFixture(); - const before = remoteTips(fixture); - let concurrentTip = ""; - const error = await captureFailure( - syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - beforePush: () => { - runGit(fixture.work, ["checkout", "--quiet", "feature/pr-5"]); - concurrentTip = commitFile( - fixture.work, - "concurrent.txt", - "human push\n", - "concurrent human push", - ); - runGit(fixture.work, ["push", "--quiet", "origin", "feature/pr-5"]); - }, - }), - ); - assert.match( - error instanceof Error ? error.message : String(error), - /stale info|atomic push failed|failed to push/, - ); - - const after = remoteTips(fixture); - assert.equal(after["feature/pr-5"], concurrentTip); - for (const [branch, sha] of Object.entries(before)) { - if (branch !== "feature/pr-5") assert.equal(after[branch], sha); - } - }); - - it("resumes a manually resolved conflict through the remaining branches", async () => { - const fixture = createFixture({ conflict: true }); - let conflict: RebaseConflictError | undefined; - try { - await syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }); - } catch (error) { - if (error instanceof RebaseConflictError) conflict = error; - else throw error; - } - assert.ok(conflict?.stateDir); - const stateDir = conflict.stateDir; - const repoDir = NodePath.join(stateDir, "repo"); - write(NodePath.join(repoDir, "shared.txt"), "resolved upstream and pr 4\n"); - runGit(repoDir, ["add", "shared.txt"]); - - await resumeStack(stateDir, { push: true }); - let parent = remoteTip(fixture.upstream, "main"); - for (const { branch } of fixture.manifest.pullRequests) { - const child = remoteTip(fixture.origin, branch); - assert.ok(isAncestor(fixture.origin, parent, child)); - parent = child; - } - }); - - it("rejects closed, renamed, and foreign-owned managed PRs", () => { - const fixture = createFixture(); - const valid: Array = fixture.manifest.pullRequests.map( - ({ number, branch }, index) => ({ - number, - state: "open", - headBranch: branch, - headOwner: "patroza", - baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, - isDraft: true, - }), - ); - - const variants: ReadonlyArray> = [ - valid.map((pr) => (pr.number === 4 ? { ...pr, state: "closed" } : pr)), - valid.map((pr) => (pr.number === 4 ? { ...pr, headBranch: "renamed" } : pr)), - valid.map((pr) => (pr.number === 4 ? { ...pr, headOwner: "someone-else" } : pr)), - ]; - for (const variant of variants) { - assert.throws(() => validatePullRequestSnapshots(fixture.manifest, variant), StackError); - } - }); - - it("ignores ordinary open PRs that are not part of the managed integration chain", () => { - const fixture = createFixture(); - const valid: Array = fixture.manifest.pullRequests.map( - ({ number, branch }, index) => ({ - number, - state: "open", - headBranch: branch, - headOwner: "patroza", - baseBranch: index === 0 ? "main" : fixture.manifest.pullRequests[index - 1]!.branch, - isDraft: true, - }), - ); - assert.doesNotThrow(() => - validatePullRequestSnapshots(fixture.manifest, [ - ...valid, - { - number: 99, - state: "open", - headBranch: "feature/parallel", - headOwner: "patroza", - baseBranch: "fork/changes", - isDraft: true, - }, - ]), - ); - }); - - it("reports a PR as empty when its commits have already landed upstream", async () => { - const fixture = createFixture({ landedPr4Upstream: true }); - const error = await captureFailure( - syncStack({ - sourceRoot: fixture.work, - push: false, - validatePullRequests: false, - }), - ); - assert.match( - error instanceof Error ? error.message : String(error), - /PR #4 became empty.*already have landed upstream/, - ); - }); - - it("never updates a diverged origin main", async () => { - const fixture = createFixture({ divergedMain: true }); - const before = remoteTips(fixture); - const error = await captureFailure( - syncStack({ - sourceRoot: fixture.work, - push: true, - validatePullRequests: false, - }), - ); - assert.match( - error instanceof Error ? error.message : String(error), - /has diverged.*refusing to update fork main/, - ); - assert.deepStrictEqual(remoteTips(fixture), before); - }); -}); - -describe("rebaseOpenFeaturePullRequests isolation", () => { - function createFeatureRebaseFixture() { - const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "feature-rebase-")); - const work = NodePath.join(root, "work"); - const origin = NodePath.join(root, "origin.git"); - NodeFS.mkdirSync(work); - runGit(root, ["init", "--bare", "--quiet", origin]); - runGit(work, ["init", "--quiet", "--initial-branch=main"]); - runGit(work, ["config", "user.name", "Stack Test"]); - runGit(work, ["config", "user.email", "stack-test@example.com"]); - runGit(work, ["config", "commit.gpgsign", "false"]); - runGit(work, ["remote", "add", "origin", origin]); - commitFile(work, "base.txt", "base\n", "base"); - runGit(work, ["checkout", "--quiet", "-b", "fork/changes"]); - runGit(work, ["push", "--quiet", "origin", "main", "fork/changes"]); - - // Two branches based on the same fork/changes tip. - runGit(work, ["checkout", "--quiet", "-b", "feature/flaky", "fork/changes"]); - commitFile(work, "flaky.txt", "flaky\n", "flaky feature"); - runGit(work, ["push", "--quiet", "origin", "feature/flaky"]); - - runGit(work, ["checkout", "--quiet", "-b", "overlay/critical", "fork/changes"]); - commitFile(work, "overlay.txt", "overlay\n", "overlay work"); - runGit(work, ["push", "--quiet", "origin", "overlay/critical"]); - - const oldForkTip = remoteTip(origin, "fork/changes"); - - // Advance fork/changes so both branches need a rebase. - runGit(work, ["checkout", "--quiet", "fork/changes"]); - commitFile(work, "changes.txt", "moved\n", "fork/changes advances"); - runGit(work, ["push", "--quiet", "origin", "fork/changes"]); - const newForkTip = remoteTip(origin, "fork/changes"); - - // Reject only feature/flaky pushes via a pre-receive hook (stale-lease stand-in). - const hookPath = NodePath.join(origin, "hooks", "pre-receive"); - NodeFS.writeFileSync( - hookPath, - `#!/bin/sh -while read oldrev newrev refname; do - if [ "$refname" = "refs/heads/feature/flaky" ]; then - echo "rejected flaky feature push" >&2 - exit 1 - fi -done -`, - { mode: 0o755 }, - ); - - const manifest: StackManifest = { - upstreamRemote: "upstream", - upstreamBranch: "main", - forkChangesBranch: "fork/changes", - integrationBranch: "fork/integration", - pullRequests: [{ number: 2, branch: "fork/changes" }], - integrationOverlays: [{ number: 10, branch: "overlay/critical" }], - }; - write( - NodePath.join(work, ".github", "pr-stack.json"), - `${JSON.stringify(manifest, undefined, 2)}\n`, - ); - - return { root, work, origin, oldForkTip, newForkTip, manifest }; - } - - it("continues rebasing other PRs when one force-with-lease push is rejected", async () => { - const fixture = createFeatureRebaseFixture(); - const beforeOverlay = remoteTip(fixture.origin, "overlay/critical"); - const beforeFlaky = remoteTip(fixture.origin, "feature/flaky"); - - const result = await rebaseOpenFeaturePullRequests({ - sourceRoot: fixture.work, - manifest: fixture.manifest, - push: true, - oldForkChangesTip: fixture.oldForkTip, - newForkChangesTip: fixture.newForkTip, - openPulls: [ - { - number: 96, - headBranch: "feature/flaky", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 10, - headBranch: "overlay/critical", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - ], - }); - - // Overlay still updates even though the ordinary feature push was rejected. - const afterOverlay = remoteTip(fixture.origin, "overlay/critical"); - assert.notEqual(afterOverlay, beforeOverlay); - assert.ok(isAncestor(fixture.origin, fixture.newForkTip, afterOverlay)); - assert.ok(result.updated.some((entry) => entry.branch === "overlay/critical")); - - // Flaky feature remains on the old tip and is recorded as a conflict. - assert.equal(remoteTip(fixture.origin, "feature/flaky"), beforeFlaky); - assert.ok( - result.conflicts.some( - (entry) => entry.branch === "feature/flaky" && /push failed|rejected/i.test(entry.message), - ), - ); - }); - - it("rebases registered overlays before ordinary feature PRs", async () => { - const fixture = createFeatureRebaseFixture(); - // No rejection hook: both should update; order is asserted via selectOpenFeature. - NodeFS.unlinkSync(NodePath.join(fixture.origin, "hooks", "pre-receive")); - const ordered = selectOpenFeaturePullRequests({ - expectedRepository: "patroza/t3code", - manifest: fixture.manifest, - openPulls: [ - { - number: 96, - headBranch: "feature/flaky", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - { - number: 10, - headBranch: "overlay/critical", - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - }, - ], - }); - assert.deepEqual( - ordered.map(({ branch }) => branch), - ["overlay/critical", "feature/flaky"], - ); - - const result = await rebaseOpenFeaturePullRequests({ - sourceRoot: fixture.work, - manifest: fixture.manifest, - push: true, - oldForkChangesTip: fixture.oldForkTip, - newForkChangesTip: fixture.newForkTip, - openPulls: ordered.map((entry) => ({ - number: entry.number, - headBranch: entry.branch, - baseBranch: "fork/changes", - headRepository: "patroza/t3code", - })), - }); - assert.equal(result.conflicts.length, 0); - assert.ok( - isAncestor(fixture.origin, fixture.newForkTip, remoteTip(fixture.origin, "overlay/critical")), - ); - assert.ok( - 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 deleted file mode 100644 index 157513bd803..00000000000 --- a/scripts/rebase-pr-stack.ts +++ /dev/null @@ -1,2204 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off -// @effect-diagnostics globalFetch:off -// @effect-diagnostics globalConsole:off - -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeURL from "node:url"; - -import { diskBackedWorkRoot, mkdtempDiskBacked } from "./lib/disk-backed-tmp.ts"; - -const EXPECTED_REPOSITORY = process.env.T3CODE_FORK_REPOSITORY ?? "patroza/t3code"; -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; -} - -/** - * Automatic conflict resolution for protected stack rebases. - * - * - `commit` is a full 40-char SHA for a one-shot replay of that exact commit, or `"*"` to - * match any commit on `branch` for `path` (durable across layer rewrites). - * - During `git rebase`, `ours` is the new base and `theirs` is the commit being replayed. - */ -export interface StackConflictResolution { - readonly branch: string; - /** Full 40-char SHA, or `"*"` for any commit on this branch+path. */ - readonly commit: string; - readonly path: string; - readonly strategy: "ours" | "theirs"; -} - -export interface StackManifest { - readonly upstreamRemote: string; - readonly upstreamBranch: string; - readonly forkChangesBranch: string; - readonly integrationBranch: string; - readonly pullRequests: ReadonlyArray; - readonly integrationOverlays: ReadonlyArray; - readonly conflictResolutions?: ReadonlyArray; -} - -export interface PullRequestSnapshot { - readonly number: number; - readonly state: string; - readonly headBranch: string; - readonly headOwner: string; - readonly baseBranch: string; - readonly isDraft: boolean; -} - -interface RebaseOperation { - readonly kind: "pull-request" | "integration"; - readonly index: number; - readonly branch: string; - readonly parentBranch: string; - readonly pullRequestNumber?: number; - readonly oldBase: string; - readonly oldTip: string; - readonly newBase: string; - readonly commits: ReadonlyArray; -} - -interface PersistedState { - readonly version: 1; - readonly sourceRoot: string; - readonly repoDir: string; - readonly originUrl: string; - readonly upstreamUrl: string; - readonly manifest: StackManifest; - readonly snapshots: Readonly>; - readonly upstreamTip: string; - readonly initialBaseForAll: boolean; - readonly newTips: Readonly>; - readonly nextIndex: number; - readonly currentOperation?: RebaseOperation | undefined; -} - -export interface StackRunOptions { - readonly sourceRoot?: string; - readonly manifestPath?: string; - readonly push: boolean; - readonly validatePullRequests?: boolean; - readonly pullRequests?: ReadonlyArray; - readonly preserveState?: boolean; - readonly initialBaseForAll?: boolean; - /** - * After each replayed commit lands during a layer rebase, typecheck packages - * touched by that commit. Fail the stack rewrite on the first red commit - * instead of stacking `fix(stack)` tips later. Requires `node_modules` in the - * rewrite worktree (install once before sync when enabling this). - */ - readonly verifyEachCommit?: boolean; - readonly beforePush?: (state: Readonly) => void | Promise; -} - -/** Repository roots whose source files must always be merged, never replaced wholesale. */ -const PRODUCT_WORKSPACE_ROOTS = new Set(["apps", "packages", "infra"]); - -export function isProductConflictPath(path: string): boolean { - const normalized = path.replaceAll("\\", "/"); - const segments = normalized.split("/"); - const isWorkspaceSource = - segments.length >= 4 && PRODUCT_WORKSPACE_ROOTS.has(segments[0] ?? "") && segments[2] === "src"; - return ( - normalized === "package.json" || - normalized.endsWith("/package.json") || - normalized.startsWith("scripts/") || - normalized.startsWith("oxlint-plugin-t3code/") || - isWorkspaceSource - ); -} - -function unsafeAutomaticResolutionMessage(path: string): string { - return ( - `Automatic whole-file conflict resolution is forbidden for product path ${path}. ` + - "Resolve it with a 3-way merge, preserve both product surfaces, run the focused behavior " + - "tests, and resume the preserved stack state." - ); -} - -export function assertSafeAutomaticConflictResolution(path: string): void { - if (isProductConflictPath(path)) { - throw new StackError(unsafeAutomaticResolutionMessage(path)); - } -} - -/** - * Map changed repo paths to pnpm filter names for commit-local typecheck. - * Config/docs/workflow-only commits return an empty list (no package gate). - */ -export function packagesForChangedPaths(paths: ReadonlyArray): ReadonlyArray { - const filters = new Set(); - for (const raw of paths) { - const path = raw.replaceAll("\\", "/"); - if (path.startsWith("packages/client-runtime/")) filters.add("@t3tools/client-runtime"); - else if (path.startsWith("packages/contracts/")) filters.add("@t3tools/contracts"); - else if (path.startsWith("packages/shared/")) filters.add("@t3tools/shared"); - else if (path.startsWith("packages/ssh/")) filters.add("@t3tools/ssh"); - else if (path.startsWith("packages/tailscale/")) filters.add("@t3tools/tailscale"); - else if (path.startsWith("packages/effect-acp/")) filters.add("effect-acp"); - else if (path.startsWith("packages/effect-codex-app-server/")) { - filters.add("effect-codex-app-server"); - } else if (path.startsWith("apps/server/")) filters.add("t3"); - else if (path.startsWith("apps/web/")) filters.add("@t3tools/web"); - else if (path.startsWith("apps/mobile/")) filters.add("@t3tools/mobile"); - else if (path.startsWith("apps/desktop/")) filters.add("@t3tools/desktop"); - else if (path.startsWith("apps/discord-bot/")) filters.add("@t3tools/discord-bot"); - else if (path.startsWith("apps/vscode/")) filters.add("t3-code"); - else if (path.startsWith("apps/marketing/")) filters.add("@t3tools/marketing"); - else if (path.startsWith("scripts/")) filters.add("@t3tools/scripts"); - else if (path.startsWith("oxlint-plugin-t3code/")) filters.add("@t3tools/oxlint-plugin-t3code"); - } - return [...filters].sort(); -} - -/** - * Typecheck packages touched by `HEAD` vs its first parent. Used as - * `git rebase --exec` and as the `verify-head` CLI entry. - */ -export function verifyReplayHead( - repoDir: string, - options?: { readonly stateDir?: string | undefined }, -): void { - const gitOpts = options?.stateDir === undefined ? {} : { stateDir: options.stateDir }; - const parent = run("git", ["rev-parse", "--verify", "HEAD^"], { - cwd: repoDir, - allowFailure: true, - ...gitOpts, - }); - if (parent.status !== 0) { - console.log("verify-head: root commit; skipping package typecheck"); - return; - } - const diff = git(repoDir, ["diff", "--name-only", "HEAD^", "HEAD"], gitOpts); - const paths = diff - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - const packages = packagesForChangedPaths(paths); - if (packages.length === 0) { - console.log( - `verify-head: ${git(repoDir, ["rev-parse", "--short", "HEAD"], gitOpts)} touches no package sources; ok`, - ); - return; - } - if (!NodeFS.existsSync(NodePath.join(repoDir, "node_modules"))) { - throw new StackError( - "verify-each-commit requires node_modules in the rewrite worktree. " + - "Run `CI= pnpm install --no-frozen-lockfile` in the worktree (or source tree with " + - "linked modules) before `sync --verify-each-commit`.", - options?.stateDir === undefined ? undefined : { stateDir: options.stateDir }, - ); - } - const sha = git(repoDir, ["rev-parse", "--short", "HEAD"], gitOpts); - const subject = git(repoDir, ["log", "-1", "--format=%s"], gitOpts); - const vpBin = NodePath.join(repoDir, "node_modules", ".bin", "vp"); - const vp = NodeFS.existsSync(vpBin) ? vpBin : `${vpBin}.cmd`; - console.log(`verify-head: ${sha} ${subject} → ${packages.join(", ")}`); - for (const pkg of packages) { - // Invoke Vite+ directly instead of `pnpm exec`: this preserves each - // workspace's real typecheck script without triggering an implicit install. - const result = run(vp, ["run", "--filter", pkg, "typecheck"], { - cwd: repoDir, - allowFailure: true, - ...gitOpts, - }); - if (result.status !== 0) { - throw new StackError( - `Commit ${sha} ("${subject}") failed typecheck for ${pkg}. ` + - `Fix the replayed commit (or the conflict resolution that produced it); ` + - `do not land a tip-only fix(stack) product patch.\n` + - `${(result.stderr || result.stdout).trim().slice(-1200)}`, - options?.stateDir === undefined ? undefined : { stateDir: options.stateDir }, - ); - } - } -} - -function thisScriptPath(): string { - return NodeURL.fileURLToPath(import.meta.url); -} - -export interface StackRunResult { - readonly stateDir: string; - readonly snapshots: Readonly>; - readonly newTips: Readonly>; - readonly upstreamTip: string; - readonly pushed: boolean; -} - -export class StackError extends Error { - readonly stateDir: string | undefined; - - constructor( - message: string, - options?: { readonly stateDir?: string | undefined; readonly cause?: unknown }, - ) { - super(message, options?.cause === undefined ? undefined : { cause: options.cause }); - this.name = new.target.name; - this.stateDir = options?.stateDir; - } -} - -export class RebaseConflictError extends StackError { - readonly pullRequestNumber: number | undefined; - readonly branch: string; - readonly parentBranch: string; - readonly commit: string; - readonly commitSubject: string; - readonly conflictingPaths: ReadonlyArray; - - constructor( - operation: RebaseOperation, - stateDir: string, - commit: string, - commitSubject: string, - conflictingPaths: ReadonlyArray, - ) { - const label = - operation.pullRequestNumber === undefined - ? `integration branch ${operation.branch}` - : `PR #${operation.pullRequestNumber} (${operation.branch})`; - super( - `Rebase conflict in ${label} onto ${operation.parentBranch} while replaying ${commit}: ${conflictingPaths.join(", ")}`, - { stateDir }, - ); - this.pullRequestNumber = operation.pullRequestNumber; - this.branch = operation.branch; - this.parentBranch = operation.parentBranch; - this.commit = commit; - this.commitSubject = commitSubject; - this.conflictingPaths = conflictingPaths; - } -} - -class GitCommandError extends StackError { - readonly args: ReadonlyArray; - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; - - constructor( - args: ReadonlyArray, - cwd: string, - result: NodeChildProcess.SpawnSyncReturns, - stateDir?: string, - ) { - const stderr = result.stderr.trim(); - super(`git ${args.join(" ")} failed in ${cwd}${stderr ? `: ${stderr}` : ""}`, { stateDir }); - this.args = args; - this.stdout = result.stdout; - this.stderr = result.stderr; - this.exitCode = result.status ?? 1; - } -} - -function stripAnsi(text: string): string { - return text.replace(/\u001b\[[0-9;?]*[a-zA-Z]/g, ""); -} - -function run( - executable: string, - args: ReadonlyArray, - options: { - readonly cwd: string; - readonly allowFailure?: boolean; - readonly env?: NodeJS.ProcessEnv; - readonly stateDir?: string; - }, -): NodeChildProcess.SpawnSyncReturns { - const baseEnv: NodeJS.ProcessEnv = { - ...process.env, - GIT_TERMINAL_PROMPT: "0", - // Keep FORCE_COLOR as-is when set; force "0" breaks some t3 gh-wrapper list queries. - // Strip ANSI from stdout/stderr so callers can parse `gh --json`. - ...options.env, - }; - const result = NodeChildProcess.spawnSync(executable, [...args], { - cwd: options.cwd, - encoding: "utf8", - env: baseEnv, - }); - if (result.stdout) result.stdout = stripAnsi(result.stdout); - if (result.stderr) result.stderr = stripAnsi(result.stderr); - if (result.error) { - throw new StackError(`Unable to run ${executable}: ${result.error.message}`, { - stateDir: options.stateDir, - cause: result.error, - }); - } - if (!options.allowFailure && result.status !== 0) { - if (executable === "git") { - throw new GitCommandError(args, options.cwd, result, options.stateDir); - } - throw new StackError( - `${executable} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`, - { stateDir: options.stateDir }, - ); - } - return result; -} - -function git( - cwd: string, - args: ReadonlyArray, - options: { - readonly allowFailure?: boolean; - readonly env?: NodeJS.ProcessEnv; - readonly stateDir?: string; - } = {}, -): string { - return run("git", args, { cwd, ...options }).stdout.trim(); -} - -function assertObject(value: unknown, label: string): asserts value is Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new StackError(`${label} must be an object.`); - } -} - -export function parseManifest(source: string): StackManifest { - let value: unknown; - try { - value = JSON.parse(source); - } catch (cause) { - throw new StackError("The PR stack manifest is not valid JSON.", { cause }); - } - assertObject(value, "The PR stack manifest"); - const { - upstreamRemote, - upstreamBranch, - forkChangesBranch, - integrationBranch, - pullRequests, - integrationOverlays = [], - conflictResolutions = [], - } = value; - if ( - typeof upstreamRemote !== "string" || - upstreamRemote.length === 0 || - typeof upstreamBranch !== "string" || - upstreamBranch.length === 0 || - typeof forkChangesBranch !== "string" || - forkChangesBranch.length === 0 || - typeof integrationBranch !== "string" || - integrationBranch.length === 0 || - !Array.isArray(pullRequests) || - !Array.isArray(integrationOverlays) || - !Array.isArray(conflictResolutions) - ) { - throw new StackError("The PR stack manifest has missing or invalid fields."); - } - - const parsedPullRequests = pullRequests.map((entry, index) => { - assertObject(entry, `pullRequests[${index}]`); - if ( - !Number.isSafeInteger(entry.number) || - Number(entry.number) <= 0 || - typeof entry.branch !== "string" || - entry.branch.length === 0 - ) { - throw new StackError(`pullRequests[${index}] has an invalid number or branch.`); - } - return { number: Number(entry.number), branch: entry.branch }; - }); - const parsedIntegrationOverlays = integrationOverlays.map((entry, index) => { - assertObject(entry, `integrationOverlays[${index}]`); - if ( - !Number.isSafeInteger(entry.number) || - Number(entry.number) <= 0 || - typeof entry.branch !== "string" || - entry.branch.length === 0 - ) { - throw new StackError(`integrationOverlays[${index}] has an invalid number or branch.`); - } - return { number: Number(entry.number), branch: entry.branch }; - }); - const parsedConflictResolutions = conflictResolutions.map((entry, index) => { - assertObject(entry, `conflictResolutions[${index}]`); - const branch = entry.branch; - const commitValue = entry.commit; - const path = entry.path; - const strategy = entry.strategy; - const commitOk = - typeof commitValue === "string" && - (commitValue === "*" || /^[0-9a-f]{40}$/i.test(commitValue)); - if ( - typeof branch !== "string" || - branch.length === 0 || - !commitOk || - typeof path !== "string" || - path.length === 0 || - NodePath.isAbsolute(path) || - path.split("/").includes("..") || - (strategy !== "ours" && strategy !== "theirs") - ) { - throw new StackError( - `conflictResolutions[${index}] is invalid (need branch, commit SHA or "*", relative path, ours|theirs).`, - ); - } - // commitValue narrowed by commitOk (string + shape check). - const commit = commitValue as string; - try { - assertSafeAutomaticConflictResolution(path); - } catch (error) { - if (!(error instanceof StackError)) throw error; - throw new StackError(`conflictResolutions[${index}] is unsafe. ${error.message}`); - } - return { - branch, - commit: commit === "*" ? "*" : commit.toLowerCase(), - path, - strategy: strategy as "ours" | "theirs", - } satisfies StackConflictResolution; - }); - - const managed = [...parsedPullRequests, ...parsedIntegrationOverlays]; - const numbers = new Set(managed.map(({ number }) => number)); - const branches = new Set(managed.map(({ branch }) => branch)); - if (numbers.size !== managed.length || branches.size !== managed.length) { - throw new StackError("The PR stack manifest contains duplicate PR numbers or branches."); - } - if (branches.has(integrationBranch)) { - throw new StackError("The integration branch must not also be a PR branch."); - } - if (parsedPullRequests.at(-1) && parsedPullRequests.at(-1)?.branch !== forkChangesBranch) { - throw new StackError( - `The top PR branch must be the fork changes branch (${forkChangesBranch}).`, - ); - } - - return { - upstreamRemote, - upstreamBranch, - forkChangesBranch, - integrationBranch, - pullRequests: parsedPullRequests, - integrationOverlays: parsedIntegrationOverlays, - ...(parsedConflictResolutions.length > 0 - ? { conflictResolutions: parsedConflictResolutions } - : {}), - }; -} - -export function readManifest( - sourceRoot: string, - manifestPath = NodePath.join(sourceRoot, ".github", "pr-stack.json"), -): StackManifest { - return parseManifest(NodeFS.readFileSync(manifestPath, "utf8")); -} - -function expectedBase(manifest: StackManifest, index: number): string { - return index === 0 - ? manifest.upstreamBranch - : (manifest.pullRequests[index - 1]?.branch ?? manifest.upstreamBranch); -} - -export function validatePullRequestSnapshots( - manifest: StackManifest, - pullRequests: ReadonlyArray, -): void { - for (const [index, expected] of manifest.pullRequests.entries()) { - const actual = pullRequests.find(({ number }) => number === expected.number); - if (!actual || actual.state !== "open") { - throw new StackError(`Manifest PR #${expected.number} is not open.`); - } - if (!actual.isDraft) { - throw new StackError(`Managed PR #${expected.number} must remain a draft.`); - } - if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { - throw new StackError( - `PR #${expected.number} is owned by ${actual.headOwner}, expected ${EXPECTED_REPOSITORY.split("/")[0]}.`, - ); - } - if (actual.headBranch !== expected.branch) { - throw new StackError( - `PR #${expected.number} uses ${actual.headBranch}, expected ${expected.branch}.`, - ); - } - const base = expectedBase(manifest, index); - if (actual.baseBranch !== base) { - throw new StackError( - `PR #${expected.number} is based on ${actual.baseBranch}, expected ${base}.`, - ); - } - } - for (const expected of manifest.integrationOverlays) { - const actual = pullRequests.find(({ number }) => number === expected.number); - if (!actual || actual.state !== "open") { - throw new StackError(`Integration overlay PR #${expected.number} is not open.`); - } - if (!actual.isDraft) { - throw new StackError(`Integration overlay PR #${expected.number} must remain a draft.`); - } - if (actual.headOwner !== EXPECTED_REPOSITORY.split("/")[0]) { - throw new StackError(`Integration overlay PR #${expected.number} is not owned by this fork.`); - } - if (actual.headBranch !== expected.branch) { - throw new StackError( - `Integration overlay PR #${expected.number} uses ${actual.headBranch}, expected ${expected.branch}.`, - ); - } - if (actual.baseBranch !== manifest.forkChangesBranch) { - throw new StackError( - `Integration overlay PR #${expected.number} is based on ${actual.baseBranch}, expected ${manifest.forkChangesBranch}.`, - ); - } - } -} - -interface GitHubPullResponse { - readonly number?: unknown; - readonly state?: unknown; - readonly head?: { - readonly ref?: unknown; - readonly user?: { readonly login?: unknown } | null; - readonly repo?: { readonly full_name?: unknown } | null; - } | null; - readonly base?: { readonly ref?: unknown } | null; - readonly draft?: unknown; -} - -function githubToken(): string { - const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN; - if (!token) { - throw new StackError("GH_TOKEN or GITHUB_TOKEN is required to validate pull requests."); - } - return token; -} - -async function githubRequest(path: string): Promise { - const response = await fetch(`https://api.github.com${path}`, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${githubToken()}`, - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "t3code-rebase-pr-stack", - }, - }); - if (!response.ok) { - throw new StackError(`GitHub API request ${path} failed with HTTP ${response.status}.`); - } - return response.json(); -} - -export async function fetchPullRequestSnapshots( - manifest: StackManifest, -): Promise> { - const openResponses: Array = []; - for (let page = 1; ; page += 1) { - const value = await githubRequest( - `/repos/${EXPECTED_REPOSITORY}/pulls?state=open&per_page=100&page=${page}`, - ); - if (!Array.isArray(value)) { - throw new StackError("GitHub returned an invalid open pull request response."); - } - openResponses.push(...(value as Array)); - if (value.length < 100) break; - } - - const byNumber = new Map(); - for (const response of openResponses) { - if (typeof response.number === "number") byNumber.set(response.number, response); - } - for (const { number } of [...manifest.pullRequests, ...manifest.integrationOverlays]) { - if (!byNumber.has(number)) { - const value = await githubRequest(`/repos/${EXPECTED_REPOSITORY}/pulls/${number}`); - assertObject(value, `GitHub PR #${number}`); - byNumber.set(number, value as GitHubPullResponse); - } - } - - return [...byNumber.values()].map((response) => { - const number = response.number; - const state = response.state; - const headBranch = response.head?.ref; - const headOwner = response.head?.user?.login; - const headRepository = response.head?.repo?.full_name; - const baseBranch = response.base?.ref; - const isDraft = response.draft; - if ( - typeof number !== "number" || - typeof state !== "string" || - typeof headBranch !== "string" || - typeof headOwner !== "string" || - typeof baseBranch !== "string" || - typeof isDraft !== "boolean" - ) { - throw new StackError("GitHub returned an invalid pull request record."); - } - if (headRepository !== EXPECTED_REPOSITORY) { - return { - number, - state, - headBranch, - headOwner: typeof headRepository === "string" ? headRepository : headOwner, - baseBranch, - isDraft, - }; - } - return { number, state, headBranch, headOwner, baseBranch, isDraft }; - }); -} - -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, -): Promise { - validatePullRequestSnapshots(manifest, supplied ?? (await fetchPullRequestSnapshots(manifest))); -} - -function resolveRemoteUrl(sourceRoot: string, remote: string): string { - const url = git(sourceRoot, ["remote", "get-url", remote]); - if (!url) throw new StackError(`Remote ${remote} has no URL.`); - return url; -} - -function writeState(stateDir: string, state: PersistedState): void { - NodeFS.writeFileSync( - NodePath.join(stateDir, STATE_FILE), - `${JSON.stringify(state, undefined, 2)}\n`, - "utf8", - ); -} - -function readState(stateDir: string): PersistedState { - const statePath = NodePath.join(stateDir, STATE_FILE); - let value: unknown; - try { - value = JSON.parse(NodeFS.readFileSync(statePath, "utf8")); - } catch (cause) { - throw new StackError(`Unable to read rebase state from ${statePath}.`, { - stateDir, - cause, - }); - } - assertObject(value, "Rebase state"); - if ( - value.version !== 1 || - typeof value.sourceRoot !== "string" || - typeof value.repoDir !== "string" || - typeof value.originUrl !== "string" || - typeof value.upstreamUrl !== "string" || - typeof value.upstreamTip !== "string" || - typeof value.nextIndex !== "number" - ) { - throw new StackError(`Invalid rebase state in ${statePath}.`, { stateDir }); - } - return value as unknown as PersistedState; -} - -function updateState( - stateDir: string, - state: PersistedState, - patch: Partial, -): PersistedState { - const updated = { ...state, ...patch }; - writeState(stateDir, updated); - return updated; -} - -export function rewriteInstallArgs(): ReadonlyArray { - return ["install", "--frozen-lockfile", "--prefer-offline"]; -} - -function initializeState( - sourceRoot: string, - manifest: StackManifest, - initialBaseForAll: boolean, - verifyEachCommit = false, -): { readonly stateDir: string; readonly state: PersistedState } { - // Disk-backed: full git clones must not land on tmpfs /tmp (host) or RAM root (t3vm). - const stateDir = mkdtempDiskBacked("rebase-pr-stack-", { - subdir: "rebase-work", - envVar: "T3_REBASE_WORK_ROOT", - }); - const repoDir = NodePath.join(stateDir, "repo"); - NodeFS.mkdirSync(repoDir); - const originUrl = resolveRemoteUrl(sourceRoot, "origin"); - const upstreamUrl = resolveRemoteUrl(sourceRoot, manifest.upstreamRemote); - - try { - git(repoDir, ["init", "--quiet"], { stateDir }); - git(repoDir, ["config", "user.name", "T3 Code PR Stack"], { stateDir }); - git( - repoDir, - ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], - { - stateDir, - }, - ); - git(repoDir, ["config", "commit.gpgsign", "false"], { stateDir }); - git(repoDir, ["remote", "add", "origin", originUrl], { stateDir }); - git(repoDir, ["remote", "add", manifest.upstreamRemote, upstreamUrl], { stateDir }); - - const originBranches = [ - manifest.upstreamBranch, - ...manifest.pullRequests.map(({ branch }) => branch), - manifest.integrationBranch, - ]; - git( - repoDir, - [ - "fetch", - "--quiet", - "--no-tags", - "origin", - ...originBranches.map((branch) => `+refs/heads/${branch}:refs/remotes/origin/${branch}`), - ], - { stateDir }, - ); - git( - repoDir, - [ - "fetch", - "--quiet", - "--no-tags", - manifest.upstreamRemote, - `+refs/heads/${manifest.upstreamBranch}:refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`, - ], - { stateDir }, - ); - - const snapshots = Object.fromEntries( - originBranches.map((branch) => [ - branch, - git(repoDir, ["rev-parse", `refs/remotes/origin/${branch}`], { stateDir }), - ]), - ); - const upstreamTip = git( - repoDir, - ["rev-parse", `refs/remotes/${manifest.upstreamRemote}/${manifest.upstreamBranch}`], - { stateDir }, - ); - const originMain = snapshots[manifest.upstreamBranch]; - if (!originMain) throw new StackError("The origin main snapshot is missing.", { stateDir }); - const ancestorStatus = run("git", ["merge-base", "--is-ancestor", originMain, upstreamTip], { - cwd: repoDir, - allowFailure: true, - stateDir, - }).status; - if (ancestorStatus !== 0) { - throw new StackError( - `origin/${manifest.upstreamBranch} (${originMain}) has diverged from ${manifest.upstreamRemote}/${manifest.upstreamBranch} (${upstreamTip}); refusing to update fork main.`, - { stateDir }, - ); - } - if (verifyEachCommit) { - const integrationTip = snapshots[manifest.integrationBranch]; - if (!integrationTip) - throw new StackError("The integration snapshot is missing.", { stateDir }); - // Install once from the latest composed tree so workspace links point - // into this clone and the dependency set is a superset of replayed layers. - git(repoDir, ["checkout", "--quiet", "--detach", integrationTip], { stateDir }); - const install = run("pnpm", rewriteInstallArgs(), { - cwd: repoDir, - allowFailure: true, - env: { CI: "" }, - stateDir, - }); - if (install.status !== 0) { - throw new StackError(`Unable to prepare rewrite dependencies.\n${install.output}`, { - stateDir, - }); - } - } - - const state: PersistedState = { - version: 1, - sourceRoot, - repoDir, - originUrl, - upstreamUrl, - manifest, - snapshots, - upstreamTip, - initialBaseForAll, - newTips: {}, - nextIndex: 0, - }; - writeState(stateDir, state); - return { stateDir, state }; - } catch (error) { - if (error instanceof StackError && error.stateDir) throw error; - throw new StackError(error instanceof Error ? error.message : String(error), { - stateDir, - cause: error, - }); - } -} - -function revList(repoDir: string, range: string, stateDir: string): ReadonlyArray { - const output = git(repoDir, ["rev-list", "--reverse", range], { stateDir }); - return output ? output.split("\n") : []; -} - -function makeOperation(state: PersistedState): RebaseOperation | undefined { - const { manifest, snapshots, newTips, nextIndex, initialBaseForAll } = state; - if (nextIndex < manifest.pullRequests.length) { - const pullRequest = manifest.pullRequests[nextIndex]; - if (!pullRequest) return undefined; - const parentBranch = expectedBase(manifest, nextIndex); - const oldTip = snapshots[pullRequest.branch]; - const desiredOldBase = - snapshots[nextIndex === 0 || initialBaseForAll ? manifest.upstreamBranch : parentBranch]; - const newBase = nextIndex === 0 ? state.upstreamTip : newTips[parentBranch]; - if (!desiredOldBase || !oldTip || !newBase) { - throw new StackError(`Missing snapshot while preparing PR #${pullRequest.number}.`); - } - // A newly inserted middle layer is not yet an ancestor of its old child, - // and an updated parent may have moved after its child was last rebased. - // Replay from their actual common ancestor instead of assuming the desired - // parent tip was already present in the child. - const oldBase = - nextIndex === 0 || initialBaseForAll - ? desiredOldBase - : git(state.repoDir, ["merge-base", desiredOldBase, oldTip], { - stateDir: NodePath.dirname(state.repoDir), - }); - return { - kind: "pull-request", - index: nextIndex, - branch: pullRequest.branch, - parentBranch, - pullRequestNumber: pullRequest.number, - oldBase, - oldTip, - newBase, - commits: revList(state.repoDir, `${oldBase}..${oldTip}`, NodePath.dirname(state.repoDir)), - }; - } - if (nextIndex === manifest.pullRequests.length) { - const top = manifest.pullRequests.at(-1); - if (!top) return undefined; - const desiredOldBase = snapshots[top.branch]; - const oldTip = snapshots[manifest.integrationBranch]; - const newBase = newTips[top.branch]; - if (!desiredOldBase || !oldTip || !newBase) { - throw new StackError("Missing snapshot while preparing the integration branch."); - } - const oldBase = git(state.repoDir, ["merge-base", desiredOldBase, oldTip], { - stateDir: NodePath.dirname(state.repoDir), - }); - return { - kind: "integration", - index: nextIndex, - branch: manifest.integrationBranch, - parentBranch: top.branch, - oldBase, - oldTip, - newBase, - commits: revList(state.repoDir, `${oldBase}..${oldTip}`, NodePath.dirname(state.repoDir)), - }; - } - return undefined; -} - -function rebaseInProgress(repoDir: string): boolean { - const gitDir = git(repoDir, ["rev-parse", "--git-dir"]); - const absoluteGitDir = NodePath.resolve(repoDir, gitDir); - return ( - NodeFS.existsSync(NodePath.join(absoluteGitDir, "rebase-merge")) || - NodeFS.existsSync(NodePath.join(absoluteGitDir, "rebase-apply")) - ); -} - -export function shouldAttemptConflictResolution( - conflictingPaths: ReadonlyArray, - rebaseHead: string, -): boolean { - return conflictingPaths.length > 0 && rebaseHead.length > 0; -} - -function conflictError( - stateDir: string, - state: PersistedState, - operation: RebaseOperation, -): RebaseConflictError { - const conflictsOutput = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], { - stateDir, - }); - const conflictingPaths = conflictsOutput ? conflictsOutput.split("\n") : []; - const commit = - git(state.repoDir, ["rev-parse", "--verify", "REBASE_HEAD"], { - allowFailure: true, - stateDir, - }) || - operation.commits[0] || - ZERO_SHA; - const commitSubject = - commit === ZERO_SHA - ? "unknown commit" - : git(state.repoDir, ["show", "-s", "--format=%s", commit], { - allowFailure: true, - stateDir, - }); - const subject = commitSubject || "unknown commit"; - if (conflictingPaths.length > 0) { - console.error(conflictResolutionManifestSnippet(operation.branch, commit, conflictingPaths)); - } - return new RebaseConflictError(operation, stateDir, commit, subject, conflictingPaths); -} - -function matchConflictResolution( - configured: ReadonlyArray, - branch: string, - commit: string, - path: string, -): StackConflictResolution | undefined { - const exact = configured.find( - (entry) => - entry.branch === branch && - entry.commit !== "*" && - entry.commit === commit && - entry.path === path, - ); - if (exact) return exact; - return configured.find( - (entry) => entry.branch === branch && entry.commit === "*" && entry.path === path, - ); -} - -function applyConfiguredConflictResolutions( - stateDir: string, - state: PersistedState, - operation: RebaseOperation, -): boolean { - const conflictingPaths = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], { - stateDir, - }) - .split("\n") - .filter(Boolean); - const commit = git(state.repoDir, ["rev-parse", "--verify", "REBASE_HEAD"], { - stateDir, - }).toLowerCase(); - const configured = state.manifest.conflictResolutions ?? []; - const resolutions = conflictingPaths.map((path) => - matchConflictResolution(configured, operation.branch, commit, path), - ); - if (resolutions.some((entry) => entry === undefined)) { - return false; - } - - for (const resolution of resolutions) { - if (!resolution) continue; - try { - assertSafeAutomaticConflictResolution(resolution.path); - } catch (error) { - if (!(error instanceof StackError)) throw error; - throw new StackError( - `Refusing unsafe persisted conflict resolution for ${operation.branch}. ${error.message}`, - { stateDir }, - ); - } - git(state.repoDir, ["checkout", `--${resolution.strategy}`, "--", resolution.path], { - stateDir, - }); - git(state.repoDir, ["add", "--", resolution.path], { stateDir }); - const scope = resolution.commit === "*" ? "any-commit" : commit.slice(0, 12); - console.log( - `Applied configured ${resolution.strategy} resolution for ${operation.branch} ${scope} ${resolution.path}`, - ); - } - return true; -} - -function finishOperation( - stateDir: string, - state: PersistedState, - operation: RebaseOperation, -): PersistedState { - const tip = git(state.repoDir, ["rev-parse", "HEAD"], { stateDir }); - return updateState(stateDir, state, { - newTips: { ...state.newTips, [operation.branch]: tip }, - nextIndex: operation.index + 1, - currentOperation: undefined, - }); -} - -function startOperation( - stateDir: string, - state: PersistedState, - operation: RebaseOperation, - options?: { readonly verifyEachCommit?: boolean }, -): PersistedState { - let updated = updateState(stateDir, state, { currentOperation: operation }); - if (operation.commits.length === 0) { - git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.newBase], { stateDir }); - return finishOperation(stateDir, updated, operation); - } - if (operation.oldBase === operation.newBase) { - git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.oldTip], { stateDir }); - if (options?.verifyEachCommit === true) { - // No rewrite, but still gate the layer tip when verifying a full stack run. - verifyReplayHead(updated.repoDir, { stateDir }); - } - return finishOperation(stateDir, updated, operation); - } - git(updated.repoDir, ["checkout", "--quiet", "--detach", operation.oldTip], { stateDir }); - const rebaseArgs = [ - "-c", - "commit.gpgsign=false", - "rebase", - "--onto", - operation.newBase, - operation.oldBase, - operation.oldTip, - ]; - if (options?.verifyEachCommit === true) { - // Run after each successfully replayed commit (including post-conflict continues). - rebaseArgs.push("--exec", `node ${JSON.stringify(thisScriptPath())} verify-head`); - } - let result = run("git", rebaseArgs, { - cwd: updated.repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - stateDir, - }); - while (result.status !== 0 && rebaseInProgress(updated.repoDir)) { - const conflictingPaths = git(updated.repoDir, ["diff", "--name-only", "--diff-filter=U"], { - allowFailure: true, - stateDir, - }) - .split("\n") - .filter(Boolean); - const rebaseHead = git(updated.repoDir, ["rev-parse", "--verify", "REBASE_HEAD"], { - allowFailure: true, - stateDir, - }); - // A failed rebase --exec has rebase state but no conflict/REBASE_HEAD. - // Preserve its original verifier output instead of masking it as a conflict. - if (!shouldAttemptConflictResolution(conflictingPaths, rebaseHead)) break; - if (!applyConfiguredConflictResolutions(stateDir, updated, operation)) { - throw conflictError(stateDir, updated, operation); - } - result = run("git", ["-c", "commit.gpgsign=false", "rebase", "--continue"], { - cwd: updated.repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true" }, - stateDir, - }); - } - if (result.status !== 0) { - throw new GitCommandError( - ["rebase", "--onto", operation.newBase, operation.oldBase, operation.oldTip], - updated.repoDir, - result, - stateDir, - ); - } - updated = finishOperation(stateDir, updated, operation); - return updated; -} - -function continueOperations( - stateDir: string, - initialState: PersistedState, - options?: { readonly verifyEachCommit?: boolean }, -): PersistedState { - let state = initialState; - for (;;) { - const operation = makeOperation(state); - if (!operation) return state; - state = startOperation(stateDir, state, operation, options); - } -} - -function validateAncestry( - repoDir: string, - parent: string, - child: string, - message: string, - stateDir: string, -): void { - const result = run("git", ["merge-base", "--is-ancestor", parent, child], { - cwd: repoDir, - allowFailure: true, - stateDir, - }); - if (result.status !== 0) throw new StackError(message, { stateDir }); -} - -function validateResult(stateDir: string, state: PersistedState): void { - let parent = state.upstreamTip; - for (const pullRequest of state.manifest.pullRequests) { - const child = state.newTips[pullRequest.branch]; - if (!child) - throw new StackError(`No rewritten tip exists for PR #${pullRequest.number}.`, { stateDir }); - validateAncestry( - state.repoDir, - parent, - child, - `PR #${pullRequest.number} does not contain its rewritten parent.`, - stateDir, - ); - const count = Number( - git(state.repoDir, ["rev-list", "--count", `${parent}..${child}`], { stateDir }), - ); - if (count < 1) { - throw new StackError( - `PR #${pullRequest.number} became empty after rebasing; its commits may already have landed upstream.`, - { stateDir }, - ); - } - const mergeCount = Number( - git(state.repoDir, ["rev-list", "--count", "--merges", `${parent}..${child}`], { stateDir }), - ); - if (mergeCount > 0) { - throw new StackError(`PR #${pullRequest.number} contains a merge commit after rebasing.`, { - stateDir, - }); - } - parent = child; - } - const integrationTip = state.newTips[state.manifest.integrationBranch]; - if (!integrationTip) throw new StackError("No rewritten integration tip exists.", { stateDir }); - validateAncestry( - state.repoDir, - parent, - integrationTip, - "The integration branch does not contain the rewritten top PR.", - stateDir, - ); -} - -function pushResult(stateDir: string, state: PersistedState): void { - const branches = [ - state.manifest.upstreamBranch, - ...state.manifest.pullRequests.map(({ branch }) => branch), - state.manifest.integrationBranch, - ]; - const tips: Record = { - ...state.newTips, - [state.manifest.upstreamBranch]: state.upstreamTip, - }; - const args = ["push", "--atomic", "origin"]; - for (const branch of branches) { - const oldSha = state.snapshots[branch]; - if (!oldSha) throw new StackError(`No lease snapshot exists for ${branch}.`, { stateDir }); - args.push(`--force-with-lease=refs/heads/${branch}:${oldSha}`); - } - for (const branch of branches) { - const tip = tips[branch]; - if (!tip) throw new StackError(`No push tip exists for ${branch}.`, { stateDir }); - args.push(`${tip}:refs/heads/${branch}`); - } - git(state.repoDir, args, { stateDir }); -} - -function cleanupState(stateDir: string): void { - NodeFS.rmSync(stateDir, { recursive: true, force: true }); -} - -async function finishRun( - stateDir: string, - state: PersistedState, - options: Pick, -): Promise { - validateResult(stateDir, state); - if (options.push) { - await options.beforePush?.(state); - pushResult(stateDir, state); - } - const result: StackRunResult = { - stateDir, - snapshots: state.snapshots, - newTips: state.newTips, - upstreamTip: state.upstreamTip, - pushed: options.push, - }; - if (!options.preserveState) cleanupState(stateDir); - return result; -} - -/** - * Open PRs that should ride along when `fork/changes` is rewritten. - * Excludes stack provenance branches (tim/candidates/changes) and other-repo heads. - * Registered integration overlays are ordered first so a later ordinary-feature - * push failure cannot block the compose step that depends on them. - */ -export function selectOpenFeaturePullRequests(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<{ 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 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.headBranch, - ); - const rightIndex = input.manifest.integrationOverlays.findIndex( - (overlay) => overlay.branch === right.headBranch, - ); - return leftIndex - rightIndex; - }); - 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 { - readonly updated: ReadonlyArray<{ readonly number: number; readonly branch: string }>; - readonly conflicts: ReadonlyArray<{ - readonly number: number; - readonly branch: string; - readonly message: string; - }>; - readonly skipped: ReadonlyArray<{ - readonly number: number; - readonly branch: string; - readonly reason: string; - }>; -} - -/** - * After `fork/changes` is rewritten, rebase every open feature PR that targets it - * (including registered integration overlays). Uses `git rebase --onto newBase oldBase` - * and force-with-lease pushes. - * - * Per-PR isolation: a conflict or stale lease on one branch is recorded and the - * loop continues. That is required so a racing ordinary feature push cannot - * strand integration overlays and fail the subsequent compose step. - */ -export async function rebaseOpenFeaturePullRequests(options: { - readonly sourceRoot?: string; - readonly manifest?: StackManifest; - readonly push: boolean; - readonly oldForkChangesTip: string; - readonly newForkChangesTip: string; - readonly openPulls?: ReadonlyArray<{ - readonly number: number; - readonly headBranch: string; - 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); - const openPulls = - options.openPulls ?? - (await fetchPullRequestSnapshots(manifest)).map((snapshot) => ({ - number: snapshot.number, - headBranch: snapshot.headBranch, - baseBranch: snapshot.baseBranch, - headRepository: snapshot.headOwner.includes("/") - ? snapshot.headOwner - : `${snapshot.headOwner}/${EXPECTED_REPOSITORY.split("/")[1] ?? "t3code"}`, - })); - - 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 }> = []; - const skipped: Array<{ number: number; branch: string; reason: string }> = []; - - if (features.length === 0) { - return { updated, conflicts, skipped }; - } - - const workDir = mkdtempDiskBacked("rebase-feature-prs-", { - subdir: "rebase-work", - envVar: "T3_REBASE_WORK_ROOT", - }); - const repoDir = NodePath.join(workDir, "repo"); - NodeFS.mkdirSync(repoDir, { recursive: true }); - const originUrl = resolveRemoteUrl(sourceRoot, "origin"); - git(repoDir, ["init", "--quiet"]); - git(repoDir, ["config", "user.name", "T3 Code PR Stack"]); - git(repoDir, ["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]); - git(repoDir, ["config", "commit.gpgsign", "false"]); - git(repoDir, ["remote", "add", "origin", originUrl]); - - const branchesToFetch = [ - manifest.forkChangesBranch, - ...new Set(features.flatMap(({ branch, baseBranch }) => [baseBranch, branch])), - ]; - git(repoDir, [ - "fetch", - "--quiet", - "--no-tags", - "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, [ - "rev-parse", - `refs/remotes/origin/${manifest.forkChangesBranch}`, - ]); - const forkChangesBase = - fetchedForkTip === options.newForkChangesTip || - run("git", ["cat-file", "-e", `${options.newForkChangesTip}^{commit}`], { - cwd: repoDir, - allowFailure: true, - }).status !== 0 - ? 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, - }); - if (!remoteTip) { - skipped.push({ - number: feature.number, - 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, - }); - if (hasNewBase.status === 0) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: `already based on ${feature.baseBranch}`, - }); - rewrittenTips.set(feature.branch, remoteTip); - continue; - } - - // 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(), - ), - isAncestorOfHead: (tip) => - run("git", ["merge-base", "--is-ancestor", tip, remoteTip], { - cwd: repoDir, - allowFailure: true, - }).status === 0, - }); - - if (recoveredOldBase === null) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: `cannot recover old ${feature.baseBranch} tip (no known historical base tip is an ancestor of this head)`, - }); - blockedBranches.add(feature.branch); - 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 }); - } - 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"), - }); - blockedBranches.add(feature.branch); - continue; - } - - const newTip = git(repoDir, ["rev-parse", "HEAD"]); - if (newTip === remoteTip) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: "rebase produced identical tip", - }); - rewrittenTips.set(feature.branch, remoteTip); - continue; - } - - if (options.push) { - const pushResult = run( - "git", - [ - "push", - `--force-with-lease=refs/heads/${feature.branch}:${remoteTip}`, - "origin", - `${newTip}:refs/heads/${feature.branch}`, - ], - { cwd: repoDir, allowFailure: true }, - ); - if (pushResult.status !== 0) { - // Concurrent automation may have already rebased this branch onto the - // new base; re-fetch and treat that as success-equivalent rather than - // aborting remaining PRs (especially registered overlays). - git(repoDir, [ - "fetch", - "--quiet", - "origin", - `+refs/heads/${feature.branch}:refs/remotes/origin/${feature.branch}`, - ]); - const latestRemote = git( - repoDir, - ["rev-parse", `refs/remotes/origin/${feature.branch}`], - { allowFailure: true }, - ); - const alreadyBased = - latestRemote !== "" && - run("git", ["merge-base", "--is-ancestor", newBase, latestRemote], { - cwd: repoDir, - allowFailure: true, - }).status === 0; - if (alreadyBased) { - skipped.push({ - number: feature.number, - branch: feature.branch, - reason: `remote already based on ${feature.baseBranch} after concurrent update`, - }); - rewrittenTips.set(feature.branch, latestRemote); - continue; - } - conflicts.push({ - number: feature.number, - branch: feature.branch, - message: `push failed: ${stripAnsi( - 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 }); - } - conflicts.push({ - number: feature.number, - branch: feature.branch, - message: error instanceof Error ? error.message : String(error), - }); - blockedBranches.add(feature.branch); - } - } - - // Best-effort cleanup - try { - NodeFS.rmSync(workDir, { recursive: true, force: true }); - } catch { - // ignore - } - - return { updated, conflicts, skipped }; -} - -export async function syncStack(options: StackRunOptions): Promise { - const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); - const manifest = readManifest(sourceRoot, options.manifestPath); - if (options.validatePullRequests !== false) { - await validatePullRequests(manifest, options.pullRequests); - } - const { stateDir, state } = initializeState( - sourceRoot, - manifest, - options.initialBaseForAll === true, - options.verifyEachCommit === true, - ); - const completed = continueOperations(stateDir, state, { - verifyEachCommit: options.verifyEachCommit === true, - }); - const result = await finishRun(stateDir, completed, options); - - // When fork/changes moves, record base-history and rebase open feature PRs onto the new tip. - // Skipped in unit tests / environments without GitHub credentials. - if (options.push && (process.env.GH_TOKEN || process.env.GITHUB_TOKEN)) { - const oldTip = result.snapshots[manifest.forkChangesBranch]; - const newTip = result.newTips[manifest.forkChangesBranch]; - if (oldTip && newTip) { - try { - // A normal PR merge advances fork/changes before this workflow starts, so - // snapshots already contain the new tip. Its first parent is the previous - // fork/changes base that open feature PRs still contain. - const firstParent = git(sourceRoot, ["rev-parse", `${newTip}^`], { - allowFailure: true, - }); - const previousBase = oldTip !== newTip ? oldTip : firstParent; - pushForkChangesBaseHistory(sourceRoot, [newTip, previousBase, oldTip]); - const featureResult = await rebaseOpenFeaturePullRequests({ - sourceRoot, - manifest, - push: true, - oldForkChangesTip: previousBase, - newForkChangesTip: newTip, - }); - appendFeatureRebaseSummary(featureResult); - console.log( - `Feature PRs: updated=${featureResult.updated.length} conflicts=${featureResult.conflicts.length} skipped=${featureResult.skipped.length}`, - ); - // Integration overlays must be based on the new tip for compose. Surface a - // hard error when a registered overlay could not be rebased, instead of - // failing later with a less actionable compose-time message. - // "Already based" / identical-tip skips are success — see - // isSuccessfulFeatureRebaseSkip (must match actual skip reason strings). - if (manifest.integrationOverlays.length > 0) { - const overlayBranches = new Set(manifest.integrationOverlays.map(({ branch }) => branch)); - const failedOverlays = featureResult.conflicts.filter((entry) => - overlayBranches.has(entry.branch), - ); - const skippedOverlays = featureResult.skipped.filter( - (entry) => - overlayBranches.has(entry.branch) && - !isSuccessfulFeatureRebaseSkip(entry.reason, manifest.forkChangesBranch), - ); - if (failedOverlays.length > 0 || skippedOverlays.length > 0) { - const details = [ - ...failedOverlays.map( - (entry) => `#${entry.number} (${entry.branch}): ${entry.message}`, - ), - ...skippedOverlays.map( - (entry) => `#${entry.number} (${entry.branch}): ${entry.reason}`, - ), - ].join("; "); - throw new StackError( - `Integration overlay auto-rebase incomplete after fork/changes advanced: ${details}`, - ); - } - } - } catch (error) { - // Stack layer refs are already pushed. Overlay incompleteness is fatal for - // the job (compose cannot proceed); ordinary feature PR failures are not. - if ( - error instanceof StackError && - error.message.startsWith("Integration overlay auto-rebase incomplete") - ) { - throw error; - } - console.error( - `Feature PR auto-rebase failed (stack sync already pushed): ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - } - } - - return result; -} - -/** - * Skip reasons from {@link rebaseOpenFeaturePullRequests} that mean the branch - * is already correctly based on its parent (no further work needed). - * - * Keep these strings in sync with the `skipped.push({ reason: ... })` sites in - * that function. The post-sync overlay gate must treat them as success, not as - * "incomplete" failures — otherwise a no-op cascade hard-fails when overlays - * are already on the new tip and blocks compose/dispatch. - */ -export function isSuccessfulFeatureRebaseSkip(reason: string, baseBranch: string): boolean { - return ( - reason === `already based on ${baseBranch}` || - reason === `remote already based on ${baseBranch} after concurrent update` || - reason === "rebase produced identical tip" - ); -} - -/** - * Append fork/changes tips to the durable base-history ref and push it. - * Newest tips first so multi-generation recovery prefers the most recent base - * still reachable from a feature head. - */ -export function baseHistoryPushArgs(remoteOid: string): ReadonlyArray { - return [ - "push", - `--force-with-lease=${FORK_CHANGES_BASE_HISTORY_REF}:${remoteOid}`, - "origin", - `${FORK_CHANGES_BASE_HISTORY_REF}:${FORK_CHANGES_BASE_HISTORY_REF}`, - ]; -} - -function pushForkChangesBaseHistory( - sourceRoot: string, - tipsNewestFirst: ReadonlyArray, -): void { - const repoDir = sourceRoot; - const remoteLine = git( - repoDir, - ["ls-remote", "--refs", "origin", FORK_CHANGES_BASE_HISTORY_REF], - { allowFailure: true }, - ); - const remoteOid = remoteLine.split(/\s+/u)[0] ?? ""; - 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( - diskBackedWorkRoot({ subdir: "rebase-work", envVar: "T3_REBASE_WORK_ROOT" }), - `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, baseHistoryPushArgs(remoteOid)); - 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; - const lines = [ - "## Open feature PR rebases", - "", - `- Updated: ${result.updated.length}`, - `- Conflicts: ${result.conflicts.length}`, - `- Skipped: ${result.skipped.length}`, - "", - ]; - if (result.updated.length > 0) { - lines.push("### Updated", ...result.updated.map((p) => `- #${p.number} (\`${p.branch}\`)`), ""); - } - if (result.conflicts.length > 0) { - lines.push( - "### Conflicts (manual fix needed)", - ...result.conflicts.map((p) => `- #${p.number} (\`${p.branch}\`): ${p.message}`), - "", - "Fix with:", - "```sh", - "pnpm fork:stack update --push ", - "```", - "", - ); - } - if (result.skipped.length > 0) { - lines.push( - "### Skipped", - ...result.skipped.map((p) => `- #${p.number} (\`${p.branch}\`): ${p.reason}`), - "", - ); - } - NodeFS.appendFileSync(summaryPath, `${lines.join("\n")}\n`, "utf8"); -} - -export async function resumeStack( - stateDirInput: string, - options: Pick, -): Promise { - const stateDir = NodePath.resolve(stateDirInput); - let state = readState(stateDir); - const operation = state.currentOperation; - if (!operation) { - throw new StackError(`No interrupted rebase exists in ${stateDir}.`, { stateDir }); - } - if (rebaseInProgress(state.repoDir)) { - const unresolvedOutput = git(state.repoDir, ["diff", "--name-only", "--diff-filter=U"], { - stateDir, - }); - if (unresolvedOutput) throw conflictError(stateDir, state, operation); - const result = run("git", ["-c", "commit.gpgsign=false", "rebase", "--continue"], { - cwd: state.repoDir, - allowFailure: true, - env: { GIT_EDITOR: "true", GIT_SEQUENCE_EDITOR: "true" }, - stateDir, - }); - if (result.status !== 0) { - if (rebaseInProgress(state.repoDir)) throw conflictError(stateDir, state, operation); - throw new GitCommandError(["rebase", "--continue"], state.repoDir, result, stateDir); - } - } - state = finishOperation(stateDir, state, operation); - state = continueOperations(stateDir, state, { - verifyEachCommit: options.verifyEachCommit === true, - }); - return finishRun(stateDir, state, options); -} - -function validateRemoteTopology(sourceRoot: string, manifest: StackManifest): void { - const { stateDir, state } = initializeState(sourceRoot, manifest, false); - try { - const originMain = state.snapshots[manifest.upstreamBranch]; - if (!originMain) throw new StackError("The origin main snapshot is missing.", { stateDir }); - let parent = originMain; - for (const pullRequest of manifest.pullRequests) { - const child = state.snapshots[pullRequest.branch]; - if (!child) - throw new StackError(`Missing remote branch ${pullRequest.branch}.`, { stateDir }); - validateAncestry( - state.repoDir, - parent, - child, - `PR #${pullRequest.number} does not contain ${expectedBase(manifest, manifest.pullRequests.indexOf(pullRequest))}.`, - stateDir, - ); - const count = Number( - git(state.repoDir, ["rev-list", "--count", `${parent}..${child}`], { stateDir }), - ); - if (count < 1) throw new StackError(`PR #${pullRequest.number} is empty.`, { stateDir }); - parent = child; - } - const integrationTip = state.snapshots[manifest.integrationBranch]; - if (!integrationTip) throw new StackError("The integration branch is missing.", { stateDir }); - validateAncestry( - state.repoDir, - parent, - integrationTip, - "The integration branch does not contain the top PR.", - stateDir, - ); - } finally { - cleanupState(stateDir); - } -} - -export async function checkStack( - options: { - readonly sourceRoot?: string; - readonly manifestPath?: string; - readonly pullRequests?: ReadonlyArray; - readonly validatePullRequests?: boolean; - } = {}, -): Promise { - const sourceRoot = NodePath.resolve(options.sourceRoot ?? process.cwd()); - const manifest = readManifest(sourceRoot, options.manifestPath); - if (options.validatePullRequests !== false) { - await validatePullRequests(manifest, options.pullRequests); - } - validateRemoteTopology(sourceRoot, manifest); -} - -export function conflictResolutionManifestSnippet( - branch: string, - commit: string, - paths: ReadonlyArray, - strategy: "ours" | "theirs" = "theirs", -): string { - const automaticPaths = paths.filter((path) => !isProductConflictPath(path)); - const manualPaths = paths.filter(isProductConflictPath); - const entries = automaticPaths.map( - (path) => ` { - "branch": ${JSON.stringify(branch)}, - "commit": "*", - "path": ${JSON.stringify(path)}, - "strategy": ${JSON.stringify(strategy)} - }`, - ); - const exact = automaticPaths.map( - (path) => ` { - "branch": ${JSON.stringify(branch)}, - "commit": ${JSON.stringify(commit)}, - "path": ${JSON.stringify(path)}, - "strategy": ${JSON.stringify(strategy)} - }`, - ); - const automatic = - automaticPaths.length === 0 - ? "" - : `### Record non-product resolutions in \`.github/pr-stack.json\` - -Do **not** only resume once. Exact SHAs go stale after every successful layer rewrite. -Durable \`commit: "*"\` policies are allowed only for non-product paths that always take one side: - -\`\`\`json - "conflictResolutions": [ -${entries.join(",\n")} - ] -\`\`\` - -One-shot resume for this exact replay only (optional, in addition): - -\`\`\`json - "conflictResolutions": [ -${exact.join(",\n")} - ] -\`\`\` - -During rebase: \`theirs\` = commit being replayed, \`ours\` = new base. After editing the -manifest, merge that change to \`fork/changes\` so the next scheduled sync can auto-resolve. -`; - const manual = - manualPaths.length === 0 - ? "" - : `### Manual product resolution required - -Automatic whole-file \`ours\`/\`theirs\` is forbidden for: - -${manualPaths.map((path) => `- \`${path}\``).join("\n")} - -3-way merge each path in the preserved state, verify that both sides' product behavior remains, -run the focused behavior/existence tests and the layer gate, then stage the result and resume. -Do not add these paths to \`conflictResolutions\`. -`; - return [manual, automatic].filter(Boolean).join("\n"); -} - -function appendConflictSummary(error: RebaseConflictError): void { - const summaryPath = process.env.GITHUB_STEP_SUMMARY; - if (!summaryPath) return; - const label = - error.pullRequestNumber === undefined - ? `integration branch \`${error.branch}\`` - : `PR #${error.pullRequestNumber} (\`${error.branch}\`)`; - const paths = - error.conflictingPaths.length === 0 - ? "- Git did not report a conflicted path." - : error.conflictingPaths.map((path) => `- \`${path}\``).join("\n"); - const record = - error.conflictingPaths.length === 0 - ? "" - : `\n${conflictResolutionManifestSnippet(error.branch, error.commit, error.conflictingPaths)}\n`; - NodeFS.appendFileSync( - summaryPath, - `## PR stack rebase conflict - -- Failing item: ${label} -- Parent branch: \`${error.parentBranch}\` -- Commit being replayed: \`${error.commit}\` — ${error.commitSubject} - -### Conflicting paths - -${paths} -${record} -### Local reproduction - -\`\`\`sh -node scripts/rebase-pr-stack.ts sync --push -# 1) Add conflictResolutions to .github/pr-stack.json (see above) and merge to fork/changes -# 2) Resolve and stage the reported files in the preserved state dir, then: -node scripts/rebase-pr-stack.ts resume --state ${error.stateDir ?? ""} --push -\`\`\` -`, - "utf8", - ); -} - -function usage(): string { - return `Usage: - node scripts/rebase-pr-stack.ts check - node scripts/rebase-pr-stack.ts sync --push [--verify-each-commit] - node scripts/rebase-pr-stack.ts sync --dry-run [--verify-each-commit] - node scripts/rebase-pr-stack.ts resume --state --push - node scripts/rebase-pr-stack.ts verify-head`; -} - -async function main(args: ReadonlyArray): Promise { - const [command, ...flags] = args; - if (command === "check" && flags.length === 0) { - await checkStack(); - console.log("PR stack manifest, pull requests, and remote topology are valid."); - return; - } - if (command === "verify-head" && flags.length === 0) { - verifyReplayHead(process.cwd()); - return; - } - if (command === "sync") { - const push = flags.includes("--push"); - const dryRun = flags.includes("--dry-run"); - const verifyEachCommit = flags.includes("--verify-each-commit"); - const allowed = new Set(["--push", "--dry-run", "--verify-each-commit"]); - if (push === dryRun || flags.some((flag) => !allowed.has(flag))) { - throw new StackError(usage()); - } - const result = await syncStack({ push, verifyEachCommit }); - console.log( - push - ? `Atomically updated ${Object.keys(result.newTips).length + 1} branches.` - : `Dry run succeeded; ${Object.keys(result.newTips).length} branches would be rewritten.`, - ); - return; - } - if (command === "resume") { - const stateIndex = flags.indexOf("--state"); - const stateDir = stateIndex >= 0 ? flags[stateIndex + 1] : undefined; - const push = flags.includes("--push"); - const valid = - stateDir !== undefined && - push && - flags.length === 3 && - stateIndex >= 0 && - flags.every( - (flag, index) => index === stateIndex + 1 || flag === "--state" || flag === "--push", - ); - if (!valid) throw new StackError(usage()); - const result = await resumeStack(stateDir, { push: true }); - console.log( - `Rebase resumed and atomically updated ${Object.keys(result.newTips).length + 1} branches.`, - ); - return; - } - throw new StackError(usage()); -} - -const isMain = - process.argv[1] !== undefined && - import.meta.url === NodeURL.pathToFileURL(NodePath.resolve(process.argv[1])).href; - -if (isMain) { - main(process.argv.slice(2)).catch((error: unknown) => { - if (error instanceof RebaseConflictError) appendConflictSummary(error); - console.error(error instanceof Error ? error.message : String(error)); - if (error instanceof StackError && error.stateDir) { - console.error(`Rebase workspace preserved at: ${error.stateDir}`); - } - process.exitCode = 1; - }); -} From 7f1b906f184baca60a416b7887dea64b25b5e097 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:31:52 +0000 Subject: [PATCH 2/2] chore(stack): stop running the draft lock on unrelated PRs, align docs The draft lock triggered on every pull request in the repository, started a run, looked itself up in an allowlist, exited, and left a "Keep managed PR draft" check on PRs it had nothing to do with. Filter it to the bases the managed PRs actually target: main, fork/base and fork/tim. Ordinary work targets fork/dev, so it no longer runs there at all. Not folded into Fork CI, which was the other option considered. Fork CI deliberately does not watch main, and #255 targets main -- merging that would push fork CI plumbing into the upstream mirror, which is the single worst outcome this guard exists to prevent. Folding it in would have quietly dropped the guard on the one PR that most needs it. Docs are aligned to the model that is actually running rather than the one that was proposed: - The handover doc's synchronization section described rebuilding the provenance stack and importing a C1..C2 tree delta. That is gone; upstream is merged. Rewritten around the merge, with the old procedure kept as a short "what this replaced" note and why it was never necessary for upstream. - Core invariants and operational rules no longer instruct importing a tree delta or recording ancestry with merge -s ours. - fork-stack.md was 714 lines describing the removed workflow. Replaced with a short topology doc: what each branch is, that the provenance branches are no longer rebuilt, and that their PRs stay draft. - stack-history-rewrite.md is deleted along with its inbound links. It documents rewriting stack history, which no longer happens. Verified: no reference anywhere to removed tooling or deleted docs, and no broken internal links in the handover doc. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .github/workflows/managed-pr-draft-lock.yml | 8 + AGENTS.md | 2 +- docs/fork-stack.md | 739 +------------------- docs/stable-dev-release-branch-handover.md | 137 ++-- docs/stack-history-rewrite.md | 121 ---- 5 files changed, 95 insertions(+), 912 deletions(-) delete mode 100644 docs/stack-history-rewrite.md diff --git a/.github/workflows/managed-pr-draft-lock.yml b/.github/workflows/managed-pr-draft-lock.yml index d8e5c25bc41..1eba3d9421b 100644 --- a/.github/workflows/managed-pr-draft-lock.yml +++ b/.github/workflows/managed-pr-draft-lock.yml @@ -3,6 +3,14 @@ name: Managed PR draft lock on: pull_request_target: types: [opened, reopened, ready_for_review, synchronize] + # Only the bases the managed provenance PRs target. Ordinary work targets + # fork/dev, so this no longer starts a run -- and shows a check -- on every + # unrelated PR just to look itself up in an allowlist and exit. + # + # Not folded into Fork CI: that workflow deliberately does not watch main, + # and #255 targets main. Merging it would push fork CI plumbing into the + # upstream mirror, which is the single worst outcome this guard prevents. + branches: [main, fork/base, fork/tim] permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 27741b1e312..4892d363f6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ whole branching model. See overlay tips**: amend/rewrite the offending commit when you have stack push bypass; do not leave a forever-forward `style(docs):` / `fix(stack):` tip. See [docs/fork-stack.md](./docs/fork-stack.md) (“Commit-green during stack rewrite”, “Permanent - draft PRs”) and [docs/stack-history-rewrite.md](./docs/stack-history-rewrite.md). + draft PRs”). - **Fork product changes need existence/behavior tests:** every user-visible or behavioral fork change must land with a test that fails if the surface disappears (pure helpers alone are not enough). Prefer pure gates + `aria-label`/`data-testid` existence, or markers in diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 906b582c903..3c968caf857 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -1,723 +1,44 @@ -# Downstream fork workflow +# Fork branch topology -> [!IMPORTANT] -> **Superseded. Do not follow this for new work.** -> -> Contributors branch from and target **`fork/dev`** — every kind of work, including Discord, VS Code, -> identity and desktop. The integration overlays are drained and deregistered, and `fork/changes` and -> `fork/integration` are frozen. See -> [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). -> -> Kept as a record of how the fork operated before 2026-08-06, and because the provenance stack -> (`main` → `fork/base` → `fork/tim` → `fork/candidates`) it describes is still current. - -This repository separates upstream history, downstream changes, temporary review branches, and the -runnable build: +How this fork's branches relate. The operating model — contributing, releasing, syncing upstream — +is [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). ```text pingdotgg/t3code:main - └── fork/base fork-only repo plumbing (CI runners, Fork CI workflow) - └── fork/tim selected Tim Smart PRs - └── fork/candidates selected open upstream PRs - └── fork/changes our downstream changes - ├── ordinary feature PRs - ├── registered draft overlays - └── fork/integration changes + overlays, tested/deployed -``` - -`main` mirrors `pingdotgg/t3code:main`. - -**`fork/base`** sits on `main` and holds **only** adaptations this fork needs for GitHub Actions and -repo automation (for example GitHub-hosted `fork-ci.yml` and Blacksmith-free `ci.yml` runner labels). -No Tim imports, no candidates, no product. Permanent draft PR against `main`. See -[fork-base.md](./fork-base.md). - -`fork/tim` is a linear provenance layer with one commit per selected Tim Smart PR and a permanently -open PR against **`fork/base`** (not bare `main`). `fork/candidates` is a temporary -upstream-provenance layer with one commit per selected open upstream PR and a permanently open PR -against `fork/tim`. `fork/changes` is the GitHub default branch and canonical downstream layer, with a -permanently open PR against `fork/candidates`. -`fork/integration` is generated from the reviewed layers plus registered integration overlays and -is used by running instances. - -## Long-lived integration overlays - -An upstreamable feature may remain as an open PR instead of being merged into `fork/changes`. -Register it under `integrationOverlays` in `.github/pr-stack.json`. Every overlay remains a -**parallel draft PR based on `fork/changes`**; overlays are never based on each other. The stack -workflow rebases overlays when `fork/changes` moves and composes their commits, in manifest order, -only in `fork/integration`. - -Draft state is the merge lock. Normal Fork CI continues to run and can remain green, so health and -merge permission remain separate signals. A trusted workflow automatically returns managed PRs -(#1, #27, #2, and registered overlays) to draft if they are accidentally marked ready. Permanent -overlay drafts **must** carry the GitHub label **`OVERLAY`**. - -```sh -pnpm fork:stack overlay-add 10 -pnpm fork:stack overlay-start 10 feature/deep-link-follow-up -pnpm fork:stack overlay-promote 10 upstream/desktop-deep-links -``` - -### Permanent draft PRs — reopen first, do not mint replacements - -Managed stack PRs (`fork/base`, `fork/tim`, `fork/candidates`, `fork/changes`) and every registered -**`integrationOverlays`** PR are long-lived identity. If one is **accidentally closed** (or stuck -closed after a tip rewrite): - -1. **Fix the branch first** — rebase onto the intended base (`fork/changes` for overlays; layer - parent for managed stack PRs), resolve product conflicts properly, force-with-lease the tip. -2. **Reopen the same PR number** — `gh pr reopen `, ensure it is **draft**, and for overlays - ensure label **`OVERLAY`**. -3. **Only if reopen fails** (GitHub refuses, or the head/base relationship is irrecoverable): open a - **new** draft PR for the same branch, apply **`OVERLAY`**, and update - `.github/pr-stack.json` → `integrationOverlays[].number` in the **same** change that introduces - the new number. Do not leave the manifest pointing at a closed PR. - -Do **not** open a fresh overlay PR as the default recovery path; PR number churn breaks stack -validation and review continuity. - -### Fixing layer tips — prefer amend / rewrite - -When repairing **`fork/changes`** or a **registered overlay tip** (format, typecheck, accidental -strip from reapply, compose-policy docs, etc.) and you have stack push bypass: - -- Prefer **`git commit --amend`** or a small history rewrite that folds the fix into the **commit - that introduced the problem** (or into the existing product / reapply commit on that tip). -- Force-with-lease the layer tip; rebase children/overlays as needed. -- **Avoid** permanent tip-only recovery commits (`style(docs):…`, `fix(stack):…`, drive-by format - tips) on shared stack branches when rewrite is allowed — they accumulate noise and still require - the next restack to fold them. - -Ordinary **feature** work still lands as new commits via PRs that **merge** into the layer. Amend -is for **maintaining** the layer tip itself, not for rewriting already-merged public feature history -on someone else's open PR without coordination. - -To change an overlay's product, commit directly to its branch (amend when fixing that tip) 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`. -**Merging** a child PR into a registered overlay base (or into `fork/changes`) triggers **Compose -fork integration**, so `fork/integration` picks up the new tip once overlays are based on current -`fork/changes`. Plain **pushes** / force-pushes do **not** compose — that keeps permanent draft PR -status limited to Fork CI (Check/Test/…) and keeps rebase storms manual. -When `fork/changes` rewrites without a merge event, rebase overlays and compose with -`workflow_dispatch` (or local scripts). Landing an overlay into shared product is deliberate: remove -its manifest entry in the same reviewed change that lands the implementation in `fork/changes`, drop -its branch from `compose-integration.yml` / `fork-ci.yml` base lists, then verify that the resulting -`fork/integration` tree is unchanged. - -Some overlays also own complete client integrations. Their path ownership and change-routing rules -live in [client-overlays.md](./client-overlays.md). Check that ownership before starting ordinary -work so Discord, VS Code, and desktop-link changes do not accidentally leak back into -`fork/changes`. - -## Updating from upstream - -Do not use GitHub's **Sync fork** button, create a PR into this repository's `main`, or push `main` -manually. A GitHub PR merge would rewrite upstream commits, while an ordinary push is correctly -blocked by the `Protect upstream main` ruleset. - -### Fast path — compose `fork/integration` after product or overlay **merges** - -Day-to-day merges do **not** run a full layer restack. Workflow **Compose fork integration** -(`.github/workflows/compose-integration.yml`) rebuilds `fork/integration` and dispatches Fork CI -**only** when: - -- a PR is **merged** into **`fork/changes`**, or -- a PR is **merged** into a **registered overlay base** (child PR into desktop/discord/vscode/ - identity), or -- it is started manually (`workflow_dispatch`): - -```sh -gh workflow run compose-integration.yml --repo patroza/t3code --ref fork/changes -``` - -It does **not** run on branch **pushes** (including overlay auto-rebase force-with-lease and agent -deploy-key tip rewrites). Permanent layer draft PRs must not show compose as a failing/required -check — only Fork CI jobs gate those PRs. - -Before compose, the workflow runs `node scripts/rebase-integration-overlays.ts` so registered -overlay tips are force-with-lease rebased onto current `fork/changes` when they lag (no-op when -already based). Clean **merges** to `fork/changes` should no longer require a human to rebase every -overlay first. Conflicts still fail the job with the overlay branch and paths. Overlay branch names -in the workflow `on.pull_request` base list must stay aligned with `.github/pr-stack.json` → -`integrationOverlays`. - -### Tim / candidates CI (via `fork/base`, no Blacksmith) - -Upstream `main` ships `.github/workflows/ci.yml` with **Blacksmith** runner labels. This fork has -no Blacksmith capacity. **`fork/base`** rewrites those labels to GitHub-hosted runners and adds -`fork-ci.yml`, so every layer above base inherits working CI files. - -**Required:** keep the repository workflow **CI** disabled (belt-and-suspenders; Blacksmith still -exists on bare `main`): - -```sh -gh workflow disable CI --repo patroza/t3code -``` - -Layer green is **Fork CI** on each tip: - -```sh -gh workflow run fork-ci.yml --repo patroza/t3code --ref fork/base -gh workflow run fork-ci.yml --repo patroza/t3code --ref fork/tim -gh workflow run fork-ci.yml --repo patroza/t3code --ref fork/candidates -``` - -Restack order: `main` → rebuild **`fork/base`** → `fork/tim` → `fork/candidates` → `fork/changes` -→ overlays → compose. Never bolt CI tip commits onto Tim/candidates product tips again. - -Do not re-enable upstream CI on bare `main` to “make checks run.” - -### Slow path — full provenance restack (local only) - -Use a **local** restack when taking new upstream, Tim, or candidates — not after ordinary feature -merges. The GitHub Actions workflow **Rebase fork PR stack** is **`disabled_manually` and must stay -disabled.** Do not enable it, schedule it, or `gh workflow run rebase-pr-stack.yml`. - -```sh -export GH_TOKEN="$(gh auth token)" -node scripts/rebase-pr-stack.ts sync --dry-run -node scripts/rebase-pr-stack.ts sync --push -``` - -That script fetches `pingdotgg/t3code:main`, verifies that the existing mirror has not diverged, and -atomically updates `main`, `fork/tim`, `fork/candidates`, `fork/changes`, and `fork/integration` -with force-with-lease when run with appropriate write credentials. A repository-scoped write deploy -key stored as `FORK_STACK_DEPLOY_KEY` can bypass branch rulesets for those updates (including -`main`'s PR and status-check requirements); it cannot access other repositories. Never expose or -reuse it. Prefer per-layer green gates even when using the script; stop the line on a red parent. - -## Branch rulesets (protection vs rewrites) - -Repository rulesets gate **long-lived stack branches**. Ordinary feature branches (`feat/**`, -`import/**`, …) are not covered, so agents and humans can still force-push them freely. - -| Ruleset | Branches | Enforced | Bypass (always) | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Protect upstream main | `main` | No delete, no force-push, linear history, **PR required**, **Fork CI checks** | `patroza`, `omegabot`, deploy key (`FORK_STACK_DEPLOY_KEY`); Admin role may bypass via PR only | -| Protect fork/changes (PR + CI) | `fork/changes` | No delete, no force-push, linear history, **PR required** (squash/rebase), **strict Fork CI** (Check, Test, Mobile Native Static Analysis, Release Smoke) | `patroza`, `omegabot`, deploy key | -| Protect integration overlays (PR + CI) | `fork/discord`, `fork/vscode`, `fork/identity`, `t3-discord/f7d37879-desktop-deeplinks` (registered overlays) | Same as `fork/changes`: **PR required** (squash/rebase), **strict Fork CI** (Check, Test, Mobile Native Static Analysis, Release Smoke) | `patroza`, `omegabot`, deploy key (overlay auto-rebase / stack rewrites) | -| Protect fork/tim, candidates, integration | `fork/tim`, `fork/candidates`, `fork/integration` | No delete, no force-push, linear history (no PR requirement — stack rebuilds these tips) | `patroza`, `omegabot`, deploy key | - -**Overlay child PRs are not a free pass.** A PR whose base is a registered overlay (for example -`feat/…` → `fork/discord`) is subject to the same required checks as a PR into `fork/changes`. -Fork CI’s `pull_request.branches` list includes those overlay bases so Check/Test actually run -before merge. Compose does **not** re-lint; if a red overlay tip is ever force-pushed with bypass, -integration fails next — treat that as a process failure, not “CI will catch it later.” - -**CI path:** compose / stack workflows authenticate with the deploy key for protected branch -pushes, not `GITHUB_TOKEN` alone (default workflow token is read-only and cannot be added as an -Integration bypass on this personal fork). - -**Who cannot force-push protected branches:** write collaborators without a User bypass entry. -They can still open PRs into `fork/changes` or an overlay base and merge only when required checks -are green. **Bots and agents without a User bypass cannot merge red child PRs into overlays.** - -**Who can force-push:** `patroza`, `omegabot` (must accept the collaborator invite), and the stack -deploy key. Feature-branch force-pushes do not need bypass. Bypass is for intentional stack rewrites -and overlay auto-rebase — **not** a license to skip local `vp check` / typecheck before push. - -Upstream's `.github/workflows/ci.yml` and `.github/workflows/deploy-relay.yml` remain present on the -exact `main` mirror but are disabled in this repository. Fork PR and integration checks use -`.github/workflows/fork-ci.yml`; **Compose fork integration** (and the manual stack restack) dispatch -that workflow for the generated integration tip. This avoids redundant CI and prevents an -upstream-mirror update from being treated as a fork product or relay deployment. - -## Starting work - -The helper starts an independent branch from `fork/changes`: - -```sh -pnpm fork:stack start feature/my-change + └── main exact upstream mirror, fast-forward only + └── fork/dev canonical product: contributor target and release source ``` -Commit and push normally, then open the PR against `fork/dev` (never against `main`). Updating -that branch updates the same PR and reruns PR CI. Ordinary feature and import PRs are deliberately -not registered in the stack manifest, so multiple independent PRs may be open concurrently without -editing central metadata. - -### Keeping feature PRs up to date - -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 -pnpm fork:stack update --push - -# Explicit PR (checks out the head branch, updates, pushes) -pnpm fork:stack update --push 48 - -# Plan only (no push) -pnpm fork:stack update -``` - -`update` will: - -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 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. - -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. - -When the stack workflow rewrites `fork/changes`, it also force-with-lease rebases every open feature -PR that targets `fork/changes` (conflicts are reported in the job summary and skipped). After that -remote rewrite, update your local checkouts with: - -```sh -# On the feature branch (or fork/changes / any tracking branch) -pnpm fork:stack pull -``` - -`pull` fetches the remote tip and uses `git cherry` patch-ids: - -- if every local commit is patch-equivalent to something already on the remote → **hard reset** to - remote (safe when the only difference is a rewritten history you already pushed); -- if you have unique unpushed patches → **rebase** those onto the remote tip. - -Require a clean working tree. This is the low-pain path after automation rebases open PRs. - -After review, merge the PR into `fork/changes`. That push automatically runs the stack synchronizer: - -```sh -feature PR merged into fork/changes - → rebase-pr-stack workflow - → fork/integration updated atomically - → CI dispatched for the exact integration SHA - → successful CI classifies the tree diff - → runtime-affecting changes trigger fleet deployment - → test, documentation, and automation-only changes stop after CI -``` - -Deployment classification compares complete tested integration trees rather than only the latest -commit. Unknown paths are runtime-affecting by default. This preserves safe deployment when a PR -contains mixed changes or a new source directory appears, while avoiding fleet rebuilds and mobile -OTA updates for tests, snapshots, documentation, agent instructions, and GitHub-only metadata. - -Runtime-affecting integrations also publish both mobile release tracks from the exact tested SHA. -Both tracks use Expo Fingerprint: they publish an OTA update when a compatible build already -exists, and start a new build when native runtime inputs changed. A new production build is -submitted to TestFlight automatically, so an installed tester build stays current without a manual -dispatch. Manual runs of `Mobile EAS Production` can still force `build` or `update`; manual runs of -`Mobile EAS Development` may target iOS, Android, or both. Automatic integration publishing targets -iOS, because Android has no signing keystore configured. - -The manifest contains the permanent `fork/tim`, `fork/candidates`, and `fork/changes` PRs. The -synchronizer rebases that provenance chain onto the latest upstream `main` and rebuilds -`fork/integration`. Other open repository PRs are ignored. Temporary state is retained after a -conflict and can be resumed with the command printed in the error. - -### Lockfile after layer rewrites (agents and humans) - -Stack and manual recoveries often hit conflicts in `pnpm-lock.yaml` (and sometimes `patches/*`) when -upstream or Tim changes dependencies while a large `fork/changes` commit also touches manifests. - -**Do not** finish a recovery by only checking out `--ours` or `--theirs` for the lockfile if any -`package.json` still disagrees with it. Fork CI installs with a **frozen** lockfile; a mismatch -fails every job at `Setup Vite+` with `ERR_PNPM_OUTDATED_LOCKFILE` (for example after -`packages/client-runtime` gained `react` / `@types/react` while the lockfile was left on the -rebased base). - -Required recovery step after resolving stack conflicts that touch package manifests or the lockfile: - -```sh -# On the tip you are about to push as fork/changes (or a fix PR based on it) -CI= pnpm install --no-frozen-lockfile -git add pnpm-lock.yaml -# commit, open/merge PR to fork/changes if the rewrite already landed without this -# then recompose integration and re-dispatch Fork CI -node scripts/compose-integration-overlays.ts --push -# or: gh workflow run compose-integration.yml --repo patroza/t3code --ref fork/changes -gh workflow run fork-ci.yml --repo patroza/t3code --ref fork/integration -``` - -Prefer one deliberate lockfile regeneration at the end of a multi-commit `fork/changes` rebase over -resolving the lockfile at every intermediate conflict. - -### Conflict resolutions (`.github/pr-stack.json`) - -Protected stack rebases stop on the first unresolved conflict unless the path is listed under -`conflictResolutions`. **Resuming once without updating the manifest leaves a bomb for the next -upstream sync** — exact commit SHAs change every time a layer is rewritten. - -Each entry: - -| Field | Meaning | -| ---------- | -------------------------------------------------------------------------------------------------------------- | -| `branch` | Layer being rebased (`fork/tim`, `fork/candidates`, `fork/changes`, or an overlay branch) | -| `commit` | Full 40-char SHA of the commit being replayed (`REBASE_HEAD`), **or** `"*"` for any commit on that branch+path | -| `path` | Repo-relative conflicted file | -| `strategy` | `theirs` = take the commit being replayed; `ours` = keep the new base (rebase semantics) | - -Prefer **`commit: "*"`** only for known permanent, non-product policies such as generated or -stack-owned metadata. Use a full SHA only for a one-shot non-product resolution. Product paths -cannot use either form: the tool rejects blind whole-file resolution and requires a 3-way merge. - -Required workflow when automation stops on a conflict: +| Branch | Rewritten | Role | +| ------------------ | -------------- | ---------------------------------------------------------------------------- | +| `main` | mirror-managed | Exact copy of `pingdotgg/t3code:main`. Never receives downstream work. | +| `fork/dev` | **never** | The product. Every PR targets it; every release comes from it. | +| `fork/base` | was | Fork-only CI plumbing. Permanent draft PR #255 against `main`. | +| `fork/tim` | was | Selected Tim Smart imports. Permanent draft PR #1. | +| `fork/candidates` | was | Selected open upstream PRs. Permanent draft PR #27. | +| `fork/changes` | was | Superseded by `fork/dev`. Frozen; delete once its remaining PRs are drained. | +| `fork/integration` | was | Superseded by `fork/dev`. Frozen. | -1. Note branch, `REBASE_HEAD` SHA, subject, and conflicted paths from the job summary / logs. -2. Decide `ours` vs `theirs` (or a hand-merged tree) for each path. -3. For non-product paths, **append** matching `conflictResolutions` entries to - `.github/pr-stack.json` (durable `*` when the same path will keep that side on future rebases). - For product paths, perform a 3-way merge in the preserved state; do not add a manifest entry. -4. Open/merge a PR to `fork/changes` with that manifest update **before** calling the stack “done”. -5. Resolve/stage files and `node scripts/rebase-pr-stack.ts resume --state --push`, **or** - re-run `sync --push` after the manifest is on the tip the sync reads. -6. Run **per-layer full CI** (below). Lockfile conflicts still need - `CI= pnpm install --no-frozen-lockfile` — never leave a mismatched lock as the “resolution”. - -The stack conflict summary prints ready-to-paste JSON for both `*` and exact-SHA forms. - -### Product conflicts (shared UI / app code — never blind whole-file) - -`conflictResolutions` with whole-file `ours`/`theirs` is appropriate for **fork-owned** paths and -boilerplate (`pnpm-lock.yaml`, pure fork-only modules). It is **not** safe for shared product files -where both the new base and the replayed commit carry real behavior (classic example: -`apps/web/src/components/chat/ChatHeader.tsx` — recovery once kept -`resolveRemoteVscodeOpenTarget` + unit tests and **dropped the remote Open in VS Code header -button**, so CI stayed green while the control vanished; restored in #154). - -**Never register automatic whole-file policies (durable `*` or exact SHA)** on: - -- source under any current or future `apps/*/src/**`, `packages/*/src/**`, or `infra/*/src/**` -- `scripts/**`, `oxlint-plugin-t3code/**`, and root/workspace `package.json` manifests - -Especially VCS clusters (`GitVcsDriverCore*`, `vcs.ts` / `vcsAction*`, BranchToolbar, CommandPalette, -`ws.ts`): taking main or Tim whole-file once produced tip-only `fix(stack)` patches (#165/#166). -Those patches are debt — fold them into the **related provenance/feature commit** on the next -rewrite (see [stack-history-rewrite.md](./stack-history-rewrite.md)). - -When a conflict touches `apps/**` or `packages/**` product code: - -1. **Do not** apply any manifest whole-file policy unless the path is documented as always taking - one side for every rewrite and is **not** product code above. The stack tool rejects both - wildcard and exact-SHA policies for product paths. -2. **3-way merge or re-apply** the known-good feature commit after a clean base; do not invent a - partial hand merge that keeps helpers/tests and drops JSX / wiring. -3. **Parity check** before resume/push: `git diff` the pre-rewrite tip vs the resolved path; if a - symbol remains only in tests (or pure helpers) while the product surface is gone, the resolution - is incomplete. -4. **Tests that would have failed #154:** every fork product change needs an existence or behavior - assertion for the surface users see — pure URI/helper tests alone are insufficient. Prefer: - - exported pure gates (`shouldOfferRemoteVscodeOpen`, list defaults, …), **and** - - one existence check (`aria-label` / `data-testid` via `renderToStaticMarkup`, or source markers - in `apps/web/src/forkSurfaceExistence.test.ts` for chrome that is hard to mount). -5. After resolving, run the focused tests for the conflicted package **and** the root pre-push gate - for the layer (see AGENTS.md). Prefer - `node scripts/rebase-pr-stack.ts sync --dry-run --verify-each-commit` (or `--push`) so **each - replayed commit** typechecks before the next lands. - -### Commit-green during stack rewrite (not tip-only) - -**Layer tip green is necessary; it is not sufficient.** Tip-only `fix(stack): rejoin …` commits hide -broken intermediate SHAs and reappear after the next rebase. - -Two bars: - -| When | Gate | -| --------------------------------------- | ----------------------------------------- | -| **Each layer tip** after rewrite | Full local Fork CI (below) | -| **Each replayed commit** during rewrite | Typecheck packages touched by that commit | - -Enable per-commit typecheck: - -```bash -CI= pnpm install --no-frozen-lockfile # once in the tree that supplies node_modules -node scripts/rebase-pr-stack.ts sync --dry-run --verify-each-commit -# or -node scripts/rebase-pr-stack.ts sync --push --verify-each-commit -``` - -Implementation: `git rebase --exec 'node scripts/rebase-pr-stack.ts verify-head'` after every pick. -`verify-head` maps `HEAD^..HEAD` paths to pnpm filters and runs each package's `typecheck`. Config / -docs / lock-only commits skip package typecheck. - -**On failure:** stop. Fix the **replayed commit** (conflict resolution or provenance content), not a -new tip patch. Product recovery belongs **inside** Tim/candidate/feature commits, never as a -standalone `fix(stack)` product commit on `fork/changes`. - -Allowed under `fix(stack)` / `feat(fork-stack)` naming: - -- stack automation (`scripts/rebase-pr-stack.ts`, compose, CI wiring) -- durable **non-product** `conflictResolutions` (manifest paths, lockfile strategy) -- docs for the stack itself - -Not allowed as permanent history: - -- re-applying dropped UI/VCS/API after a blind resolve -- “make typecheck green” tips that only undo a bad `ours`/`theirs` - -History cleanup procedure: [stack-history-rewrite.md](./stack-history-rewrite.md). - -### Integration overlay compose and lockfiles - -`node scripts/compose-integration-overlays.ts` rebuilds `fork/integration` by cherry-picking each -overlay's commits onto current `fork/changes`. Overlay lockfiles **intentionally diverge** (each -overlay only needs its own workspace package). Compose therefore: - -1. **Skips** commits that only touch `pnpm-lock.yaml`. -2. On a mixed commit that conflicts **only** on `pnpm-lock.yaml`, keeps the current lock (`--ours`) - and continues the product files from the overlay. -3. **Seeds `node_modules`** before install when a warm tree is available (see below). -4. **Regenerates** a single integration lockfile with - `pnpm install --no-frozen-lockfile --prefer-offline` (proxy env stripped) and commits it. - -Do not treat overlay lockfile commits as product truth for integration. Do not leave a partial -compose tip pushed after a lockfile conflict — finish compose (or re-run the script) so the -regenerated lock is on `fork/integration`. - -#### Disk-backed stack temp (`~/.t3/rebase-work`) - -Stack rebase helpers (`rebase-pr-stack`, `rebase-integration-overlays`) place full git clones -under **`~/.t3/rebase-work/`** (or `T3_REBASE_WORK_ROOT` / `T3CODE_HOME/rebase-work` on t3vm), -never tmpfs `/tmp`. Same rationale as compose-work. - -#### Warm `node_modules` seed (`cp --reflink=auto`) - -Cold `pnpm install` in a temp compose clone is multi‑minute (or hung if the agent session still -inherits a SOCKS proxy). Compose therefore: - -1. Puts the compose worktree under **`~/.t3/compose-work/`** (btrfs home), **not** `/tmp` (often - tmpfs — reflink cannot share extents with `/home`). -2. **Clones** an existing `node_modules` with `cp -a --reflink=auto` from, in order: - - `COMPOSE_NODE_MODULES_SOURCE` (if set) - - `/node_modules` (the checkout running the script) - - sibling / `~/pj/t3code` / `~/deploy/t3code` warm trees -3. Runs install against that seed so resolution is mostly offline and fast. - -On btrfs/xfs same-filesystem copies this is CoW (seconds for multi‑GB trees). On other FS it falls -back to a full copy. Optional: `COMPOSE_WORK_ROOT` overrides the work directory parent. - -### Per-layer full CI after stack rebase (required — stop the line) - -When you manually rebase or rewrite the stack, **do not advance to the next layer until the current -layer passes the full local CI gate** (not only `vp check`). A red parent must never receive more -layers on top of it. Prefer `--verify-each-commit` during the rewrite so intermediate SHAs are also -typecheck-green (see **Commit-green during stack rewrite** above). - -After each layer is rebased onto its parent, install/lock is consistent, and conflicts are resolved -(and `conflictResolutions` updated when you hand-resolved): - -1. Check out that layer’s tip. -2. Run the **full local Fork CI gate** on that tip: - - `vp check` - - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run -r --cache --log labeled typecheck` - - `vp run --cache build:desktop` + preload verify steps from `.github/workflows/fork-ci.yml` - - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run test` (**required per stack layer**) - - On macOS when applicable: mobile native lint / Open With pieces from Fork CI - - `node scripts/release-smoke.ts` when release/packaging paths may have changed -3. Fix **every** failure on **that layer**. Commit and force-with-lease push the layer if needed. -4. **Only then** rebase, replay, or compose the **next** layer onto the fixed parent. - -Layer order for this gate: - -```text -main (upstream mirror — skip product fixes; do not hand-edit) - → fork/tim - → fork/candidates - → fork/changes - → each integration overlay (desktop, discord, vscode) onto fork/changes - → fork/integration (compose last; full CI on the composed tip) -``` - -Skipping CI on a layer and stacking “fix it later” commits is how lockfile, typecheck, and test -failures cascade into every PR and block merge. **One red layer stops the rewrite.** Feature PRs -(e.g. based on `fork/changes`) after `pnpm fork:stack update`: rebase onto the fixed parent, then -run the mandatory pre-push gate (and full tests when rewriting stack layers themselves) before -push/merge. Agent-facing requirements: [AGENTS.md](../AGENTS.md) (“Per-layer stack CI”). - -`register` is used during the one-time cutover and only when intentionally building an advanced, -dependent integration chain: - -```sh -pnpm fork:stack register 201 -``` - -The permanent `fork/tim`, `fork/candidates`, and `fork/changes` PRs are never merged while this -model is active. - -### Multiple features - -Independent changes use parallel branches and PRs, all based on `fork/changes`. They can be reviewed -and merged in any order; run `pnpm fork:stack update --push` on a remaining branch if an earlier -merge overlaps it or the PR becomes CONFLICTING. - -Related changes may use one cohesive PR. If separate review is valuable, chain only those PRs by -basing the dependent PR on the preceding feature branch. Merge the chain from bottom to top into -`fork/changes`. Do not place unrelated features in one dependency chain. - -Use the PR title, branch name, affected-area field in the PR template, and GitHub's open/merged PR -history to find prior work. Agents must check `gh pr status` and verify a PR's state before deciding -whether to update its branch or create a new PR. - -Search by feature words instead of remembering PR numbers: - -```sh -pnpm fork:stack find "board pagination" -pnpm fork:stack find-upstream "worktree cleanup" -``` - -## Importing another fork - -### Migration namespaces during provenance imports - -Upstream and downstream migrations use independent manifests and ledgers: - -| Owner | Manifest | SQLite ledger | ID policy | -| -------------------------------------------- | --------------------------------------------- | ------------------------ | ------------------------------------- | -| `pingdotgg/t3code:main` | `migrationEntries` in `Migrations.ts` | `effect_sql_migrations` | Preserve upstream ID and name exactly | -| This fork, Tim imports, candidates, overlays | `forkMigrationEntries` in `ForkMigrations.ts` | `t3_fork_sql_migrations` | Allocate the next fork-local ID | - -The one-time namespace bootstrap backs up the old mixed ledger as -`effect_sql_migrations_backup_v1`, then regenerates `effect_sql_migrations` with canonical upstream -rows only. Never add a fork migration to it, and never avoid a collision by choosing a large -downstream ID. Effect's migrator uses the greatest numeric ID as a high-water mark, so a large fork -ID would suppress every later upstream migration below it. - -This is a **required source adaptation** whenever rebuilding `fork/tim` or `fork/candidates`: - -1. Diff every imported commit against its source and inspect changes under - `apps/server/src/persistence/Migrations*`. Check both newly added migrations and edits or renames - to existing migrations. -2. Keep migrations already present on upstream `main` in `Migrations.ts`, with the upstream numeric - ID and name unchanged. -3. Move every migration introduced by Tim, an unmerged candidate, an overlay, or this fork into - `ForkMigrations.ts`. Give it the next durable fork-local ID even if its source commit used an - upstream-shaped filename or edited the shared manifest. -4. Rewrite follow-up changes to that migration in the fork copy. Do not modify the upstream - migration to make it serve both histories. -5. If an imported migration was previously released through the legacy shared ledger, extend the - namespace bootstrap with an exact legacy `(id, name)` mapping and a schema/data probe. Unknown - legacy states must fail closed; never infer application from ID alone. -6. Run upgrade fixtures for every known released ledger shape, plus a fresh database and a database - where an upstream and fork migration have the same local numeric ID. - -When an upstream candidate is accepted, remove its provenance commit during the candidates rebuild -but keep its historical fork-ledger assignment reserved. If upstream ships equivalent schema under -an upstream ID, make both migrations idempotent or add an explicit reconciliation step; never relabel -the old fork ledger row as proof that the upstream migration ran. - -External forks are source remotes, not branches to merge wholesale. For Tim Smart, start an import -branch from `fork/tim`, port only the wanted source PR, and open it against `fork/tim`: - -```sh -git fetch tim -git switch -c import/tim-pr-17 origin/fork/tim -git cherry-pick -git cherry-pick --no-commit -# keep Tim's imported behavior in one commit; test and open against fork/tim -``` - -Do not merge an external branch wholesale. For every import PR, document: - -- imported unchanged; -- adapted to local behavior; -- intentionally excluded; -- provenance using fully qualified links such as `tim-smart/t3code#17`. - -Merge the import with squash so `fork/tim` gains exactly one provenance commit. Adjustments for our -environment use a separate normal PR against `fork/changes`; never hide downstream policy inside the -Tim layer. A later Tim update is compared against both the prior provenance commit and our -adjustment, and automation never overwrites local decisions. - -## Running open upstream candidates - -An upstream PR may be production-worthy before `pingdotgg/t3code` accepts it. Import it from -`fork/candidates`, never from `main`, `fork/tim`, or `fork/changes`: - -```sh -git fetch origin fork/candidates -git fetch upstream refs/pull//head:refs/remotes/upstream/pr/ -git switch -c import/upstream-pr- origin/fork/candidates -git cherry-pick --no-commit upstream/pr/ -# retain only the reviewed source PR behavior, update .github/upstream-candidates.json, -# test, commit once, push, and open against fork/candidates -``` - -Each candidate PR must become exactly one provenance commit and document the upstream PR URL, -source SHA, imported behavior, local adaptations, and exclusions. The registry -`.github/upstream-candidates.json` records the same source SHA and lifecycle state. Product-specific -follow-ups belong in `fork/changes`, not in the candidate commit. - -The provenance description must also list every migration that was moved or rewritten into the fork -namespace. A candidate migration remaining in the upstream manifest is an incomplete import even if -the layer currently passes on a fresh database. - -Before updating the upstream mirror, inspect every active candidate: - -- unchanged and open: retain it; -- updated upstream: review and replace its provenance commit through a new candidate PR; -- merged with equivalent behavior: remove the candidate commit while rebasing the layer; -- merged differently or closed: stop automatic synchronization and reconcile deliberately. - -After reconciliation, compare the old and rebuilt `fork/integration` trees. Removing an accepted -candidate must not remove adaptations that belong to `fork/changes`. - -## Upstreamable changes - -Every feature lands in `fork/changes`; upstreamability is a clean projection, not an alternative -home. Closing or rejecting an upstream PR therefore never removes the downstream implementation. - -After the downstream PR merges, promote it onto real upstream history: - -```sh -pnpm fork:stack promote upstream/portable-feature -# remove downstream-only assumptions from the staged extraction, test, and commit -``` - -The command creates a branch from upstream `main` and stages the downstream PR's commits without -committing, allowing the projection to be simplified before opening it to `pingdotgg/t3code:main`: - -```sh -gh pr create \ - --repo pingdotgg/t3code \ - --base main \ - --head patroza:upstream/portable-feature -``` - -For work that began upstream-first, adopt its clean branch into the downstream fork: - -```sh -pnpm fork:stack adopt upstream/portable-feature adopt/portable-feature -# push and open adopt/portable-feature against fork/changes -``` - -If the upstream proposal is withdrawn, demotion closes only the projection and cross-links the -downstream source: - -```sh -pnpm fork:stack demote -``` +## Upstream -Never rebase the downstream branch onto `main`. Promotion creates an independently reviewable upstream -implementation while `fork/changes` remains canonical. Select `main` in T3, or use -`start-upstream`, only for deliberately upstream-first work. +`upstream/main` is merged straight into `fork/dev`, and `main` is fast-forwarded to the same tip. +Because upstream is append-only, `fork/dev` carries its real commits, so the branch page's "commits +behind" is accurate. See +[Synchronizing Upstream](./stable-dev-release-branch-handover.md#synchronizing-upstream-into-forkdev). -## Splitting the consolidated fork +## The provenance branches -The registered chain is ordered from upstream toward deployment. Its final PR must always use -`fork/changes`; earlier permanent layers describe provenance such as `fork/tim` and -`fork/candidates`. Add another layer only when it has durable ownership and update the manifest, PR -bases, and documentation together. +`fork/base`, `fork/tim` and `fork/candidates` record which upstream and Tim work was selected before +upstream accepted it. Their content is already in `fork/dev`, and **they are no longer rebuilt when +upstream moves** — the tooling that rebased them has been removed. -## Provenance rebuild archive +Their PRs stay **draft**, enforced by `managed-pr-draft-lock.yml`. That matters most for #255: it +targets `main`, so merging it would push fork CI plumbing into the upstream mirror. -The pre-provenance woven graph is preserved locally and remotely at: +Do not merge them. Do not target them with new work. -- `archive/fork-changes-woven-2026-07-24` -- `archive/fork-integration-woven-2026-07-24` -- matching annotated tags prefixed with `archive-` +## Integration overlays -The clean rebuild preserves the exact archived `fork/changes` tree while replacing its ancestry -with `main → fork/tim → fork/candidates → fork/changes`. Never delete or force-update the archive -refs. +Retired. The four registered overlays were drained into `fork/dev`, their PRs closed, and +`integrationOverlays` emptied. `.github/pr-stack.json` survives only as the allowlist for the draft +lock. There is no overlay to create, target, or compose. diff --git a/docs/stable-dev-release-branch-handover.md b/docs/stable-dev-release-branch-handover.md index 70362a5e6ce..22055efbd46 100644 --- a/docs/stable-dev-release-branch-handover.md +++ b/docs/stable-dev-release-branch-handover.md @@ -144,7 +144,8 @@ The clean branch names are placeholders and only matter once that work is actual ### Clean provenance - `main`, `fork/base`, `fork/tim`, and `fork/candidates` retain their current provenance roles. -- Rewritten provenance tips are never merged into `fork/dev`; their tree delta is imported instead. +- `upstream/main` is merged into `fork/dev` directly. The rebased provenance branches are not + merged into it at all — their commit identities change, so merging a tip would duplicate history. - Any generated clean branches are output only, may be rewritten safely, and are never merged back. ## How the Cutover Was Done @@ -158,7 +159,7 @@ A ref change plus branch protection, not a re-architecture. `required_linear_history`, `non_fast_forward`, `pull_request` restricted to squash, and required checks `Check` / `Test` / `Mobile Native Static Analysis` / `Release Smoke`. 5. Merge commits and rebase merging disabled repository-wide, so squash is the only method; the sync - automation holds a ruleset bypass actor so provenance ancestry merges can still be pushed. + automation holds a ruleset bypass actor so the upstream merge can still be pushed. 6. `fork/dev` made the GitHub default branch and the base for contributor PRs. 7. Deployment pointed at `fork/dev` (see [Ops](#ops)). @@ -332,7 +333,7 @@ single unit to replay per PR. One exception is known: **provenance sync PRs are merge-committed, not squashed**, because squashing discards the upstream ancestry link that makes "commits behind upstream" readable. See -[Record upstream ancestry](#record-upstream-ancestry-so-behind-stays-readable). +[Synchronizing Upstream](#synchronizing-upstream-into-forkdev). Beyond that, exceptions are not defined. If a case appears where preserving a dependent series on `fork/dev` genuinely matters, it can be argued on its own merits then. @@ -354,7 +355,7 @@ is the right shape for a policy whose exceptions are undefined. Pair it with Leave merge commits disabled even though provenance syncs need one. Re-enabling them repo-wide makes **Merge** the merge button's primary action again — GitHub picks it in the order merge → squash → rebase — which quietly reverses this decision for every ordinary PR. Give the sync automation a -ruleset **bypass actor** instead, so it can push the ancestry merge directly while every human path +ruleset **bypass actor** instead, so it can push the upstream merge directly while every human path stays squash-only. Release only the exact merge SHA after its required checks pass. A green PR tip is not sufficient if @@ -379,78 +380,49 @@ Likewise, ordinary cross-cutting features — the desktop URL-handler enhancemen become ordinary squash-merged PRs on `fork/dev`, not permanent layers. A dedicated layer is justified only when work has independent external provenance or must remain independently staged. -## Synchronizing the Rebased Stack into `fork/dev` +## Synchronizing Upstream into `fork/dev` -Rewritten provenance branches must not be repeatedly merged into `fork/dev`. After a rebase their -commits have new identities; merging the rewritten tip would duplicate history and produce avoidable -conflicts. Synchronize the net tree change instead. +Merge it: -### Record upstream ancestry so "behind" stays readable +```sh +git fetch upstream main +git merge upstream/main # resolve, verify, then push to fork/dev +git push origin upstream/main:main # fast-forward the mirror +``` -The no-merge rule above is about branches that are **rebased**: `fork/base`, `fork/tim`, and -`fork/candidates` get new commit identities every cycle, so merging their tips repeatedly duplicates -history. `upstream/main` is not rebased. It is append-only and its commit identities are permanent, -so there is no reason for `fork/dev` to lack them. +`upstream/main` is append-only and its commit identities are permanent, so a merge is sound and +`fork/dev` gains upstream's commits as real ancestors. "Commits behind upstream" then reads true on +the branch page, and the merge base advances, so the next sync replays only what is genuinely new. +The first such merge replayed three commits, not the whole divergence. -Importing only a tree delta gives `fork/dev` upstream's _content_ without upstream's _commit -objects_. GitHub computes ahead/behind purely by reachability, so the branch page reads -`N commits behind pingdotgg/t3code:main` and `N` grows with every import — which makes the one number -everyone actually wants to read permanently useless. +Do **not** press GitHub's **Sync fork** button. It merges into the default branch on GitHub's terms +rather than after local verification, and there is no opportunity to resolve or run the gate first. -Fix it by recording the ancestry the content already implies, as the final step of a sync: +### Verify before pushing, not after -```sh -git merge -s ours -m "chore(provenance): record upstream as an ancestor" -``` +The merge lands directly on `fork/dev` — it cannot go through the squash-only PR path without +flattening the second parent and discarding the ancestry. So the merge is validated locally and +pushed with a ruleset bypass, rather than reviewed in a PR. -`-s ours` keeps `fork/dev`'s tree byte-for-byte and adds only the parent link. After it, the upstream -commits are genuine ancestors and the branch page reads 0 behind, then counts up honestly as upstream -moves. - -**This step asserts that every upstream change is accounted for.** If a sync resolution silently -dropped one, the merge makes that loss permanent — later merges start from the new merge base and -never re-offer those hunks. So run it only as the last step of a sync whose checks passed, never on -its own to turn the banner green. - -One consequence for repository configuration: this is a merge commit, so it cannot go through the -squash-only PR path. Do **not** re-enable merge commits repo-wide to allow it — that makes Merge the -default button for every PR. Add the sync automation as a **bypass actor** on the `fork/dev` ruleset -and let it push the ancestry merge directly, leaving `required_linear_history` and squash-only intact -for every human path. - -Sequence per sync: merge the content PR normally (squashed), then push the `-s ours` ancestry merge -on top. Squashing a sync branch that already contains the ancestry merge would discard it. - -The "Sync fork" button remains the wrong tool — it merges upstream into `fork/dev` for content, which -re-applies changes the delta already brought in. The ancestry merge above is the supported path. - -Assume `C1` is the `fork/candidates` tree currently incorporated into `fork/dev`, and `C2` is the -latest rebuilt and verified `fork/candidates` tree. Then: - -1. Create a sync branch from `fork/dev`. -2. Calculate the tree delta from `C1` to `C2` and apply it to the sync branch. -3. Resolve integration conflicts against the current `fork/dev` product tree. -4. Run the full required checks. -5. Open a normal PR into `fork/dev`, titled for example - `sync(provenance): import upstream stack C1..C2`. -6. Merge it without rewriting `fork/dev` (squashed, like any other PR). -7. Push the upstream ancestry merge on top: `git merge -s ours ` (see above). -8. Record `C2` and the imported upstream commit as the newly imported provenance checkpoint. - -**This is a manual procedure to begin with, and that is fine.** At the current upstream cadence it -runs rarely enough that automation is a convenience, not a prerequisite. The imported checkpoint may -be recorded in an immutable tag or a small machine-owned state file: - -```json -{ - "importedCandidatesCommit": "", - "importedCandidatesTree": "", - "importedUpstreamCommit": "" -} -``` +That makes local verification the only gate, and it must be the full one: recursive typecheck and the +test suite. **A clean textual merge is not evidence of a working one.** Every sync so far has hit +integration breakage that produced no conflict at all — most recently upstream adding a parameter to +`resolveSnoozePresets` and updating its own call sites, while the fork's board code kept the old +arity. Git had nothing to say about it; the typechecker did. + +### What this replaced + +Earlier syncs rebuilt `fork/base` → `fork/tim` → `fork/candidates` onto the new upstream tip, +computed the tree delta between the previous and new `fork/candidates` trees, applied that delta to +`fork/dev`, and then recorded ancestry with `git merge -s ours`. + +That existed because those provenance branches are rebased, so merging their tips would duplicate +history. It was never necessary for upstream itself, which is not rebased. The rebuild also meant a +conflict in an early layer blocked the import entirely — the exact coupling this whole document set +out to remove, reintroduced one level up. The stack tooling that drove it has been removed. -Automate it later by persisting the last imported commit and tree, building the `C1..C2` sync branch -automatically, opening a reviewed PR, and updating the checkpoint only after that PR merges. +`fork/base`, `fork/tim` and `fork/candidates` still exist and still record which upstream and Tim +work was selected, but they are no longer rebuilt on every upstream update. ## GitHub PRs as the Development Ledger @@ -521,13 +493,13 @@ Steps 1 and 2 — establishing `fork/dev` and releasing from it — are done; se The registered overlays are drained and deregistered. What is left are the ordinary PRs still based on `fork/changes`: #317, #226 and #185 conflict when their real commit is cherry-picked onto `fork/dev`, and #237 and #238 live in an external fork and need their author. Once those are -resolved, `fork/changes` and `fork/integration` can be deleted and the composition workflows removed. +resolved, `fork/changes` and `fork/integration` can be deleted. The composition workflows and stack +tooling are already gone. -### Automate provenance synchronization (when manual becomes tedious) +### Automate the upstream merge (when manual becomes tedious) -Persist the last imported candidates commit and tree, build the `C1..C2` sync branch automatically, -open a reviewed PR, run the full gate, push the ancestry merge, and update the checkpoint only after -merge. Never merge a rewritten provenance branch directly into `fork/dev`. +The merge itself is one command; what takes the time is verifying it and resolving the integration +breakage that produces no conflict. Automating the mechanical half is easy and would help least. ### Automate clean projection (only if needed) @@ -538,8 +510,7 @@ verify every rewritten layer, prove tree equivalence, and publish. ## Operational Rules 1. Never force-push or rebase `fork/dev`. -2. Never merge a rewritten provenance tip directly into `fork/dev`; import its tree delta, then - record upstream ancestry with `merge -s ours`. +2. Merge `upstream/main` into `fork/dev` directly, and never a rebased provenance tip. 3. Never require a clean projection rebuild to ship an unrelated urgent fix. 4. Never release an untested `fork/dev` SHA; release the exact merge SHA, not the PR tip. 5. Every ordinary product change enters through a GitHub PR, squash merged. @@ -553,24 +524,28 @@ verify every rewritten layer, prove tree equivalence, and publish. - **Merge policy: squash.** Every PR into `fork/dev` becomes one commit, enforced by disabling merge and rebase merging repository-wide and by the `fork/dev` ruleset. The one exception is the upstream - ancestry merge, pushed by an automation holding a ruleset bypass actor. See - [Merge policy](#merge-policy-squash-decided). + merge, pushed with a ruleset bypass because squashing it would discard the second parent and with + it the upstream ancestry. See [Merge policy](#merge-policy-squash-decided). - **Validation and release are separate workflows.** `fork-ci` decides validity; `fork-release` acts on it. See [Releasing from `fork/dev`](#releasing-from-forkdev). -- **Upstream ancestry is recorded on `fork/dev`**, so "commits behind upstream" reads true. See - [Record upstream ancestry](#record-upstream-ancestry-so-behind-stays-readable). +- **Upstream is merged directly into `fork/dev`**, not imported as a tree delta from a rebuilt + provenance stack. `fork/dev` therefore carries upstream's real commits and "commits behind + upstream" reads true. See [Synchronizing Upstream](#synchronizing-upstream-into-forkdev). +- **The stack and overlay tooling is removed.** Nothing rebuilds `fork/base`, `fork/tim` or + `fork/candidates` on an upstream update. ## Open Decisions None of these block anything currently running: - Release cadence per target. Everything is immediate today; lagged promotion is available. -- Frequency of upstream/provenance synchronization. +- Frequency of upstream synchronization. - Whether identity, Discord, or VS Code needs a stable subsystem staging branch. - Whether clean downstream projection is ever built, and if so on what trigger. - Naming of the generated clean branches. - Exact rules for mapping shared-package changes to downstream consumers. -- When to delete `fork/changes` and `fork/integration`, and remove the overlay machinery. +- When to delete `fork/changes` and `fork/integration`, and whether to close the three + provenance PRs (#255, #1, #27) now that nothing rebuilds their branches. ## The Operating Principle diff --git a/docs/stack-history-rewrite.md b/docs/stack-history-rewrite.md deleted file mode 100644 index e1f9306974b..00000000000 --- a/docs/stack-history-rewrite.md +++ /dev/null @@ -1,121 +0,0 @@ -# Stack history rewrite (fold tip-only `fix(stack)` debt) - -> [!IMPORTANT] -> **Superseded. Do not follow this for new work.** -> -> The stack tooling this describes was removed. Upstream is merged straight into `fork/dev`, which is -> the only contributor target. See -> [stable-dev-release-branch-handover.md](./stable-dev-release-branch-handover.md). - -Goal: **layer tips green** and **replayed commits green**, without permanent product -`fix(stack): rejoin…` commits. - -## What to fold vs keep - -| Kind | Examples | Action | -| ---------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Product recovery | #165, #166 (VCS / BranchToolbar / worktree cleanup / CommandPalette) | Fold into the **Tim provenance or feature commit** that owns the surface; until then one well-named **product** commit (`fix(vcs):…`), never `fix(stack):` | -| Manifest-only | “record conflict resolution” commits that only touch `.github/pr-stack.json` | Squash into one `chore(stack): conflict resolution registry` (or the first stack-tooling commit in the range) | -| Stack machinery | rebase-pr-stack, compose, CI helpers | Keep; prefer `feat(fork-stack):` / `fix(fork-stack):` | -| Docs for stack | AGENTS / fork-stack policy | Keep with tooling | - -## Per-commit gate (required on rewrites) - -```bash -CI= pnpm install --no-frozen-lockfile -node scripts/rebase-pr-stack.ts sync --dry-run --verify-each-commit -# when ready: -node scripts/rebase-pr-stack.ts sync --push --verify-each-commit -``` - -On failure, fix the **commit being replayed** (or its conflict resolution). Do not push a new tip -patch and call the rewrite done. - -Same habit outside full restacks: when repairing `fork/changes` or an overlay tip with stack push -bypass, **amend/rewrite** the bad commit instead of leaving tip-only recovery commits. If a -permanent overlay draft PR was closed during the rewrite, **reopen that PR number** (after the -branch is fixed); do not mint a replacement unless reopen fails — then label **`OVERLAY`** and -update `pr-stack.json`. See [fork-stack.md](./fork-stack.md) (“Permanent draft PRs”, “Fixing layer -tips”). - -## Fold product #165 + #166 (already applied on `fork/changes` tip) - -Those commits restored main #4727 ref-refresh behavior **and** fork `failureKind` / worktree cleanup -/ reuse-base-branch after whole-file Tim policies dropped one side. Fold them into a single product -commit: - -```bash -git switch -C rewrite/fold-vcs-stack-fixes origin/fork/changes -# tip = #166, parent = #165, grandparent = durable resolutions only -git reset --soft HEAD~2 -git commit -m "$(cat <<'EOF' -fix(vcs): keep #4727 ref refresh with fork failureKind and worktree cleanup - -Join upstream Git ref-refresh resource-storm fixes with fork contracts -(failureKind, commit signing, worktree cleanup RPCs, reuse-base-branch UI) -instead of leaving tip-only fix(stack) recovery commits after Tim whole-file -conflict policies. - -EOF -)" -# force-with-lease push fork/changes only after full layer gate -``` - -Long-term: on the next **Tim** layer rewrite, re-resolve `GitVcsDriverCore*`, `vcs.ts`, -`BranchToolbarBranchSelector` as a **3-way product merge** into the Tim provenance commit that -touches VCS, then **drop** any remaining recovery commit on `fork/changes`. Durable whole-file -`ours`/`theirs` for those paths has been **removed** from `conflictResolutions` so the next sync -stops auto-taking one side. - -## Collapse manifest-only `fix(stack)` commits - -List candidates (only `.github/pr-stack.json`): - -```bash -git log --oneline origin/fork/candidates..origin/fork/changes --grep='fix(stack)' --name-only -``` - -Interactive rebase onto `origin/fork/candidates` and `fixup` pure-manifest commits into one -`chore(stack): conflict resolution registry` (or the first non-empty stack-tooling commit). Leave -commits that also touch product files alone until reviewed. - -Automated sketch (review the todo before running): - -```bash -# Produce a rebase todo that fixups consecutive manifest-only stack commits — review carefully. -git rebase -i origin/fork/candidates -``` - -Do **not** rewrite published SHAs without coordinating deploy/CI; use force-with-lease and recompose -`fork/integration`. - -## Tim / candidates layer reds - -`fork/tim` and `fork/candidates` may still fail full typecheck from older incomplete joins. Do not -paper over with changes-layer tips. Next full upstream stack rewrite: - -1. Rewrite `fork/tim` with product merges + `--verify-each-commit` (or per-commit typecheck by hand). -2. Only then `fork/candidates` → `fork/changes` → overlays → integration. -3. Full per-layer CI after each tip (AGENTS.md stop-the-line). - -## After any rewrite - -1. Per-layer full CI on each tip. -2. `node scripts/compose-integration-overlays.ts` (or stack workflow compose). -3. Full Fork CI on `fork/integration`. -4. Confirm no new product `fix(stack):` tips landed. - -## Historical note on per-commit typecheck - -Verifying **every** historical SHA with the tip `node_modules` will false-fail: older -`package.json` / lock pairs do not match. Meaningful `--verify-each-commit` use is during a -**forward** rewrite after `CI= pnpm install` on the new base, and after each pick when the -worktree install still matches (re-install when `package.json` / lock change). - -This rewrite (fold pure-manifest registry commits; keep tip tree identical) does **not** claim -every historical intermediate SHA typechecks in isolation — only that: - -1. tip tree is unchanged from the pre-rewrite product tip; -2. pure-manifest `fix(stack): record …` noise is collapsed into `chore(stack): durable conflictResolutions registry`; -3. product recovery is named `fix(vcs): …` not `fix(stack): rejoin …`; -4. going forward, rewrites use `--verify-each-commit` with a matching install.