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