name: Sync next

# Keeps a package's long-lived `next` iteration branch in step with the base
# branch it stacks on. Without it a base advances — a feature branch merged, a
# fix pushed direct — and `next` silently rots, because nothing else moves it:
# work resumed there starts from a stale tree.
#
# The pairing is derived, never configured. An explicit `next/<base>` wins
# where one exists — that is the multi-branch packages (bitcoin-core's
# 28.x..31.x, bitcoin-knots' 29.x and 29.x-prerdts, azerothcore's main and
# playerbots) — and otherwise the repo's default branch pairs with a plain
# `next`. A base with neither gets a branch created for it, which is how a
# package that never had a `next` acquires one.
#
# Neither `next` nor the base is ever force-pushed or rewritten, so this cannot
# race a Helix session working the same branch. The outcomes are: create,
# fast-forward (the overwhelming majority), a merge commit when `next` carries
# unmerged work, or — only when that merge conflicts — a pull request for a
# human.
#
# That PR's head is a throwaway `sync-next/<base>` branch holding the base tip,
# never the base itself: GitHub's web conflict editor commits to the head
# branch, so a PR headed by the base would resolve straight onto the base. It
# still closes on its own once a later run merges cleanly, because merging the
# base into `next` makes the scratch tip reachable from the PR's own base.
#
# No loop is possible: this only ever writes to `next`/`next/*` and
# `sync-next/*`, none of which is a trigger branch for this workflow.

on:
  workflow_call:

concurrency:
  # Serialized per (repo, base): two merges in quick succession would otherwise
  # race onto the same `next`. Never cancelled — a cancelled run just leaves the
  # branch behind again, which is the condition this exists to remove.
  group: sync-next-${{ github.repository }}-${{ github.ref_name }}
  cancel-in-progress: false

jobs:
  sync:
    name: Sync next
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - name: Checkout services repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Sync the paired next branch
        env:
          GH_TOKEN: ${{ github.token }}
          # A reusable workflow inherits the caller's `github` context, so this
          # is the base branch whose push triggered the run.
          BASE: ${{ github.ref_name }}
        run: |
          set -euo pipefail

          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git fetch --no-tags --prune origin '+refs/heads/*:refs/remotes/origin/*'

          exists() { git rev-parse --verify -q "refs/remotes/origin/$1" >/dev/null; }

          # A push always has its own ref, so this only fires if a caller lists a
          # branch the repo does not have — which is the failure mode where CI
          # silently never runs, and it should say so rather than resolve
          # nothing and act on it.
          if ! exists "$BASE"; then
            echo "::error::No branch '$BASE' on this repository — check the branches listed in the caller workflow."
            exit 1
          fi

          DEFAULT_BRANCH=$(gh api "repos/$GITHUB_REPOSITORY" --jq .default_branch)

          # The pairing rule, in full. Order matters: an explicit `next/<base>`
          # outranks the plain `next` even on the default branch, which is what
          # keeps azerothcore (default `main`, paired `next/main`) correct.
          if exists "next/$BASE"; then
            TARGET="next/$BASE"
          elif [ "$BASE" = "$DEFAULT_BRANCH" ]; then
            TARGET="next"
          else
            TARGET="next/$BASE"
          fi
          echo "Base branch:   $BASE"
          echo "Paired branch: $TARGET"

          if ! exists "$TARGET"; then
            echo "::notice::$TARGET does not exist — creating it at $BASE."
            git push origin "$(git rev-parse "origin/$BASE"):refs/heads/$TARGET"
            exit 0
          fi

          BASE_SHA=$(git rev-parse "origin/$BASE")
          TARGET_SHA=$(git rev-parse "origin/$TARGET")

          if [ "$BASE_SHA" = "$TARGET_SHA" ]; then
            echo "::notice::$TARGET is already at $BASE."
            exit 0
          fi

          if git merge-base --is-ancestor "$TARGET_SHA" "$BASE_SHA"; then
            echo "$TARGET is strictly behind $BASE — fast-forwarding."
            git push origin "$BASE_SHA:refs/heads/$TARGET"
            exit 0
          fi

          # `next` is ahead and the base is fully contained in it — the normal
          # state while Helix has work in flight. Nothing to carry across.
          if git merge-base --is-ancestor "$BASE_SHA" "$TARGET_SHA"; then
            echo "::notice::$TARGET already contains $BASE — nothing to sync."
            exit 0
          fi

          # Genuinely diverged. Merge rather than rebase: a rebase means
          # force-pushing a branch a Helix session may be sitting on, and since
          # `next` collapses into a single changeset when it merges back, its
          # internal history does not need to be linear.
          git checkout -B sync-next-work "origin/$TARGET"
          if git merge --no-edit -m "chore: merge $BASE into $TARGET" "origin/$BASE"; then
            git push origin "HEAD:refs/heads/$TARGET"
            echo "::notice::Merged $BASE into $TARGET."
            exit 0
          fi
          git merge --abort || true

          echo "::warning::Merging $BASE into $TARGET conflicts — opening a pull request."
          SCRATCH="sync-next/$BASE"
          OPEN=$(gh pr list --base "$TARGET" --head "$SCRATCH" --state open --json number --jq 'length')
          if [ "$OPEN" != "0" ]; then
            echo "::notice::A sync pull request into $TARGET is already open."
            exit 0
          fi

          # Only ever reached with no sync PR open, so this cannot discard
          # conflict resolutions a human has already pushed onto the scratch
          # branch.
          git push --force origin "$BASE_SHA:refs/heads/$SCRATCH"

          {
            echo "\`$BASE\` has moved on and cannot be merged into \`$TARGET\` unattended — the merge conflicts."
            echo
            echo "Resolve the conflicts and merge this PR. Until it lands, \`$TARGET\` is missing what \`$BASE\` has gained, and work built on it will be based on a stale tree."
            echo
            echo "**Merge it with a merge commit, not a squash.** \`$TARGET\` has to come out with \`$BASE\` as an ancestor; a squash gives it equivalent content under a fresh commit instead, and the next sync diverges again for the same reason this one did."
            echo
            echo "\`$TARGET\` and \`$BASE\` are both untouched — nothing was pushed to either and no history was rewritten. \`$SCRATCH\` is a throwaway branch holding the \`$BASE\` tip, and it is deleted when this merges."
          } > /tmp/sync-next-body.md

          gh pr create \
            --base "$TARGET" \
            --head "$SCRATCH" \
            --title "chore: sync $BASE into $TARGET" \
            --body-file /tmp/sync-next-body.md \
            || echo "::warning::Could not open a sync pull request into $TARGET."
        shell: bash