Local Fitness

Automating the fix for squash-merge drift on a long-lived branch

Shipped

Local-fitness promotes work from dev to main through squash-merged pull requests. Version 0.14.0’s headline feature was a compact calendar chart style, but the piece worth teaching landed alongside it: a script and a workflow that force dev back onto main’s commit after every promotion, so the two branches share a tip again. It’s a small piece of release engineering, but it touches a git behavior worth understanding and a GitHub API dance worth doing carefully, so let’s build it.

Why a squash-merged branch drifts

Squash merging takes every commit in a pull request and collapses it into one new commit on the base branch. GitHub’s own docs spell out the consequence: because that squashed commit “is only on the base branch and not the head branch, the common ancestor of the two branches remains unchanged.” For a feature branch you delete right after merge, that’s irrelevant. For a branch you keep working on, it isn’t: the common ancestor is now one commit behind where the branch actually is, even though the tree is identical.

That’s exactly what happens to dev. Squash-merge dev into main, and dev still has its own last commit as the tip, one behind main. The next dev → main pull request diffs against that stale ancestor, so it lists commits that already shipped, as phantom changes; GitHub’s own merge methods guide describes this directly, that continuing work on a squash-merged branch means “commits that you previously squashed and merged will be listed in the new pull request.” GitHub’s docs say the same thing from the recommendation side: “If you plan to continue work on the head branch of a pull request after the pull request is merged, we recommend you don’t squash and merge the pull request.” Since a long-lived integration branch has to keep being squash-merged (that’s the whole point of a clean main history), the fix isn’t to change the merge strategy. It’s to stop letting dev diverge: reset it to main’s SHA after every promotion, so the histories share a tip again.

Rewrite branch protection without losing any of it

The reset itself is one API call, PATCH /repos/{owner}/{repo}/git/refs/{ref} with force=true. The GitHub REST docs describe the force flag plainly: leaving it out or setting it false “will make sure you’re not overwriting work,” and setting it true is what lets you move the ref backward or sideways instead of only fast-forwarding it. That’s a force push, and a protected branch blocks force pushes by default, so before the reset you have to flip allow_force_pushes on, and flip it back off immediately after. The branch protection PUT endpoint takes the entire protection config in one request, so the risky part is re-sending everything else exactly as it was, changing only that one field.

Here’s the function that does the re-marshaling, kept small on purpose so it’s easy to verify against a real payload:

