From 4a7358959fa1aa52312d8b49aeaa4f534ab334b4 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:30:06 +0200 Subject: [PATCH] chore(fork-base): fork workflows, stack tools, and docs --- .github/VOUCHED.td | 35 - .github/client-overlay-ownership.json | 40 + .github/pr-stack.json | 92 + .github/pull_request_template.md | 18 + .github/upstream-candidates.json | 35 + .github/workflows/ci.yml | 8 +- .github/workflows/compose-integration.yml | 113 + .github/workflows/deploy-relay.yml | 3 +- .github/workflows/fork-ci.yml | 289 +++ .github/workflows/managed-pr-draft-lock.yml | 35 + .github/workflows/mobile-eas-development.yml | 119 + .github/workflows/mobile-eas-preview.yml | 32 +- .github/workflows/mobile-eas-production.yml | 58 +- .../workflows/mobile-showcase-screenshots.yml | 4 +- .github/workflows/pr-size.yml | 295 --- .github/workflows/pr-vouch.yml | 199 -- .github/workflows/rebase-pr-stack.yml | 70 + .github/workflows/release.yml | 35 +- AGENTS.md | 582 +++-- apps/web/src/lib/imageCompression.test.ts | 5 +- docs/client-overlays.md | 71 + docs/fork-base.md | 35 + docs/fork-stack.md | 672 +++++ docs/stack-history-rewrite.md | 114 + docs/stack-ship-path.md | 355 +++ package.json | 6 +- scripts/classify-deployment-diff.sh | 110 + scripts/classify-deployment-diff.test.sh | 50 + 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/lib/disk-backed-tmp.test.ts | 41 + scripts/lib/disk-backed-tmp.ts | 104 + scripts/rebase-integration-overlays.test.ts | 83 + scripts/rebase-integration-overlays.ts | 372 +++ scripts/rebase-pr-stack.test.ts | 1210 +++++++++ scripts/rebase-pr-stack.ts | 2204 +++++++++++++++++ vite.config.ts | 11 +- 41 files changed, 8656 insertions(+), 725 deletions(-) delete mode 100644 .github/VOUCHED.td create mode 100644 .github/client-overlay-ownership.json create mode 100644 .github/pr-stack.json create mode 100644 .github/upstream-candidates.json create mode 100644 .github/workflows/compose-integration.yml create mode 100644 .github/workflows/fork-ci.yml create mode 100644 .github/workflows/managed-pr-draft-lock.yml create mode 100644 .github/workflows/mobile-eas-development.yml delete mode 100644 .github/workflows/pr-size.yml delete mode 100644 .github/workflows/pr-vouch.yml create mode 100644 .github/workflows/rebase-pr-stack.yml create mode 100644 docs/client-overlays.md create mode 100644 docs/fork-base.md create mode 100644 docs/fork-stack.md create mode 100644 docs/stack-history-rewrite.md create mode 100644 docs/stack-ship-path.md create mode 100755 scripts/classify-deployment-diff.sh create mode 100755 scripts/classify-deployment-diff.test.sh create mode 100644 scripts/client-overlay-owner.test.ts create mode 100644 scripts/client-overlay-owner.ts create mode 100644 scripts/compose-integration-overlays.test.ts create mode 100644 scripts/compose-integration-overlays.ts create mode 100644 scripts/fork-stack.test.ts create mode 100755 scripts/fork-stack.ts create mode 100644 scripts/lib/disk-backed-tmp.test.ts create mode 100644 scripts/lib/disk-backed-tmp.ts create mode 100644 scripts/rebase-integration-overlays.test.ts create mode 100644 scripts/rebase-integration-overlays.ts create mode 100644 scripts/rebase-pr-stack.test.ts create mode 100644 scripts/rebase-pr-stack.ts diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 73376110d9a..00000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,35 +0,0 @@ -# Trust list for this repository. -# -# External contributors listed here are treated as trusted by the vouch -# workflow. Collaborators with write access are automatically trusted and -# do not need to be duplicated in this file. -# -# Syntax: -# github:username -# -github:username reason for denouncement -# -# Keep entries sorted alphabetically. -github:adityavardhansharma -github:binbandit -github:chuks-qua -github:cursoragent -github:gbarros-dev -github:github-actions[bot] -github:hwanseoc -github:jamesx0416 -github:jasonLaster -github:JoeEverest -github:maria-rcks -github:nmggithub -github:Noojuno -github:notkainoa -github:PatrickBauer -github:realAhmedRoach -github:shiroyasha9 -github:Yash-Singh1 -github:eggfriedrice24 -github:Ymit24 -github:shivamhwp -github:jappyjan -github:justsomelegs -github:UtkarshUsername diff --git a/.github/client-overlay-ownership.json b/.github/client-overlay-ownership.json new file mode 100644 index 00000000000..0c7cbcf9668 --- /dev/null +++ b/.github/client-overlay-ownership.json @@ -0,0 +1,40 @@ +{ + "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 new file mode 100644 index 00000000000..6742eb5e118 --- /dev/null +++ b/.github/pr-stack.json @@ -0,0 +1,92 @@ +{ + "upstreamRemote": "upstream", + "upstreamBranch": "main", + "forkChangesBranch": "fork/changes", + "integrationBranch": "fork/integration", + "pullRequests": [ + { + "number": 255, + "branch": "fork/base" + }, + { + "number": 1, + "branch": "fork/tim" + }, + { + "number": 27, + "branch": "fork/candidates" + }, + { + "number": 2, + "branch": "fork/changes" + } + ], + "integrationOverlays": [ + { + "number": 254, + "branch": "t3-discord/f7d37879-desktop-deeplinks" + }, + { + "number": 174, + "branch": "fork/discord" + }, + { + "number": 175, + "branch": "fork/vscode" + }, + { + "number": 250, + "branch": "fork/identity" + } + ], + "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" + } + ] +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 76aac7e4d85..dbb971f3bd0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,12 +19,30 @@ we may close it without merging it, or never review it. +## Downstream Fork Relationship + + + +## External-Fork Provenance + + + ## UI Changes +## Verification + + + ## Checklist - [ ] This PR is small and focused diff --git a/.github/upstream-candidates.json b/.github/upstream-candidates.json new file mode 100644 index 00000000000..1ad3b0a1c1a --- /dev/null +++ b/.github/upstream-candidates.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "candidates": [ + { + "upstreamPr": 4018, + "sourceSha": "de8fd65934768173819b93adcd6b92af3e8c7fc3", + "status": "active", + "purpose": "Bound server thread history and lazily page older web activity" + }, + { + "upstreamPr": 3510, + "sourceSha": "034f4936d7a1435887bb62ac3f2db61f08928cbf", + "status": "active", + "purpose": "Page mobile history and bound stale subscription catch-up" + }, + { + "upstreamPr": 4176, + "sourceSha": "56b6615afdfe3804a466e33cbab9056b8981f217", + "status": "active", + "purpose": "Bound long-lived orchestration, browser, preview, and VCS in-memory state" + }, + { + "upstreamPr": 4506, + "sourceSha": "f7eaa00b99e67a9c1caf09e2fe6ff1f536f66b91", + "status": "active", + "purpose": "Show answered provider questions in the web thread timeline" + }, + { + "upstreamPr": 4245, + "sourceSha": "6be48eb238cf310a60cc9571240601e774c7d381", + "status": "active", + "purpose": "Queue follow-up messages server-side during active turns with explicit steer controls" + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e51867cbe7..11457d7f65b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -59,7 +59,7 @@ jobs: test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -91,7 +91,7 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -119,7 +119,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/compose-integration.yml b/.github/workflows/compose-integration.yml new file mode 100644 index 00000000000..c73c38598a1 --- /dev/null +++ b/.github/workflows/compose-integration.yml @@ -0,0 +1,113 @@ +# 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/deploy-relay.yml b/.github/workflows/deploy-relay.yml index f652844a54f..bb2c8ff48f0 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,7 +17,8 @@ concurrency: jobs: deploy_relay: name: Deploy production relay - runs-on: blacksmith-8vcpu-ubuntu-2404 + if: github.repository == 'pingdotgg/t3code' + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: name: production diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml new file mode 100644 index 00000000000..3f203892342 --- /dev/null +++ b/.github/workflows/fork-ci.yml @@ -0,0 +1,289 @@ +name: Fork CI + +env: + # Install dependencies without downloading Electron in every job. The desktop jobs + # fetch and verify the runtime explicitly below; mobile lint and release smoke do not need it. + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + +on: + workflow_dispatch: + inputs: + # Tim/candidates provenance tips do not carry fork-ci.yml (only upstream + # Blacksmith ci.yml). Dispatch against fork/changes (or any ref that has + # this workflow) and set checkout_ref to the tip under test so checks + # attach to that SHA / permanent draft PR. + checkout_ref: + description: "Git ref or SHA to check out and test (default: dispatch ref)" + required: false + type: string + default: "" + # Full PR CI for product layers. Job names match required checks (Check / Test / + # Mobile Native Static Analysis / Release Smoke) on protected bases. + # + # Permanent stack PRs (bases): + # - #255 base → main (dispatch only; do not list main here) + # - #1 tim → fork/base + # - #27 candidates → fork/tim + # - #2 changes → fork/candidates + # + # Upstream Blacksmith CI must stay disabled_manually. fork/base supplies + # GitHub-hosted runners + fork-ci.yml for all layers above it. + # + # Keep overlay bases in sync with integrationOverlays in pr-stack.json. + pull_request: + branches: + - fork/base + - fork/tim + - fork/candidates + - fork/changes + # Registered integration overlays (same names as .github/pr-stack.json) + - t3-discord/f7d37879-desktop-deeplinks + - fork/discord + - fork/vscode + - fork/identity + +concurrency: + group: ci-${{ github.event.pull_request.number || inputs.checkout_ref || github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Restore Vite Task check cache + id: vite-task-check-cache + uses: actions/cache/restore@v6 + with: + path: node_modules/.vite/task-cache + key: vite-task-check-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: | + vite-task-check-${{ runner.os }}-${{ runner.arch }}- + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Check + run: vp check + + - name: Typecheck + run: vp run -r --cache --log labeled typecheck + + - name: Build desktop pipeline + run: vp run --cache build:desktop + + - name: Verify preload bundle output + run: | + test -f apps/desktop/dist-electron/preload.cjs + grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs + grep -n "__clerk_internal_electron_passkeys" apps/desktop/dist-electron/preload.cjs + + - name: Save Vite Task check cache + if: success() + uses: actions/cache/save@v6 + with: + path: node_modules/.vite/task-cache + key: ${{ steps.vite-task-check-cache.outputs.cache-primary-key }} + + test: + name: Test + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Test + run: vp run test + + mobile_native_static_analysis: + name: Mobile Native Static Analysis + runs-on: macos-15 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + # Saving the complete macOS node_modules cache takes ~95 seconds and + # loses the cache reservation whenever parallel PR runs overlap. + # A clean install is faster and has deterministic completion time. + cache: false + run-install: true + + - name: Ensure Electron runtime is installed + run: vp run --filter @t3tools/desktop ensure:electron + + - name: Test macOS Open With integration + # Present from Tim onward; fork/base is CI plumbing only and lacks this path. + run: | + if [[ -f apps/desktop/src/shell/DesktopOpenWith.test.ts ]]; then + vp test run apps/desktop/src/shell/DesktopOpenWith.test.ts + else + echo "DesktopOpenWith tests not on this tip; skipping." + fi + + - name: Install mobile native static analysis tools + run: brew bundle install --file apps/mobile/Brewfile + + - name: Lint mobile native sources + run: vp run lint:mobile + + release_smoke: + name: Release Smoke + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Exercise release-only workflow steps + run: node scripts/release-smoke.ts + + deployment_scope: + name: Classify Deployment Scope + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + deploy: ${{ steps.classify.outputs.deploy }} + server: ${{ steps.classify.outputs.server }} + discord: ${{ steps.classify.outputs.discord }} + vscode: ${{ steps.classify.outputs.vscode }} + mobile: ${{ steps.classify.outputs.mobile }} + desktop: ${{ steps.classify.outputs.desktop }} + steps: + - name: Checkout integration source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Find previous successful integration CI + id: previous + env: + GH_TOKEN: ${{ github.token }} + run: | + previous_sha="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-ci.yml/runs" \ + -f branch=fork/integration \ + -f event=workflow_dispatch \ + -f status=success \ + -f per_page=20 \ + --jq ".workflow_runs | map(select(.head_sha != \"${GITHUB_SHA}\")) | first | .head_sha // \"\"" + )" + echo "sha=${previous_sha}" >>"${GITHUB_OUTPUT}" + + - name: Classify changes since previous successful integration CI + id: classify + env: + PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + run: | + deploy=true + server=true + discord=true + vscode=true + mobile=true + desktop=true + if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]]; then + if ! git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + git fetch --quiet origin "${PREVIOUS_SHA}" || true + fi + if git cat-file -e "${PREVIOUS_SHA}^{commit}" 2>/dev/null; then + classification="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" + )" + printf '%s\n' "${classification}" >&2 + deploy="$(sed -n 's/^deploy=//p' <<<"${classification}")" + server="$(sed -n 's/^server=//p' <<<"${classification}")" + discord="$(sed -n 's/^discord=//p' <<<"${classification}")" + vscode="$(sed -n 's/^vscode=//p' <<<"${classification}")" + mobile="$(sed -n 's/^mobile=//p' <<<"${classification}")" + desktop="$(sed -n 's/^desktop=//p' <<<"${classification}")" + else + echo "Previous successful integration SHA is unavailable; deployment remains enabled." + fi + else + echo "No previous successful integration SHA; deployment remains enabled." + fi + echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" + echo "server=${server}" >>"${GITHUB_OUTPUT}" + echo "discord=${discord}" >>"${GITHUB_OUTPUT}" + echo "vscode=${vscode}" >>"${GITHUB_OUTPUT}" + echo "mobile=${mobile}" >>"${GITHUB_OUTPUT}" + echo "desktop=${desktop}" >>"${GITHUB_OUTPUT}" + + dispatch_mobile_releases: + name: Dispatch Mobile Releases + needs: [check, test, mobile_native_static_analysis, release_smoke, deployment_scope] + if: | + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/fork/integration' && + needs.deployment_scope.outputs.mobile == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Dispatch exact integration SHA + env: + GH_TOKEN: ${{ github.token }} + run: | + # mode=auto, not update: an OTA alone never reaches a phone when the + # native runtime changed, so the fingerprint decides between an update + # and a TestFlight build. iOS only, because Android has no keystore. + gh workflow run mobile-eas-production.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref fork/integration \ + -f mode=auto \ + -f platform=ios \ + -f sha="$GITHUB_SHA" \ + -f message="Integration ${GITHUB_SHA}" + gh workflow run mobile-eas-development.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref fork/integration \ + -f platform=ios \ + -f sha="$GITHUB_SHA" diff --git a/.github/workflows/managed-pr-draft-lock.yml b/.github/workflows/managed-pr-draft-lock.yml new file mode 100644 index 00000000000..0ed1a8e5a9c --- /dev/null +++ b/.github/workflows/managed-pr-draft-lock.yml @@ -0,0 +1,35 @@ +name: Managed PR draft lock + +on: + pull_request_target: + types: [opened, reopened, ready_for_review, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + keep-draft: + name: Keep managed PR draft + runs-on: ubuntu-24.04 + steps: + - name: Restore draft state without changing CI status + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + IS_DRAFT: ${{ github.event.pull_request.draft }} + run: | + manifest="$( + gh api \ + -H 'Accept: application/vnd.github.raw+json' \ + "repos/${REPOSITORY}/contents/.github/pr-stack.json?ref=fork/changes" + )" + if ! jq -e --argjson number "${PR_NUMBER}" \ + '([.pullRequests[], .integrationOverlays[]] | any(.number == $number))' \ + <<<"${manifest}" >/dev/null; then + exit 0 + fi + if [[ "${IS_DRAFT}" != "true" ]]; then + gh pr ready "${PR_NUMBER}" --undo --repo "${REPOSITORY}" + fi diff --git a/.github/workflows/mobile-eas-development.yml b/.github/workflows/mobile-eas-development.yml new file mode 100644 index 00000000000..cc5e9c4a4ad --- /dev/null +++ b/.github/workflows/mobile-eas-development.yml @@ -0,0 +1,119 @@ +name: Mobile EAS Development + +# Keep the installable development client current without rebuilding it for +# JavaScript-only changes. Expo Fingerprint reuses a compatible native build +# and publishes an OTA update; native changes produce a new internal build. +on: + workflow_dispatch: + inputs: + platform: + description: "Target platform" + required: true + type: choice + default: ios + options: + - ios + - android + - all + sha: + description: "Exact fork/integration SHA (blank uses its current tip)" + required: false + type: string + +concurrency: + group: mobile-eas-development + cancel-in-progress: false + +jobs: + development: + name: EAS Development + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + APP_VARIANT: development + NODE_OPTIONS: --max-old-space-size=8192 + MOBILE_VERSION_POLICY: fingerprint + T3CODE_MOBILE_EAS_PROJECT_ID: ${{ vars.T3CODE_MOBILE_EAS_PROJECT_ID }} + T3CODE_MOBILE_EXPO_OWNER: ${{ vars.T3CODE_MOBILE_EXPO_OWNER }} + T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER: ${{ vars.T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER }} + T3CODE_MOBILE_IOS_TEAM_ID: ${{ vars.T3CODE_MOBILE_IOS_TEAM_ID }} + steps: + - id: expo-token + name: Check for EXPO_TOKEN + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + if [ -n "$EXPO_TOKEN" ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "EXPO_TOKEN is not available; skipping EAS development job." + fi + + - name: Checkout + if: steps.expo-token.outputs.present == 'true' + uses: actions/checkout@v6 + with: + ref: fork/integration + fetch-depth: 0 + + - name: Resolve approved integration source + if: steps.expo-token.outputs.present == 'true' + env: + REQUESTED_SHA: ${{ inputs.sha }} + run: | + integration_sha="$(git rev-parse HEAD)" + target_sha="${REQUESTED_SHA:-$integration_sha}" + if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "sha must be an exact 40-character commit SHA" >&2 + exit 1 + fi + git fetch origin "$target_sha" + git merge-base --is-ancestor "$target_sha" "$integration_sha" + git checkout --detach "$target_sha" + test "$(git rev-parse HEAD)" = "$target_sha" + + - name: Setup Vite+ + if: steps.expo-token.outputs.present == 'true' + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Expose pnpm + if: steps.expo-token.outputs.present == 'true' + run: | + pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" + vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" + echo "$vp_pnpm_bin" >> "$GITHUB_PATH" + "$vp_pnpm_bin/pnpm" --version + + - name: Setup EAS + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action@v8 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + packager: npm + + - name: Pull development environment variables + if: steps.expo-token.outputs.present == 'true' + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: eas env:pull development --non-interactive + + - name: Publish compatible update or development build + if: steps.expo-token.outputs.present == 'true' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + with: + profile: development + branch: development + platform: ${{ inputs.platform }} + environment: development + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-preview.yml b/.github/workflows/mobile-eas-preview.yml index d53602f8f5e..0e6afb6c3e4 100644 --- a/.github/workflows/mobile-eas-preview.yml +++ b/.github/workflows/mobile-eas-preview.yml @@ -2,18 +2,17 @@ name: Mobile EAS Preview on: pull_request: - types: [opened, reopened, synchronize, labeled] + # Preview builds belong to feature PRs. The permanent fork stack PRs target + # lower provenance layers and must not create a preview run on every rebase. + branches: + - fork/changes + types: [opened, reopened, synchronize, labeled, unlabeled] jobs: preview: name: EAS Preview - if: | - contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') && - (github.event.action != 'labeled' || github.event.label.name == '🚀 Mobile Continuous Deployment') - runs-on: blacksmith-8vcpu-ubuntu-2404 - concurrency: - group: mobile-eas-preview-${{ github.event.pull_request.number }} - cancel-in-progress: true + if: contains(github.event.pull_request.labels.*.name, '🚀 Mobile Continuous Deployment') + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write @@ -39,10 +38,6 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 - # No sparse-checkout here: it makes actions/checkout fetch with - # --filter=blob:none, and eas-cli archives the project via - # `git clone --depth 1 file://`, which fails (exit 128) - # when the partial clone can't serve the unfetched blobs. - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' @@ -50,9 +45,7 @@ jobs: with: node-version-file: package.json cache: true - run-install: | - args: - - --filter=@t3tools/mobile... + run-install: true - name: Expose pnpm if: steps.expo-token.outputs.present == 'true' @@ -86,9 +79,14 @@ jobs: env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} with: - profile: preview:dev + # Release configuration, not `preview:dev`: PR builds must show + # production-grade performance and memory behaviour. The dev-client + # `preview:dev` profile stays available for local Metro attachment. + profile: preview branch: pr-${{ github.event.pull_request.number }} - platform: all + # iOS only: Android has no signing keystore configured, so including + # it fails the job before the iOS artifact is published. + platform: ios environment: preview working-directory: apps/mobile github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index 2e61de6039e..560c5b62e1c 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -9,11 +9,12 @@ on: workflow_dispatch: inputs: mode: - description: "build (+ auto-submit to TestFlight) or update (OTA)" + description: "auto (fingerprint decides), build (+ auto-submit to TestFlight), or update (OTA)" required: true type: choice - default: build + default: auto options: + - auto - build - update platform: @@ -29,16 +30,28 @@ on: description: "OTA update message (mode=update only)" required: false type: string + sha: + description: "Exact fork/integration SHA (blank uses its current tip)" + required: false + type: string + +concurrency: + group: mobile-eas-production + cancel-in-progress: false jobs: production: name: EAS Production ${{ inputs.mode }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read env: APP_VARIANT: production NODE_OPTIONS: --max-old-space-size=8192 + T3CODE_MOBILE_EAS_PROJECT_ID: ${{ vars.T3CODE_MOBILE_EAS_PROJECT_ID }} + T3CODE_MOBILE_EXPO_OWNER: ${{ vars.T3CODE_MOBILE_EXPO_OWNER }} + T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER: ${{ vars.T3CODE_MOBILE_IOS_BUNDLE_IDENTIFIER }} + T3CODE_MOBILE_IOS_TEAM_ID: ${{ vars.T3CODE_MOBILE_IOS_TEAM_ID }} steps: - id: expo-token name: Check for EXPO_TOKEN @@ -56,12 +69,31 @@ jobs: if: steps.expo-token.outputs.present == 'true' uses: actions/checkout@v6 with: + ref: fork/integration fetch-depth: 0 # No sparse-checkout here: it makes actions/checkout fetch with # --filter=blob:none, and eas-cli archives the project via # `git clone --depth 1 file://`, which fails (exit 128) # when the partial clone can't serve the unfetched blobs. + - id: source + name: Resolve approved integration source + if: steps.expo-token.outputs.present == 'true' + env: + REQUESTED_SHA: ${{ inputs.sha }} + run: | + integration_sha="$(git rev-parse HEAD)" + target_sha="${REQUESTED_SHA:-$integration_sha}" + if [[ ! "$target_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "sha must be an exact 40-character commit SHA" >&2 + exit 1 + fi + git fetch origin "$target_sha" + git merge-base --is-ancestor "$target_sha" "$integration_sha" + git checkout --detach "$target_sha" + test "$(git rev-parse HEAD)" = "$target_sha" + echo "sha=$target_sha" >> "$GITHUB_OUTPUT" + - name: Setup Vite+ if: steps.expo-token.outputs.present == 'true' uses: voidzero-dev/setup-vp@v1 @@ -98,6 +130,24 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive + # Fingerprint decides: a JavaScript-only integration publishes an OTA + # update to the production channel, which the installed TestFlight build + # picks up on next launch. A change to native runtime inputs starts a + # production build and submits it to TestFlight instead. + - name: Deploy with fingerprint check + if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'auto' + uses: expo/expo-github-action/continuous-deploy-fingerprint@main + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + with: + profile: production + branch: production + platform: ${{ inputs.platform }} + environment: production + auto-submit-builds: true + working-directory: apps/mobile + github-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build and submit if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' working-directory: apps/mobile @@ -115,5 +165,5 @@ jobs: --channel production \ --environment production \ --platform ${{ inputs.platform }} \ - --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ + --message "${{ inputs.message || format('Production OTA ({0})', steps.source.outputs.sha) }}" \ --non-interactive diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index 3eaaf508e31..d02cea5e8a6 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -32,7 +32,7 @@ jobs: ios: name: iPhone 6.9, iPhone 6.5, and iPad 13 if: inputs.platform == 'all' || inputs.platform == 'ios' - runs-on: blacksmith-12vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout @@ -79,7 +79,7 @@ jobs: android: name: Android phone, 7-inch tablet, and 10-inch tablet if: inputs.platform == 'all' || inputs.platform == 'android' - runs-on: blacksmith-16vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 60 env: T3_SHOWCASE_ANDROID_ABI: x86_64 diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml deleted file mode 100644 index af557dff62d..00000000000 --- a/.github/workflows/pr-size.yml +++ /dev/null @@ -1,295 +0,0 @@ -name: PR Size - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - -permissions: - contents: read - -jobs: - prepare-config: - name: Prepare PR size config - runs-on: ubuntu-24.04 - outputs: - labels_json: ${{ steps.config.outputs.labels_json }} - steps: - - id: config - name: Build PR size label config - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const managedLabels = [ - { - name: "size:XS", - color: "0e8a16", - description: "0-9 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:S", - color: "5ebd3e", - description: "10-29 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:M", - color: "fbca04", - description: "30-99 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:L", - color: "fe7d37", - description: "100-499 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XL", - color: "d93f0b", - description: "500-999 effective changed lines (test files excluded in mixed PRs).", - }, - { - name: "size:XXL", - color: "b60205", - description: "1,000+ effective changed lines (test files excluded in mixed PRs).", - }, - ]; - - core.setOutput("labels_json", JSON.stringify(managedLabels)); - sync-label-definitions: - name: Sync PR size label definitions - needs: prepare-config - if: github.event_name != 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: write - steps: - - name: Ensure PR size labels exist - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - label: - name: Label PR size - needs: prepare-config - if: github.event_name == 'pull_request_target' - runs-on: ubuntu-24.04 - permissions: - contents: read - issues: read - pull-requests: write - concurrency: - group: pr-size-${{ github.event.pull_request.number }} - cancel-in-progress: true - steps: - # This pull_request_target job may fetch untrusted PR commits only as passive - # git data. Do not add dependency installs, build/test scripts, or cache - # actions here; use pull_request plus workflow_run for that pattern instead. - - name: Checkout base repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Sync PR size label - uses: actions/github-script@v8 - env: - PR_SIZE_LABELS_JSON: ${{ needs.prepare-config.outputs.labels_json }} - with: - script: | - const { execFileSync } = require("node:child_process"); - - const issueNumber = context.payload.pull_request.number; - const baseSha = context.payload.pull_request.base.sha; - const headSha = context.payload.pull_request.head.sha; - const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`; - const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - // Keep this aligned with the repo's test entrypoints and test-only support files. - const testExcludePathspecs = [ - ":(glob,exclude)**/__tests__/**", - ":(glob,exclude)**/test/**", - ":(glob,exclude)**/tests/**", - ":(glob,exclude)apps/server/integration/**", - ":(glob,exclude)**/*.test.*", - ":(glob,exclude)**/*.spec.*", - ":(glob,exclude)**/*.browser.*", - ":(glob,exclude)**/*.integration.*", - ]; - - const sumNumstat = (text) => - text - .split("\n") - .filter(Boolean) - .reduce((total, line) => { - const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t"); - const additions = - insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0; - const deletions = - deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0; - - return total + additions + deletions; - }, 0); - - const resolveSizeLabel = (totalChangedLines) => { - if (totalChangedLines < 10) { - return "size:XS"; - } - - if (totalChangedLines < 30) { - return "size:S"; - } - - if (totalChangedLines < 100) { - return "size:M"; - } - - if (totalChangedLines < 500) { - return "size:L"; - } - - if (totalChangedLines < 1000) { - return "size:XL"; - } - - return "size:XXL"; - }; - - execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { - stdio: "inherit", - }); - - execFileSync( - "git", - ["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`], - { - stdio: "inherit", - }, - ); - - const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], { - encoding: "utf8", - }).trim(); - - if (resolvedHeadSha !== headSha) { - core.warning( - `Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}; using fetched ref for sizing.`, - ); - } - - execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { - stdio: "inherit", - }); - - const diffArgs = [ - "diff", - "--numstat", - "--ignore-all-space", - "--ignore-blank-lines", - `${baseSha}...${resolvedHeadSha}`, - ]; - - const totalChangedLines = sumNumstat( - execFileSync( - "git", - diffArgs, - { encoding: "utf8" }, - ), - ); - const nonTestChangedLines = sumNumstat( - execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { - encoding: "utf8", - }), - ); - const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines); - - const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; - const nextLabelName = resolveSizeLabel(changedLines); - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - const classification = - nonTestChangedLines === 0 - ? testChangedLines > 0 - ? "test-only PR" - : "no line changes" - : testChangedLines > 0 - ? "test lines excluded" - : "all non-test changes"; - - core.info( - `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, - ); diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml deleted file mode 100644 index c4abb08b727..00000000000 --- a/.github/workflows/pr-vouch.yml +++ /dev/null @@ -1,199 +0,0 @@ -name: PR Vouch - -on: - pull_request_target: - types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] - issue_comment: - types: [created] - push: - branches: - - main - paths: - - .github/VOUCHED.td - - .github/workflows/pr-vouch.yml - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - collect-targets: - name: Collect PR targets - runs-on: ubuntu-24.04 - outputs: - targets: ${{ steps.collect.outputs.targets }} - steps: - - id: collect - uses: actions/github-script@v8 - with: - script: | - if (context.eventName === "pull_request_target") { - const pr = context.payload.pull_request; - core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); - return; - } - - if (context.eventName === "issue_comment") { - const issue = context.payload.issue; - const body = context.payload.comment?.body ?? ""; - if (!issue?.pull_request || !body.includes("/recheck-vouch")) { - core.setOutput("targets", "[]"); - return; - } - - core.setOutput( - "targets", - JSON.stringify([{ number: issue.number, user: issue.user.login }]), - ); - return; - } - - const pulls = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - per_page: 100, - }); - - const targets = pulls.map((pull) => ({ - number: pull.number, - user: pull.user.login, - })); - core.setOutput("targets", JSON.stringify(targets)); - - label: - name: Label PR ${{ matrix.target.number }} - needs: collect-targets - if: ${{ needs.collect-targets.outputs.targets != '[]' }} - runs-on: ubuntu-24.04 - concurrency: - group: pr-vouch-${{ matrix.target.number }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - target: ${{ fromJson(needs.collect-targets.outputs.targets) }} - steps: - - id: vouch - name: Check PR author trust - uses: mitchellh/vouch/action/check-user@v1 - with: - user: ${{ matrix.target.user }} - allow-fail: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Sync PR labels - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ matrix.target.number }} - VOUCH_STATUS: ${{ steps.vouch.outputs.status }} - with: - script: | - const issueNumber = Number(process.env.PR_NUMBER); - const status = process.env.VOUCH_STATUS; - const managedLabels = [ - { - name: "vouch:trusted", - color: "1f883d", - description: "PR author is trusted by repo permissions or the VOUCHED list.", - }, - { - name: "vouch:unvouched", - color: "fbca04", - description: "PR author is not yet trusted in the VOUCHED list.", - }, - { - name: "vouch:denounced", - color: "d1242f", - description: "PR author is explicitly blocked by the VOUCHED list.", - }, - ]; - - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); - - for (const label of managedLabels) { - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - }); - - if ( - existing.color !== label.color || - (existing.description ?? "") !== label.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: label.name, - color: label.color, - description: label.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - } - - const nextLabelName = - status === "denounced" - ? "vouch:denounced" - : ["bot", "collaborator", "vouched"].includes(status) - ? "vouch:trusted" - : "vouch:unvouched"; - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - - for (const label of currentLabels) { - if (!managedLabelNames.has(label.name) || label.name === nextLabelName) { - continue; - } - - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: label.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - if (!currentLabels.some((label) => label.name === nextLabelName)) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [nextLabelName], - }); - } - - core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/.github/workflows/rebase-pr-stack.yml b/.github/workflows/rebase-pr-stack.yml new file mode 100644 index 00000000000..50b0b9ee215 --- /dev/null +++ b/.github/workflows/rebase-pr-stack.yml @@ -0,0 +1,70 @@ +# 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/.github/workflows/release.yml b/.github/workflows/release.yml index a9754f9421b..a03c7450f01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,6 @@ on: tags: - "v*.*.*" - "!v*-nightly.*" - schedule: - - cron: "0 */3 * * *" workflow_dispatch: inputs: channel: @@ -21,6 +19,11 @@ on: description: "Release version (for example 1.2.3 or v1.2.3)" required: false type: string + artifacts_only: + description: "Build downloadable desktop artifacts without publishing a release" + required: false + default: false + type: boolean permissions: contents: read @@ -30,7 +33,7 @@ jobs: check_changes: name: Check for changes since last nightly if: github.event_name == 'schedule' - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: @@ -70,7 +73,7 @@ jobs: if: | !failure() && !cancelled() && (github.event_name != 'schedule' || needs.check_changes.outputs.has_changes == 'true') - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: release_channel: ${{ steps.release_meta.outputs.release_channel }} @@ -178,7 +181,7 @@ jobs: name: Resolve T3 Connect public config needs: preflight if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 environment: name: production @@ -274,7 +277,7 @@ jobs: name: Build WSL node-pty (linux-x64) needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout @@ -339,28 +342,28 @@ jobs: matrix: include: - label: macOS arm64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: arm64 rust_target: aarch64-apple-darwin resource_key: darwin-arm64 - label: macOS x64 - runner: blacksmith-12vcpu-macos-26 + runner: macos-15 platform: mac target: dmg arch: x64 rust_target: x86_64-apple-darwin resource_key: darwin-x64 - label: Linux x64 - runner: blacksmith-32vcpu-ubuntu-2404 + runner: ubuntu-24.04 platform: linux target: AppImage arch: x64 rust_target: x86_64-unknown-linux-gnu resource_key: linux-x64 - label: Windows x64 - runner: blacksmith-32vcpu-windows-2025 + runner: windows-2025 platform: win target: nsis arch: x64 @@ -665,8 +668,8 @@ jobs: publish_cli: name: Publish CLI to npm needs: [preflight, relay_public_config, build] - if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} - runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 + if: ${{ !failure() && !cancelled() && inputs.artifacts_only != true && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.build.result == 'success' }} + runs-on: ubuntu-24.04 # ubuntu-24.04 timeout-minutes: 10 permissions: contents: read @@ -745,7 +748,7 @@ jobs: name: Publish GitHub Release needs: [preflight, build, publish_cli] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' && needs.publish_cli.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: write @@ -860,7 +863,7 @@ jobs: name: Deploy hosted web app needs: [preflight, relay_public_config, release] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.relay_public_config.result == 'success' && needs.release.result == 'success' }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 env: T3CODE_CLERK_PUBLISHABLE_KEY: ${{ needs.relay_public_config.outputs.clerk_publishable_key }} @@ -979,7 +982,7 @@ jobs: name: Finalize release if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.release.result == 'success' && needs.preflight.outputs.release_channel == 'stable' }} needs: [preflight, release] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - id: app_token @@ -1064,7 +1067,7 @@ jobs: needs.deploy_web.result == 'success' && (needs.finalize.result == 'success' || needs.finalize.result == 'skipped') needs: [preflight, relay_public_config, release, deploy_web, finalize] - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/AGENTS.md b/AGENTS.md index c3a7fe92bf4..f4b87afae94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,148 +1,434 @@ -# T3 Code - -T3 Code is a minimal GUI for coding agents. A Node WebSocket server wraps provider CLIs (Codex, Claude Code, Cursor, Grok, OpenCode) and serves web, desktop, and mobile clients. - -You can think of T3 Code as an open source "bring-your-own-subscription" alternative to apps like Claude Desktop, Codex App, Cursor Glass and Conductor. - -## What makes T3 Code special? - -We have over 100,000 users who love T3 Code. It's important we maintain the things they love as we continue to iterate on the product. Here's a brief list of the things we can never compromise on. - -### 1. Open at the core - -T3 Code is truly open. We share our roadmap, we share how we think about things, and of course we share all our code. A large number of our users run forks. We work in the open, and should strive to stay that way. - -### 2. Performance without compromise - -Lots of apps have gotten bogged down with bad tech decisions and "slop". We have not, and we're proud of the performance of T3 Code. We regularly audit for performance regressions, often caused by sending too much data over websockets, css animations causing gpu spikes, lists being hard to render, and more. Make sure all changes are considerate of performance impact. - -### 3. Remote ready - -The architecture of T3 Code's websocket layer (npx t3) enables a lot of awesome remote features. These have become core to the product. Whether users are connecting directly over their local network, using Tailscale, or leaning in fully with T3 Connect (our tunnel solution, also in this repo), we need to make sure new features are properly supported. - -### 4. Multi-surface - -T3 Code has 3 key app surfaces: **web**, **desktop**, and **mobile**. - -**Web** is kind of two surfaces, as we have the public facing "app.t3.codes" as well as locally hosting the web app through the `npx t3` command. Both need to be supported by all new features where reasonable. - -**Desktop** is the main surface most users install first. It's a full Electron app that bundles the server runner as well. The desktop app can also be used as the host server, allowing remote connections from app.t3.codes or the mobile app. - -**Mobile** is a React Native app for both iOS and Android, available on the App Store and Google Play. The mobile app allows for connecting to any T3 Code server to control work remotely. - -## A note from Theo - -I like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising. - -Channel both "measure twice, cut once" and "yagni". Fight scope creep. Try to honor the dev's intent in both a minimal and realistic fashion. - -The rest of this document is meant to help you navigate the codebase and make changes effectively. Think of these instructions less as "hard rules", more as "good defaults". The developer's preferences should be able to override anything here. - -Of note: Most T3 Code contributions will come from T3 Code itself, often controlled remotely. This means you should be careful about accessing data, killing dev servers, and other things that may damage the T3 Code instance that the contributor is using. - -## A small glossary - -We need to be on the same page with terminology. When communicating, use this language: - -- **you** means the agent reading this file and changing T3 Code. -- **we, us, and maintainers** mean Theo, Julius and the people building T3 Code. These are who you are talking to now. -- **user** means the person using T3 Code to direct coding agents. -- **agent** means the coding agent a user runs inside T3 Code. Depending on context, that may also include you. -- **provider** means the agent runtime or harness T3 Code talks to, such as Codex, Claude, Cursor, or OpenCode. -- **client** means the web, desktop, or mobile UI. -- **environment** means one running T3 server and the machine, filesystem, provider credentials, and state it owns. -- **project** means an environment-local workspace record rooted at a directory. -- **thread** means the durable conversation and work history for a project. -- **turn** means one user-to-agent cycle, including follow-up work such as checkpointing. -- **T3 home** means the base data directory. Runtime state normally lives below its userdata directory. - -## The three ways to hurt yourself - -1. **Killing by pattern.** Never `pkill -f`, `pgrep | kill`, or `kill` a PID you found by matching a name, path, or worktree string. Your own agent process has this worktree's path in its argv, and this machine runs several other dev servers at once. Kill only a PID you captured at spawn, or the owner of your port from `ss -H -ltnp` after confirming `/proc//cwd` is your worktree. -2. **Writing to the live install.** `~/.t3/userdata` is the developer's real T3 Code database, in use while you work. Reading it and copying from it are fine, and a good way to get real test data (see Test data). Never start a server against it, never open it read-write, never clean it up. -3. **Baking in origins.** Never set `VITE_HTTP_URL` or `VITE_WS_URL` for dev. Dev is single-origin and Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known`. Setting them bakes localhost into the bundle and silently breaks every remote browser. - -## Hit every surface - -The most common defect in this repo is a change that works on the path you tested and is missing everywhere else. Before calling frontend work done, walk this list and say which entries applied: - -- **Entry points.** A behavior reachable from the chat view is usually also reachable from Settings, the command palette, and a keybinding. Fixing one is not fixing the feature. -- **Clients.** Web, desktop (wraps web, adds Electron shell/IPC), and mobile (React Native, separate navigation). Shared logic lives in `packages/client-runtime` -- **Providers.** Codex, Claude, Cursor, Grok, and OpenCode each have an adapter. Provider-shaped features need a decision per adapter, even if the decision is "not supported here". -- **Contracts.** Anything crossing the wire is typed in `packages/contracts`. Change the schema and the server, web, mobile, and desktop all follow. -- **Reverse states.** If you added a way in, add the way out and the way to see it. Snooze needs unsnooze. Close needs reopen. A one-way door is a bug. -- **Connection modes.** Local, remote/relay, and tunnel behave differently. Multi-device and multi-environment cases are real. -- **Docs.** `docs/` splits by audience. Behavior changes that a user would notice belong in `docs/user/` (shipped-product voice, no repo tooling or source paths); architecture and contributor changes in `docs/internals/`; runbooks in `docs/operations/`; new vocabulary in `docs/internals/glossary.md`. - -## Dev servers - -- `vp i` installs. Worktrees get this from the t3.json setup script; if module resolution looks broken, it probably did not run. -- `vp run dev` starts server and web. In a worktree, state defaults to that worktree's gitignored `.t3`, which deliberately outranks an ambient `T3CODE_HOME` so you cannot land on shared state by accident. An explicit `--home-dir` still wins. -- Ports derive from the worktree path and are stable across restarts, but read the real ones from the `[dev-runner]` line since occupied ports shift. -- `--share` publishes over the tailnet. Do not open the URL when you use this, just send it to the user with the pairing code included in url -- The web app requires pairing. Hand over the pairing URL, not the bare origin. A URL without its token is useless to whoever you gave it to. -- Stop what you started, by the PID you tracked. See rule 1. - -## Test data - -An empty database is a bad test. Seed your worktree's `.t3` with a copy of real data instead of pointing at live state: - -- Copy from `~/.t3/userdata` (the developer's real data, the most realistic test set) or `~/.t3/dev`. Worktree state lives at `/.t3/userdata`. -- Snapshot the database with `VACUUM INTO`, which is safe even while a server has the source open and yields one consistent file: - - ```bash - mkdir -p .t3/userdata - rm -f .t3/userdata/state.sqlite* # VACUUM INTO refuses to overwrite - bun -e "new (require('bun:sqlite').Database)(process.env.HOME + '/.t3/userdata/state.sqlite', { readonly: true }).run(\"VACUUM INTO '.t3/userdata/state.sqlite'\")" - ``` - - A plain `cp` is only safe when no server has the source open, and must bring the `-wal` and `-shm` siblings along. A live file copy is a corrupt copy. - -- Bring `secrets` and `settings.json` only if the flow under test needs them. -- Copy in, never symlink. Data flows one way: into your sandbox, never back out. - -## Verifying - -- Smallest proof that the change works. `vp test run ` for the tests you touched, targeted lint and typecheck for the scope you changed. -- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. -- Backend behavior changes ship with focused tests for that behavior. -- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong. -- Upon request, user-visible frontend changes should get one integrated pass in a real client: `test-t3-app` for web, `test-t3-mobile` for mobile. The primary agent does this once after integrating. Subagents do not launch their own dev servers. Ask permission before doing computer use or spinning up browsers. - -## Pull requests - -- Never make a PR unless the developer explicitly asks you to do so. -- Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. -- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. -- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. -- UI changes need before/after images. Motion or timing needs a short video. -- One concern per PR. If the description says "also", split it. -- When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. - -## How it works - -Clients send typed WebSocket requests. The server turns them into _commands_, a pure _decider_ turns commands into persisted _events_, and a _projector_ derives the read model the UI renders. Provider CLIs run as subprocesses; per-provider _adapters_ translate their native protocols into orchestration events. Side effects run in queue-backed _reactors_ that emit _receipts_ when milestones land. Each turn ends with a _checkpoint_, a hidden git ref, so the app can diff and restore. - -Full glossary with file links: `docs/internals/glossary.md` - -## Where code lives - -- `apps/server` - WebSocket, orchestration, providers, checkpointing. Effect-heavy: read `.repos/effect-smol/LLMS.md` before writing Effect code. -- `apps/web` - React/Vite UI. `apps/desktop` wraps it, `apps/mobile` is React Native, `apps/marketing` is the site. -- `packages/contracts` - Effect/Schema contracts plus small derived helpers. No heavy runtime logic. -- `packages/shared` - shared runtime utils, subpath exports, no barrel. -- `packages/client-runtime` - client code shared by web and mobile. -- `.repos/` - vendored read-only references. Prefer their patterns over invented ones. Never edit or import from them. Sync with `vpr sync:repos` when bumping the matching dependency. - -## Taste - -- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. -- Inferred types over annotations. `any` is the enemy. -- Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. -- Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. -- If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. - -## Additional tips - -- Don't verify with browsers or computer use unless the user explicitly agrees or requests it. -- Security is important, but should not be over-indexed on, especially for dev mode/maintainer-only features. +# AGENTS.md + +## 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). + +- 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 + **`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. +- **`fork/base`** sits on upstream `main` and holds **only** fork repository plumbing (GitHub-hosted + Fork CI, Blacksmith-free `ci.yml` runners, `docs/fork-base.md`). No Tim, candidates, or product. + Permanent draft PR against `main`. Restacks rebuild base first, then Tim → candidates → changes. +- `fork/tim` contains only selected Tim Smart integrations above **`fork/base`**. `fork/candidates` + contains selected open upstream PRs that we run before upstream accepts them, one provenance + commit per source PR, above Tim. The permanent `fork/changes` PR is based on `fork/candidates`, + contains only our downstream layer, remains open, and is the GitHub/T3 default branch. +- Long-lived upstreamable features may be registered as `integrationOverlays`. They remain parallel + draft PRs based on `fork/changes`; `fork/integration` composes them in manifest order. Never merge + a registered overlay directly. Update its branch, or use + `pnpm fork:stack overlay-start ` and target the child PR at the overlay branch. + Draft state blocks merging while normal green CI remains meaningful. Permanent overlay PRs must + carry the **`OVERLAY`** label. +- **Closed permanent draft PRs (overlays / managed stack PRs):** do **not** open a replacement PR + as the first reaction. (1) Fix the branch tip if needed (rebase onto the intended base, force- + with-lease). (2) **`gh pr reopen `** the **same** PR number and restore draft + **`OVERLAY`** + (for overlays). (3) Only if GitHub refuses reopen, create a new draft PR, apply **`OVERLAY`**, and + update `.github/pr-stack.json` `integrationOverlays[].number` in the same change. Never leave the + manifest pointing at a closed PR. +- **Fixing `fork/changes` or an overlay tip:** prefer **amending / rewriting the commit that + introduced the bug** (or folding into the existing product/reapply commit), then force-with-lease + the layer tip. Do **not** stack permanent tip-only `fix(…)` / `style(…)` recovery commits when the + tip is still operator-owned stack surface and history rewrite is allowed. New tip commits are OK + for ordinary **feature** work that lands via PR merge into the layer. +- Before targeting `fork/changes`, inspect `.github/client-overlay-ownership.json` or run + `pnpm fork:overlay-owner [changed-path...]`. Changes owned by an extracted client + must update that draft overlay (or a child PR targeting it), not duplicate its implementation in + `fork/changes`. Read [docs/client-overlays.md](./docs/client-overlays.md) for mixed shared/client + changes and extraction cutovers. +- Start new work with `pnpm fork:stack start ` and open the PR against `fork/changes`. + Ordinary feature/import PRs are not added to `.github/pr-stack.json`; they enter the runnable fork + only after being reviewed and merged into `fork/changes`. +- **Never open implementation PRs against `main`.** `main` is the upstream mirror; GitHub will + report conflicts and a huge unrelated diff. Always base and retarget feature PRs on `fork/changes`. +- Before handoff (and whenever a PR is CONFLICTING / behind), run + `pnpm fork:stack update --push` (or `pnpm fork:stack update --push `). That rebases or + replays the feature commits onto the PR's intended parent (`fork/changes` for ordinary features, + or the current parent branch for dependent/overlay-child PRs), retargets only an invalid base, and + force-with-lease pushes so the PR stays mergeable. +- After automation rebases your branch (or `fork/changes`), refresh a local checkout with + `pnpm fork:stack pull`. It hard-resets to remote when local commits are patch-equivalent, and only + rebases when you have unique unpushed work. +- Independent features use parallel PRs based on `fork/changes`. 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 + reviewed commit per source PR on `fork/candidates`; our adaptations land separately on + `fork/changes`. Cherry-pick only wanted commits, explicitly document imported, adapted, and + excluded pieces, and never merge a source branch wholesale. +- 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 + upstream projection without removing the canonical downstream implementation. + +### Automatic integration and deployment + +- Opening or updating a PR runs CI but does not deploy. +- **Layer status = Fork CI only.** Permanent `fork/*` / overlay draft PRs must look green or red + solely from **Check**, **Test**, **Mobile Native Static Analysis**, and **Release Smoke** + (`.github/workflows/fork-ci.yml`). **Compose fork integration** is an integration rebuild, not a + product-layer quality gate — do **not** add it (or any compose/rebase job) to required status + checks for those branches. +- **Fast path (day-to-day):** `.github/workflows/compose-integration.yml` (**Compose fork + integration**) runs **only** when: + - a PR is **merged** into **`fork/changes`**, or + - a PR is **merged** into a **registered overlay base** (desktop / discord / vscode / identity), + or + - it is started with **`workflow_dispatch`** (manual compose after a local tip rewrite). + It does **not** run on branch **pushes** (force-push rebases, deploy-key updates, compose's own + overlay force-with-lease). That avoids check noise on permanent draft PRs and rebase storms. + On merge it auto-rebases every registered overlay onto current `fork/changes` (no-op when + already based; force-with-lease on clean rebases), composes those tips into `fork/integration`, + and dispatches **Fork CI** on the composed tip. It does **not** rewrite main/tim/candidates or + ordinary feature PRs. Real overlay rebase conflicts fail the job with branch + paths — fix that + overlay, then re-run compose (`workflow_dispatch` or merge again). When adding an overlay to + `.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 + 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 + protected-branch force-with-lease; agents must never print or reuse it. +- Fork checks live in `.github/workflows/fork-ci.yml` and run for PRs targeting product bases (or by + explicit `workflow_dispatch` on a branch tip). Permanent stack tips: + - `fork/candidates` / `fork/changes` / overlays: PR base list in `fork-ci.yml` + - `fork/tim` (PR #1 bases on `main`): green via **`workflow_dispatch --ref fork/tim`** so we do + not attach Fork CI to every ordinary PR into `main` + - After a restack that rewrites tips, re-dispatch Fork CI on **tim** and **candidates** when you + need those permanent draft PRs green again +- The inherited upstream **`.github/workflows/ci.yml` (Blacksmith runners)** and `deploy-relay.yml` + workflows are **`disabled_manually` at repository level**. This fork has no Blacksmith runners — + if CI is left enabled, Tim/candidates jobs queue forever on `blacksmith-*-ubuntu-2404` / + `blacksmith-*-macos-*` labels. **Do not re-enable** upstream CI or relay deploy for fork releases. + After any accident re-enable: `gh workflow disable CI --repo `. +- Successful `fork/integration` CI classifies the complete tree diff from the previous approved + integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations + repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. +- Machine topology and deployment implementation belong in a separate private operations repository, + not this repository. +- **Lockfile after stack rebase / conflict resolution (required):** never leave + `pnpm-lock.yaml` mismatched with any `package.json` after a manual or automated layer rewrite. + Taking `--ours` on the lockfile during conflicts is **not** finished work when `package.json` + (or workspace package manifests) still declare different deps. Before treating the stack or a + recovery PR as done: + 1. On the rewritten tip (usually `fork/changes`), run `CI= pnpm install --no-frozen-lockfile` + (or `vp install` with frozen lockfile disabled) until the lockfile matches. + 2. Commit the updated `pnpm-lock.yaml` on a PR targeting `fork/changes` (or include it in the + recovery commit that lands the rewrite). + 3. Recompose `fork/integration` if the tip already moved, then re-dispatch Fork CI. + 4. Confirm install would succeed under CI: frozen lockfile is **on** in Fork CI; failures look + like `ERR_PNPM_OUTDATED_LOCKFILE` / "specifiers in the lockfile don't match package.json". + Prefer regenerating the lockfile over repeatedly choosing ours/theirs on `pnpm-lock.yaml` during + multi-commit rebases of `fork/changes`. +- **Per-layer full CI gate after stack rebase (required — stop the line):** when rebasing, + replaying, or rewriting the stack, **every layer must pass the full local CI gate before you + touch the next layer**. Do **not** rebase, compose, or push a child layer onto a parent that is + still red. Do **not** “finish the stack rewrite first and green it later.” + - Order: `fork/base` → `fork/tim` → `fork/candidates` → `fork/changes` → each integration overlay + → compose `fork/integration` last. + - On **each** layer tip after it is rewritten: install/lock consistent, then run the **full** + local Fork CI gate (not only `vp check`) — see **Per-layer stack CI (stop the line)** under + Task Completion Requirements and [docs/fork-stack.md](./docs/fork-stack.md) + (“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. + - Same stop-the-line rule for feature / overlay-child PRs after `pnpm fork:stack update`: rebase + onto the fixed parent, run the full pre-push gate on the feature tip, then push/merge. +- **Conflict resolutions (required when stack hits conflicts):** do **not** only hand-resolve and + resume. Update `.github/pr-stack.json` `conflictResolutions` so the next sync auto-applies the + same side. Prefer durable `commit: "*"` + path policies; exact SHAs go stale after every rewrite. + During rebase, `theirs` = commit being replayed, `ours` = new base. Documented in + [docs/fork-stack.md](./docs/fork-stack.md) ("Conflict resolutions"). +- **Product conflicts (shared UI / app code):** never blind whole-file `ours`/`theirs` on shared + product paths. 3-way merge or re-apply the feature commit; run a pre/post parity check so helpers + and tests cannot survive while JSX/wiring is dropped (see #154 remote Open in VS Code button). + Full rules: [docs/fork-stack.md](./docs/fork-stack.md) ("Product conflicts"). +- **No tip-only product `fix(stack)` recovery:** whole-file stack resolves that drop VCS/UI must be + 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. + 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 + 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 + `apps/web/src/forkSurfaceExistence.test.ts` for chrome. +- **Integration compose lockfiles:** overlay lock commits diverge by design. Compose skips + lockfile-only commits, defers lock-only conflicts, and regenerates one integration + `pnpm-lock.yaml` at the end. Never push a partial `fork/integration` after a lock conflict. + Compose seeds `node_modules` via `cp -a --reflink=auto` from a warm tree into a **home-side** + work dir (`~/.t3/compose-work`, not tmpfs `/tmp`) before install. See + [docs/fork-stack.md](./docs/fork-stack.md) ("Integration overlay compose and lockfiles"). + +## Pull requests (required handoff) + +When implementation work for a user request is done (code, docs, config — not pure Q&A): + +1. **Commit** the changes on a feature branch created with `pnpm fork:stack start ` (from + `fork/changes`), or `pnpm fork:stack overlay-start ` for overlay-owned work. +2. **Open or update a PR** against the correct base before handing off: + - Ordinary features → **`fork/changes`** (never `main`, never `fork/integration`). + - Client overlay work → the **registered overlay branch** (`fork/discord`, `fork/vscode`, `fork/identity`, or + `t3-discord/f7d37879-desktop-deeplinks`), not a duplicate of that work in `fork/changes`. +3. **Keep the PR mergeable** before saying “updated the PR” or finishing: + - **Mandatory pre-push gate** (see Task Completion Requirements): run **locally every job Fork + CI will run on this tip** — at minimum **`vp check`** and the **full monorepo typecheck** — + fix every failure (including pre-existing breakage your tip inherits from the base), then + push. Do not use Fork CI as the first formatter, linter, or typechecker. Scoped package + typecheck alone is **not** enough. + - **Same bar for overlay-child PRs.** Base = overlay does **not** relax the gate. Compose + success, draft-lock green, or “the permanent overlay PR was green last week” is **not** a + substitute for running Check on **this** tip. + - `pnpm fork:stack update --push` (current branch) or `pnpm fork:stack update --push ` + - Confirm with `gh pr view --json baseRefName,mergeable,mergeStateStatus,url` + - `baseRefName` must be `fork/changes` 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** → re-run the mandatory pre-push gate, update that branch (prefer + `fork:stack update --push`), and push. + - 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 + base (`fork/changes` or the overlay). +5. Never assume an earlier PR in the session is still open. +6. **Never merge or request merge** (and never tell a bot to merge) while local `vp check` / + full typecheck are red or were skipped. GitHub required checks on `fork/changes` and every + registered overlay base are a backstop — **local green first** is still mandatory. + +## Discord-originated commits (REQUIRED) + +When the Discord turn includes an **Identity map** block with ready-to-paste `Co-authored-by` trailers, attribution is **mandatory**, not optional: + +1. Keep the environment default **author/committer** (usually the GitHub App bot). +2. **Every** `git commit` you create for that work MUST end with those exact trailers after a blank line. Do not invent emails for unmapped people. +3. Before `git push` / opening a PR, verify with `git log -1 --format=%B` that the trailers are present on each new commit. +4. A Discord-originated commit **without** the mapped trailers is incomplete — fix it (amend if not pushed, or a follow-up commit is not enough for GitHub multi-author on already-pushed SHAs; amend/rebase when safe). + +GitHub multi-author avatars (`bot & human`) come from commit trailers, not from PR body prose alone. + +## Discord-originated pull requests (REQUIRED) + +When Discord work produces commits (or is clearly intended to land): + +0. **Always open a PR — do not wait for perfect green.** Create the PR as soon as there is something to review or track. If full lint / typecheck / focused tests / `vp check` are not finished yet, open it as a **draft**. Convert to ready for review / merge **only** after those gates pass locally. A missing PR while work sits only on a remote branch is incomplete handoff. **Draft is for tracking, not for merging:** do not squash-merge, rebase-merge, or instruct a bot to merge a draft or any tip that has not passed the local gate. + +When opening or updating a PR from a Discord thread: + +1. **Discord footer (required in the PR description).** Append this exact footer form at the end of the PR body (use the **thread starter** when known, otherwise the current requester, and that thread’s real jump link): + +```md +opened by [](discord_user_id) in chat thread **Discord** · [Thread Title](https://discord.com/channels///) +``` + +Prefer the thread starter’s Discord id/display name from turn context. Do not skip this because the bot _might_ patch the body later — still write it when you create the PR so the first revision is correct. The bot may also hard-append the footer when a PR URL is linked; that is a safety net, not a reason to omit it. + +2. If Discord turn context lists **Linked work items** / Jira issues for the thread, include those Jira issue links in the PR description (and prefer the primary key in the title/branch when one is clear). + +3. Prefer opening the PR only after commits already include the Identity map `Co-authored-by` trailers (see above). + +## Task Completion Requirements + +### Mandatory pre-push / PR handoff gate (no exceptions) + +**Whatever Fork CI runs for this tip, the agent must run locally first.** Fork CI is a safety net, +not the first formatter, linter, or typechecker. This applies to **every** implementation base: + +| PR base | Local gate required before ready / merge? | GitHub required checks | +| -------------------------------------------------------------------------------------- | ----------------------------------------- | --------------------------------------------------------- | +| `fork/changes` | **Yes** — full gate below | Check, Test, Mobile Native Static Analysis, Release Smoke | +| Registered overlay (`fork/discord`, `fork/vscode`, `fork/identity`, desktop deeplinks) | **Yes — identical** | Same as `fork/changes` | +| Dependent feature based on another feature branch | **Yes** on the child tip after rebase | Same when retargeted to a protected base | + +**Before every `git push` that is intended as ready work, `fork:stack update --push`, non-draft PR +open, ready-for-review conversion, merge / merge-request, or “handoff / done” claim**, the agent +**must** run the local gates that mirror Fork CI’s **Check** job (and Test pieces you changed), +fix all failures, then push. + +**Always open a PR for Discord/agent work that produces commits** (see _Discord-originated pull +requests_). **Draft PR exception:** you may open/update a **draft** PR earlier for tracking once +commits exist, co-author trailers are correct, and focused tests for the changed behavior have been +run — even if full monorepo typecheck / root `vp check` are still in progress. Do not claim the work +is ready, mark the PR non-draft, or merge until the full gate below passes. + +#### Why integration keeps failing on “obvious” lint + +These are process failures, not surprises: + +1. Child PR base was an overlay and agents treated Compose / permanent-overlay PR green as enough. +2. Local `vp check` was skipped (“CI will catch it” / only scoped package checks). +3. Compose rebuilds `fork/integration` **without** re-running lint — the first hard fail is + integration Fork CI. + +If a PR’s checks panel shows only Compose / draft-lock and **no** Check job, that is **not** a +clean PR — fix tooling or still run the local gate; do not merge. + +Run from the repository root, in order (mirror of `.github/workflows/fork-ci.yml`): + +1. **`vp check`** — exact formatter/linter gate used by Fork CI **Check** (includes + `t3code/namespace-node-imports` and friends). A focused format/lint while iterating is fine; it + is **not** a substitute for this root command before ready handoff. +2. **Full monorepo typecheck** (matches Fork CI): + + ```bash + ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run -r --cache --log labeled typecheck + ``` + + Equivalent: `vp run typecheck` / root `pnpm` typecheck script that runs recursive package + typechecks. **Scoped** typecheck of only the package you edited is allowed **while iterating**, + but **before ready handoff you must run the full recursive typecheck**. Failures in packages you + did not touch still block: your tip inherits the base; fix or land a fix on the tip so CI is green. + +3. **Desktop Check pieces when the tip can break them** (Fork CI **Check** also runs these): after + desktop or preload-adjacent changes, run `vp run --cache build:desktop` and the preload verify + steps from `.github/workflows/fork-ci.yml`. When in doubt on a stack layer rewrite, run them. +4. **Focused tests for behavior you changed** (not always the full workspace suite — see stack + rule below): + - `vp test run ` for built-in Vite+ tests, or the package’s `test` script when that + is what the package uses. + - Backend / contracts / runtime behavior changes **must** include and run focused tests for the + changed behavior. + - Fork product / UI changes **must** include an existence or behavior assertion that fails if + the surface is dropped (not only pure helpers). See `apps/web/src/forkSurfaceExistence.test.ts` + and [docs/fork-stack.md](./docs/fork-stack.md) (“Product conflicts”). +5. **Do not push a ready (non-draft) handoff** if steps 1–2 fail, or if required steps 3–4 fail. + Fix first. + +**Ordinary feature PRs (based on `fork/changes` or an overlay):** full-workspace `vp run test` is +optional unless the user asks or the change clearly needs the whole suite. **Do not** skip steps +1–2 to save time on ready handoff. **Stack layer rewrites** still require full Test (below). + +**Explicitly forbidden before ready handoff / merge:** + +- Ready/non-draft push after only unit tests, only scoped package typecheck, or only a partial lint. +- Marking a PR ready for review knowing typecheck or `vp check` was skipped or red. +- Treating “CI will catch it”, “compose will catch it”, or “integration CI will catch it” as a + substitute for local gates. +- Merging an overlay-child PR because Compose or Managed PR draft lock is green while Check never + ran or is red. +- Advancing a stack rewrite to the next layer while the current layer is red (see below). +- Leaving Discord/agent work with commits but **no** PR (use draft until gates finish). + +While iterating mid-task (not yet ready), keep feedback loops small: format/lint the files you +touch, typecheck the packages you edit, run the smallest relevant tests. **The bar rises to the +full pre-push gate the moment you mark ready, merge, or claim done.** + +### Per-layer stack CI (stop the line — no exceptions) + +When rebasing, replaying, conflict-resolving, or otherwise rewriting **any** fork stack layer +(`fork/tim`, `fork/candidates`, `fork/changes`, an integration overlay, or composed +`fork/integration`): + +1. Finish **only the current layer** (rebase/replay complete, lockfile consistent, conflicts + resolved and recorded in `conflictResolutions` when applicable). +2. On that layer’s tip, run the **full local CI gate** — every step you can run on the host that + Fork CI runs for a green PR tip: + - `vp check` + - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run -r --cache --log labeled typecheck` + - `vp run --cache build:desktop` and preload verify (same as Fork CI **Check**) + - `ELECTRON_SKIP_BINARY_DOWNLOAD=1 vp run test` (Fork CI **Test** — **required on every stack + layer**, not optional) + - On macOS hosts when mobile/desktop shell is in play: `vp run lint:mobile` and the Open With + test from Fork CI **Mobile Native Static Analysis** when those paths are available + - `node scripts/release-smoke.ts` when release/workflow packaging paths may have changed +3. **All of those steps must pass on the current layer.** Fix failures on **this** layer (commit + + force-with-lease push the layer branch if it is shared). Do not paper over with a fix only on a + child layer. +4. **Only after the current layer is fully green**, rebase/replay/compose the **next** layer onto + it. Repeat from step 1. + +**Layer order (never skip ahead):** + +```text +main (upstream mirror — do not hand-edit product fixes) + → fork/base (fork-only CI / repo plumbing) + → fork/tim + → fork/candidates + → fork/changes + → each integration overlay (in manifest order) onto fork/changes + → fork/integration (compose last; full CI on the composed tip) +``` + +**Hard rules:** + +- **One red layer blocks the entire rest of the rewrite.** Stop. Fix. Re-run the full gate on that + layer. Then continue. +- **Never** stack “green later” commits, push a known-red parent, or compose `fork/integration` + from layers that have not each passed the full gate. +- **Never** treat “the next layer will fix typecheck/lint/tests” as acceptable progress. +- Feature PRs and overlay children: after rebasing onto a parent, the **child tip** must also pass + the ordinary pre-push gate (and stack-layer full test gate if you are rewriting stack automation + itself) before push. + +Full narrative and examples: [docs/fork-stack.md](./docs/fork-stack.md) +(“Per-layer full CI after stack rebase”). + +### Client-visible verification + +After frontend feature development or any user-visible frontend behavior change, the primary agent +must run one integrated verification pass for each affected client surface after integrating the +work: + +- Web: use the `test-t3-app` skill. Launch one isolated environment, authenticate through the printed + pairing URL, and verify the affected flow in the controlled browser. +- Mobile: use the `test-t3-mobile` skill. Connect one representative iOS Simulator or Android + Emulator available on the host to one isolated environment and verify the affected flow. On + compatible macOS hosts, prefer iOS for cross-platform changes and stream it through serve-sim in + the T3 Code in-app browser or another available agent browser; use Android when it is the affected + or viable platform. +- Subagents must not independently launch dev servers or repeat integrated client verification + unless their delegated task explicitly requires it. +- Stop dev servers, watchers, and other long-running verification processes when the focused + verification is complete. + +## Dev Servers + +- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins. +- Start the web stack with `vp run dev`. Add `--share` when someone needs to open it from another device on the tailnet. +- Browser dev is single-origin: Vite proxies `/api`, `/ws`, `/oauth`, and `/.well-known` to the backend. Do not set `VITE_HTTP_URL` or `VITE_WS_URL` for `dev`/`dev:web`. +- Worktree paths supply stable preferred port offsets. Read the actual server and web ports from the `[dev-runner]` line because occupied ports can still shift them. +- Before handing off a `--share` URL, open its origin in a controlled browser and confirm the app loads. A successful curl is insufficient because browsers reject some otherwise reachable ports. + +## Package Roles + +- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions. +- `apps/web`: React/Vite UI. Owns session UX, conversation/event rendering, and client-side state. Connects to the server via WebSocket. +- `packages/contracts`: Shared effect/Schema schemas and TypeScript contracts for provider events, WebSocket protocol, and model/session types. Keep this package schema-only — no runtime logic. +- `packages/shared`: Shared runtime utilities consumed by both server and client applications. Uses explicit subpath exports (e.g. `@t3tools/shared/git`) — no barrel index. +- `packages/client-runtime`: Shared runtime package for sharing client code across web and mobile. + +## Reference Repos + +- Open-source Codex repo: https://github.com/openai/codex + +Use these as implementation references when designing protocol handling, UX flows, and operational safeguards. + +## Vendored Repositories + +This project vendors external repositories under `.repos/` as read-only reference material for coding +agents. + +- Prefer examples and patterns from the vendored source code over generated guesses or web search results. +- Do not edit files under `.repos/` unless explicitly asked. +- Do not import from `.repos/`; application code must continue importing from normal package dependencies. +- Manage vendored subtrees with `vpr sync:repos`; use `vpr sync:repos --repo ` to sync one configured repository. +- When updating a dependency with a configured vendored subtree, sync that subtree in the same change so + `.repos/` matches the installed dependency version. +- When writing Effect code, read `.repos/effect-smol/LLMS.md` first and inspect `.repos/effect-smol/` for + examples of idiomatic usage, tests, module structure, and API design. +- When writing relay infrastructure code with Alchemy, inspect `.repos/alchemy-effect/` for examples of + idiomatic usage, tests, module structure, and API design. diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 63712ca7e29..e579c7d2da7 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -123,7 +123,10 @@ describe("compressImageForStash", () => { }); it("reports too-large when even the smallest encoding overflows the budget", async () => { - const { close } = stubCanvasPipeline(() => 8_000_000); + // Keep the encoded payload just over the base64 char budget without minting + // multi-megabyte blobs — `blobToDataUrl` is O(n) and used to time out CI. + const overBudgetBytes = Math.ceil((MAX_STASH_IMAGE_DATA_URL_CHARS * 3) / 4) + 8_000; + const { close } = stubCanvasPipeline(() => overBudgetBytes); const result = await compressImageForStash(makeFile(9_000_000)); diff --git a/docs/client-overlays.md b/docs/client-overlays.md new file mode 100644 index 00000000000..5a742fcae84 --- /dev/null +++ b/docs/client-overlays.md @@ -0,0 +1,71 @@ +# Client integration overlays + +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 new file mode 100644 index 00000000000..c0647104b5c --- /dev/null +++ b/docs/fork-base.md @@ -0,0 +1,35 @@ +# fork/base + +`fork/base` sits between the upstream mirror (`main`) and provenance layers: + +```text +main → fork/base → fork/tim → fork/candidates → fork/changes → overlays → integration +``` + +## What belongs here + +**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 | + +## What does **not** belong here + +- Tim Smart import commits (`feat(tim): …`) +- Upstream candidate provenance (`feat: import …`) +- Product features, mobile/web UI, contracts RPC behavior + +Those stay on `fork/tim` / `fork/candidates` / `fork/changes` respectively. + +## Restack + +1. Update `main` from upstream. +2. Replay / rebuild **this** layer onto the new `main` tip (keep the fork infra tree). +3. Rebuild `fork/tim` → `fork/candidates` → `fork/changes` → overlays → compose. + +Keep repository workflow **CI** (upstream Blacksmith) **disabled_manually**. Layer green is **Fork CI**. diff --git a/docs/fork-stack.md b/docs/fork-stack.md new file mode 100644 index 00000000000..1523d8614ed --- /dev/null +++ b/docs/fork-stack.md @@ -0,0 +1,672 @@ +# Downstream fork workflow + +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: + +```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 +``` + +Commit and push normally, then open the PR against `fork/changes` (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: + +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 + +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. + +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 +``` + +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. + +## Splitting the consolidated fork + +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. + +## Provenance rebuild archive + +The pre-provenance woven graph is preserved locally and remotely at: + +- `archive/fork-changes-woven-2026-07-24` +- `archive/fork-integration-woven-2026-07-24` +- matching annotated tags prefixed with `archive-` + +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. diff --git a/docs/stack-history-rewrite.md b/docs/stack-history-rewrite.md new file mode 100644 index 00000000000..26d7894cbe6 --- /dev/null +++ b/docs/stack-history-rewrite.md @@ -0,0 +1,114 @@ +# Stack history rewrite (fold tip-only `fix(stack)` debt) + +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. diff --git a/docs/stack-ship-path.md b/docs/stack-ship-path.md new file mode 100644 index 00000000000..8043978e808 --- /dev/null +++ b/docs/stack-ship-path.md @@ -0,0 +1,355 @@ +# Stack ship path (planned operating model) + +**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 +ordinary shared work → branch from fork/changes → PR base fork/changes +overlay-owned work → branch from that overlay → PR base = overlay branch +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)`. + +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. 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 839b8e79584..20f75d74644 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,11 @@ "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" + "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" }, "devDependencies": { "@babel/plugin-transform-react-jsx": "7.28.6", diff --git a/scripts/classify-deployment-diff.sh b/scripts/classify-deployment-diff.sh new file mode 100755 index 00000000000..66c6bea9c6a --- /dev/null +++ b/scripts/classify-deployment-diff.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_sha="${1:-}" +head_sha="${2:-}" + +if [[ ! "${base_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +is_non_runtime_path() { + case "$1" in + .agents/* | .github/* | docs/* | \ + AGENTS.md | CLAUDE.md | README.md | */README.md | \ + *.md | *.mdx | *.snap | \ + *.test.* | *.spec.* | \ + test/* | tests/* | */test/* | */tests/* | \ + */__snapshots__/* | */__tests__/* | */testUtils/* | */fixtures/* | \ + apps/server/scripts/acp-mock-agent.ts | scripts/release-smoke.ts) + return 0 + ;; + *) + return 1 + ;; + esac +} + +runtime_paths=() +non_runtime_paths=() +while IFS= read -r -d '' path; do + if is_non_runtime_path "${path}"; then + non_runtime_paths+=("${path}") + else + runtime_paths+=("${path}") + fi +done < <(git diff --name-only -z "${base_sha}" "${head_sha}") + +printf 'Changed paths: %d runtime, %d non-runtime\n' \ + "${#runtime_paths[@]}" "${#non_runtime_paths[@]}" + +server=false +discord=false +vscode=false +mobile=false +desktop=false + +select_all() { + server=true + discord=true + vscode=true + mobile=true + desktop=true +} + +for path in "${runtime_paths[@]}"; do + case "${path}" in + apps/discord-bot/*) + discord=true + ;; + apps/vscode/*) + vscode=true + ;; + apps/mobile/*) + mobile=true + ;; + apps/desktop/*) + desktop=true + ;; + apps/server/*) + server=true + ;; + apps/web/*) + # The web application is served by both standalone servers and packaged + # desktop clients. + server=true + desktop=true + ;; + packages/contracts/* | packages/shared/* | packages/client-runtime/*) + # These packages cross every client/server boundary in the private fleet. + select_all + ;; + *) + # Root manifests, lockfiles, build tooling, and newly introduced runtime + # paths are deliberately conservative until assigned a narrower owner. + select_all + ;; + esac +done + +deploy=false +if [[ "${server}" == "true" || "${discord}" == "true" || "${vscode}" == "true" || + "${mobile}" == "true" || "${desktop}" == "true" ]]; then + deploy=true +fi + +if [[ "${deploy}" == "true" ]]; then + printf 'Runtime-affecting paths:\n' + printf ' %s\n' "${runtime_paths[@]}" +else + printf 'Only tests, documentation, agent metadata, or CI metadata changed.\n' +fi + +printf 'deploy=%s\n' "${deploy}" +printf 'server=%s\n' "${server}" +printf 'discord=%s\n' "${discord}" +printf 'vscode=%s\n' "${vscode}" +printf 'mobile=%s\n' "${mobile}" +printf 'desktop=%s\n' "${desktop}" diff --git a/scripts/classify-deployment-diff.test.sh b/scripts/classify-deployment-diff.test.sh new file mode 100755 index 00000000000..d0c2a00621d --- /dev/null +++ b/scripts/classify-deployment-diff.test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "${work}"' EXIT + +git -C "${work}" init --quiet +git -C "${work}" config user.email test@example.com +git -C "${work}" config user.name Test +mkdir -p "${work}/seed" +touch "${work}/seed/.keep" +git -C "${work}" add seed/.keep +git -C "${work}" commit --quiet -m seed +base="$(git -C "${work}" rev-parse HEAD)" + +assert_scope() { + local path="$1" + local expected="$2" + local output + + mkdir -p "${work}/$(dirname "${path}")" + printf 'changed\n' >"${work}/${path}" + git -C "${work}" add "${path}" + git -C "${work}" commit --quiet -m "change ${path}" + output="$( + cd "${work}" + bash "${root}/scripts/classify-deployment-diff.sh" "${base}" "$(git rev-parse HEAD)" + )" + while IFS='=' read -r key value; do + [[ "$(sed -n "s/^${key}=//p" <<<"${output}")" == "${value}" ]] || { + printf 'expected %s=%s for %s\n%s\n' "${key}" "${value}" "${path}" "${output}" >&2 + exit 1 + } + done <<<"${expected}" + git -C "${work}" reset --quiet --hard "${base}" +} + +assert_scope apps/discord-bot/src/main.ts $'deploy=true\ndiscord=true\nserver=false\nvscode=false\nmobile=false\ndesktop=false' +assert_scope apps/vscode/src/extension.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=true\nmobile=false\ndesktop=false' +assert_scope apps/mobile/src/App.tsx $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=true\ndesktop=false' +assert_scope apps/desktop/src/main.ts $'deploy=true\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=true' +assert_scope apps/server/src/server.ts $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=false' +assert_scope apps/web/src/App.tsx $'deploy=true\ndiscord=false\nserver=true\nvscode=false\nmobile=false\ndesktop=true' +assert_scope packages/client-runtime/src/index.ts $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' +assert_scope pnpm-lock.yaml $'deploy=true\ndiscord=true\nserver=true\nvscode=true\nmobile=true\ndesktop=true' +assert_scope docs/deployment.md $'deploy=false\ndiscord=false\nserver=false\nvscode=false\nmobile=false\ndesktop=false' + +echo "deployment classifier tests passed" diff --git a/scripts/client-overlay-owner.test.ts b/scripts/client-overlay-owner.test.ts new file mode 100644 index 00000000000..8b59ff85678 --- /dev/null +++ b/scripts/client-overlay-owner.test.ts @@ -0,0 +1,49 @@ +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 new file mode 100644 index 00000000000..912f8c2a841 --- /dev/null +++ b/scripts/client-overlay-owner.ts @@ -0,0 +1,76 @@ +#!/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 new file mode 100644 index 00000000000..f0d74273151 --- /dev/null +++ b/scripts/compose-integration-overlays.test.ts @@ -0,0 +1,13 @@ +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 new file mode 100644 index 00000000000..abe5fe68858 --- /dev/null +++ b/scripts/compose-integration-overlays.ts @@ -0,0 +1,383 @@ +#!/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 new file mode 100644 index 00000000000..0515c4f9894 --- /dev/null +++ b/scripts/fork-stack.test.ts @@ -0,0 +1,309 @@ +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 new file mode 100755 index 00000000000..7a8eeb76f17 --- /dev/null +++ b/scripts/fork-stack.ts @@ -0,0 +1,1046 @@ +#!/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/lib/disk-backed-tmp.test.ts b/scripts/lib/disk-backed-tmp.test.ts new file mode 100644 index 00000000000..519b2312c9b --- /dev/null +++ b/scripts/lib/disk-backed-tmp.test.ts @@ -0,0 +1,41 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { describe, expect, it } from "vite-plus/test"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { diskBackedWorkRoot, mkdtempDiskBacked } from "./disk-backed-tmp.ts"; + +describe("diskBackedWorkRoot", () => { + it("prefers explicit env var", () => { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "disk-backed-env-")); + const prev = process.env.T3_REBASE_WORK_ROOT; + process.env.T3_REBASE_WORK_ROOT = dir; + try { + expect(diskBackedWorkRoot({ subdir: "rebase-work", envVar: "T3_REBASE_WORK_ROOT" })).toBe( + dir, + ); + } finally { + if (prev === undefined) delete process.env.T3_REBASE_WORK_ROOT; + else process.env.T3_REBASE_WORK_ROOT = prev; + NodeFS.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("mkdtempDiskBacked creates a unique directory under the work root", () => { + const root = diskBackedWorkRoot({ subdir: "rebase-work", envVar: "T3_REBASE_WORK_ROOT" }); + const a = mkdtempDiskBacked("rebase-pr-stack-", { + subdir: "rebase-work", + envVar: "T3_REBASE_WORK_ROOT", + }); + const b = mkdtempDiskBacked("rebase-pr-stack-", { + subdir: "rebase-work", + envVar: "T3_REBASE_WORK_ROOT", + }); + expect(a).not.toBe(b); + expect(a.startsWith(root)).toBe(true); + expect(NodeFS.statSync(a).isDirectory()).toBe(true); + NodeFS.rmSync(a, { recursive: true, force: true }); + NodeFS.rmSync(b, { recursive: true, force: true }); + }); +}); diff --git a/scripts/lib/disk-backed-tmp.ts b/scripts/lib/disk-backed-tmp.ts new file mode 100644 index 00000000000..2f9ec939e3d --- /dev/null +++ b/scripts/lib/disk-backed-tmp.ts @@ -0,0 +1,104 @@ +// @effect-diagnostics nodeBuiltinImport:off +/** + * Disk-backed work roots for multi-GB tooling (stack rebase, compose, clones). + * + * `/tmp` is often tmpfs (host Arch) or the guest's RAM rootfs (t3vm). Putting full + * git workdirs there OOMs or fills root. Prefer HOME/.t3/ (or T3CODE_HOME + * when that is the data volume), with TMPDIR only if it is not a tmpfs. + */ +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +function isTmpfsPath(dir: string): boolean { + try { + // Linux: /proc/mounts. Best-effort — if unreadable, assume not tmpfs. + const mounts = NodeFS.readFileSync("/proc/mounts", "utf8"); + let bestLen = -1; + let bestFs = ""; + const resolved = NodeFS.realpathSync(dir); + for (const line of mounts.split("\n")) { + const parts = line.split(/\s+/); + if (parts.length < 3) continue; + const mountPoint = parts[1]!; + const fsType = parts[2]!; + if ( + (resolved === mountPoint || + resolved.startsWith(mountPoint.endsWith("/") ? mountPoint : `${mountPoint}/`)) && + mountPoint.length > bestLen + ) { + bestLen = mountPoint.length; + bestFs = fsType; + } + } + return bestFs === "tmpfs" || bestFs === "ramfs"; + } catch { + return false; + } +} + +function tryMkdir(dir: string): boolean { + try { + NodeFS.mkdirSync(dir, { recursive: true }); + NodeFS.accessSync(dir, NodeFS.constants.W_OK); + return true; + } catch { + return false; + } +} + +/** + * Resolve a durable work root for heavy temporary trees. + * + * Order: + * 1. `envVar` if set (e.g. T3_REBASE_WORK_ROOT) + * 2. `$T3CODE_HOME/` when T3CODE_HOME is set (t3vm: /var/lib/t3) + * 3. `$HOME/.t3/` + * 4. `$TMPDIR` only if not tmpfs + * 5. `os.tmpdir()` last resort + */ +export function diskBackedWorkRoot(options: { + readonly subdir: string; + readonly envVar?: string; +}): string { + const fromEnv = options.envVar ? process.env[options.envVar]?.trim() : undefined; + if (fromEnv && tryMkdir(fromEnv)) { + return fromEnv; + } + + const t3Home = process.env.T3CODE_HOME?.trim(); + if (t3Home) { + const candidate = NodePath.join(t3Home, options.subdir); + if (tryMkdir(candidate)) return candidate; + } + + const home = process.env.HOME?.trim() || NodeOS.homedir(); + if (home) { + const candidate = NodePath.join(home, ".t3", options.subdir); + if (tryMkdir(candidate)) return candidate; + } + + const tmpDir = process.env.TMPDIR?.trim(); + if (tmpDir && !isTmpfsPath(tmpDir) && tryMkdir(tmpDir)) { + return tmpDir; + } + + const osTmp = NodeOS.tmpdir(); + if (!isTmpfsPath(osTmp) && tryMkdir(osTmp)) { + return osTmp; + } + + // Last resort: still create under preferred home path even if access check was flaky. + const fallback = NodePath.join(home || "/tmp", ".t3", options.subdir); + NodeFS.mkdirSync(fallback, { recursive: true }); + return fallback; +} + +/** `mkdtemp` under {@link diskBackedWorkRoot} (prefix should end with `-`). */ +export function mkdtempDiskBacked( + prefix: string, + options: { readonly subdir: string; readonly envVar?: string }, +): string { + const root = diskBackedWorkRoot(options); + return NodeFS.mkdtempSync(NodePath.join(root, prefix)); +} diff --git a/scripts/rebase-integration-overlays.test.ts b/scripts/rebase-integration-overlays.test.ts new file mode 100644 index 00000000000..3d6336a6e4e --- /dev/null +++ b/scripts/rebase-integration-overlays.test.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 00000000000..d9bb4fd690b --- /dev/null +++ b/scripts/rebase-integration-overlays.ts @@ -0,0 +1,372 @@ +#!/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")) + ); +} + +/** + * 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]); + const rebaseResult = run( + "git", + ["-c", "commit.gpgsign=false", "rebase", "--onto", newBase, plan.oldBase], + repoDir, + { allowFailure: true }, + ); + if (rebaseResult.status !== 0) { + if (rebaseInProgress(repoDir)) { + run("git", ["rebase", "--abort"], repoDir, { allowFailure: true }); + } + const conflictPaths = git(repoDir, ["diff", "--name-only", "--diff-filter=U"], { + 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 new file mode 100644 index 00000000000..a98a0887a6d --- /dev/null +++ b/scripts/rebase-pr-stack.test.ts @@ -0,0 +1,1210 @@ +// @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; +} + +function runGit( + cwd: string, + args: ReadonlyArray, + options: { readonly allowFailure?: boolean } = {}, +): string { + const result = NodeChildProcess.spawnSync("git", [...args], { + cwd, + encoding: "utf8", + env: { + ...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", + }, + }); + 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", + }); + 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 new file mode 100644 index 00000000000..157513bd803 --- /dev/null +++ b/scripts/rebase-pr-stack.ts @@ -0,0 +1,2204 @@ +#!/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; + }); +} diff --git a/vite.config.ts b/vite.config.ts index b2498611198..5c875424b7e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,8 +21,15 @@ export default defineConfig({ testTimeout: 60_000, }, staged: { - // Formatter only for now — no lint or typecheck on commit. - "*": "vp fmt", + // Commit runs format + lint (keep in sync with lint-staged.config.js). + // Heavier typecheck + tests stay in the agent ship gate (pre-push on ready + // PRs / `pnpm pr:ready`). + // `--no-error-on-unmatched-pattern`: a commit whose staged files are all + // unformattable (e.g. only *.nix) leaves `vp fmt` with no targets, which + // otherwise fails the whole pre-commit. Treat "nothing to format" as a no-op. + "*": "vp fmt --no-error-on-unmatched-pattern", + // Lint (with autofix) only the code files oxlint understands. + "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}": "vp lint --fix", }, fmt: { ignorePatterns: [