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
reposcope; a fine-grained PAT needs Administration and Contents set to read-and-write. A token that only hascontents: writecan push commits but gets a 403 on the protection PUT. force=trueon 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 ifbase_shawere ever computed wrong. Double-check which branch isBASEand which isTARGETbefore 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_bodydoes above, rather than hand-writing a partial payload.
Sources
- About pull request merges — GitHub Docs — squash merging leaves the common ancestor unchanged, and GitHub recommends against it for a branch you’ll keep working on.
- Update a reference — GitHub REST API docs — the
forceparameter on the git refs PATCH endpoint is what turns a normal update into a non-fast-forward reset. - REST API endpoints for protected branches — GitHub Docs —
allow_force_pushesdefaults to false and is set through the same PUT endpoint that returns the rest of the protection config. - About merge methods on GitHub — GitHub Docs — continuing work on a squash-merged branch causes already-merged commits to reappear in the next pull request.