build_body() {  # $1 = 1 to allow force pushes, 0 to forbid
  local force="$1"
  jq --argjson force "$force" '{
    required_status_checks: (if .required_status_checks then
      {strict: .required_status_checks.strict, contexts: (.required_status_checks.contexts // [])}
      else null end),
    enforce_admins: (.enforce_admins.enabled // false),
    required_linear_history: (.required_linear_history.enabled // false),
    allow_force_pushes: ($force == 1)
  }' "$snapshot"
}

You can check this transform without touching GitHub at all. Save a sample protection response (this is what gh api repos/OWNER/REPO/branches/BRANCH/protection returns) as protection.json:

{
  "required_status_checks": { "strict": true, "contexts": ["ci / build"] },
  "enforce_admins": { "enabled": false },
  "required_linear_history": { "enabled": true },
  "allow_force_pushes": { "enabled": false }
}

Source build_body with snapshot="protection.json" and call it both ways:

$ build_body 0
{
  "required_status_checks": {
    "strict": true,
    "contexts": [
      "ci / build"
    ]
  },
  "enforce_admins": false,
  "required_linear_history": true,
  "allow_force_pushes": false
}
$ build_body 1
{
  "required_status_checks": {
    "strict": true,
    "contexts": [
      "ci / build"
    ]
  },
  "enforce_admins": false,
  "required_linear_history": true,
  "allow_force_pushes": true
}

Every field survives the round trip except the one you meant to change. That’s the property that matters: a wrong re-marshal here means silently loosening protection you didn’t intend to touch.

Assemble the full reset script

With the rewrite verified, wrap it in the actual reset: check whether the branches already match (idempotency matters, since this runs on every push to main), snapshot protection, flip force pushes on, move the ref, then flip protection back with a trap so it restores even if the ref update fails partway.

#!/usr/bin/env bash
set -euo pipefail

REPO="${1:?usage: reset-branch.sh <owner/repo>}"
BASE="main"
TARGET="dev"

base_sha=$(gh api "repos/$REPO/git/ref/heads/$BASE" --jq '.object.sha')
target_sha=$(gh api "repos/$REPO/git/ref/heads/$TARGET" --jq '.object.sha')

if [ "$base_sha" = "$target_sha" ]; then
  echo "already at $base_sha, nothing to do"
  exit 0
fi

PROT_PATH="repos/$REPO/branches/$TARGET/protection"
snapshot="$(mktemp)"
gh api "$PROT_PATH" > "$snapshot"

# build_body defined above

restore() { build_body 0 | gh api -X PUT "$PROT_PATH" --input - > /dev/null; }
trap restore EXIT

build_body 1 | gh api -X PUT "$PROT_PATH" --input - > /dev/null
gh api -X PATCH "repos/$REPO/git/refs/heads/$TARGET" -f sha="$base_sha" -F force=true > /dev/null

trap - EXIT
restore
echo "reset $TARGET to $base_sha"

trap restore EXIT is the line that makes this safe to run unattended. If the PATCH throws, or the runner gets killed mid-script, the trap still fires on the way out and force pushes get locked back down. Without it, a failed run leaves the branch unprotected until someone notices.

Wire it into a workflow

Commit the script as reset-branch.sh at the repo root and make it executable (chmod +x reset-branch.sh before committing). Editing branch protection needs admin permissions on the repo, which the default GITHUB_TOKEN a workflow gets does not have, so this has to run with a personal access token stored as a secret. Gate the job on that secret being present, so a fork or a repo that hasn’t configured it skips cleanly instead of failing:

name: reset-branch-after-promotion
on:
  push:
    branches: [main]
jobs:
  reset:
    runs-on: ubuntu-latest
    steps:
      - id: gate
        env:
          RESET_PAT: ${{ secrets.RESET_PAT }}
        run: |
          if [ -z "${RESET_PAT:-}" ]; then
            echo "RESET_PAT not configured, skipping"
            echo "enabled=false" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=true" >> "$GITHUB_OUTPUT"
          fi
      - uses: actions/checkout@v4
        if: steps.gate.outputs.enabled == 'true'
      - run: ./reset-branch.sh "${{ github.repository }}"
        if: steps.gate.outputs.enabled == 'true'
        env:
          GH_TOKEN: ${{ secrets.RESET_PAT }}

Triggering on every push to main, rather than on a pull request event, matters: it also covers a direct admin push to main outside the normal promotion flow, and the idempotency check makes that harmless since the branches already match.

Verify it

Once the workflow has run, confirm the branches share a tip:

$ gh api repos/OWNER/REPO/git/ref/heads/main --jq .object.sha
$ gh api repos/OWNER/REPO/git/ref/heads/dev --jq .object.sha

Both commands should print the same 40-character SHA. Matching tips mean the reset worked and the next promotion PR will only show genuinely new commits. If they differ, check the workflow run’s logs for the gate step; the most common reason is a missing or expired RESET_PAT.

Gotchas

  • The token needs repo admin scope, not just write. A classic PAT needs the repo scope; a fine-grained PAT needs Administration and Contents set to read-and-write. A token that only has contents: write can push commits but gets a 403 on the protection PUT.
  • force=true on the git refs endpoint will move a ref backward, not just sideways. It’s meant for exactly this reset, but the same call would just as happily rewind a branch to an earlier SHA if base_sha were ever computed wrong. Double-check which branch is BASE and which is TARGET before running it against a real repo.
  • The protection PUT is a full replace, not a patch. Any field you omit from the JSON body gets reset to GitHub’s default for that field, which can silently turn off protections you meant to keep. Round-trip every field you care about through the snapshot, as build_body does above, rather than hand-writing a partial payload.

Sources

Changelog

  • release: 0.14.0 — chart calendar style (346f9f8)
  • ci: automate ‘reset dev onto main’ after promotions (a5ef2ed)