Rejecting WIP and fixup commits before merge Jump to heading
Fixup commits are a good habit during review: a reviewer asks for a change, the author commits it with git commit --fixup=<sha>, the reviewer sees exactly what changed, and before merging the author runs an autosquash rebase to fold it into the right commit. WIP commits play a similar role for work in progress. The problem is the “before merging” step. It is easy to forget, and when the pull request merges with its commits preserved, main gains fixup! Add PDF renderer and WIP don't merge forever. A required check that fails while such commits are present, combined with clear instructions, catches it every time without discouraging the habit during review. This page builds that check, within commit message hooks and templates.
When to use this approach Jump to heading
- Your team merges pull requests with their commits preserved — merge commits or rebase merging.
- Reviewers encourage fixup commits, and some reach main unsquashed.
- People push WIP commits to share progress and sometimes forget to clean them up.
- If every pull request is squash-merged, intermediate commit messages never reach main and this check is unnecessary.
Step 1 — Define what counts as unfinished Jump to heading
Agree on the markers. Git’s own autosquash prefixes are unambiguous; WIP markers vary by team, so list the ones yours uses.
# Prefixes created by git commit --fixup / --squash / --fixup=amend: / --fixup=reword:
# fixup! squash! amend!
# Common WIP conventions
# WIP wip: [WIP] DO NOT MERGE tmp:
pattern='^(fixup!|squash!|amend!|WIP\b|wip:|\[WIP\]|DO NOT MERGE|tmp:)' Step 2 — Check the pull request’s commits in CI Jump to heading
List the commits the pull request adds and fail if any subject matches. Report all offenders and give the fix.
# .github/workflows/no-unfinished-commits.yml
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
unfinished-commits:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0, ref: "${{ github.event.pull_request.head.sha }}" }
- run: |
base=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
bad=$(git log --format='%h %s' "$base..HEAD" --no-merges |
grep -E '^[0-9a-f]+ (fixup!|squash!|amend!|WIP\b|wip:|\[WIP\]|DO NOT MERGE|tmp:)' || true)
if [ -n "$bad" ]; then
echo "::error::Unfinished commits must be squashed before merging:"
echo "$bad"
echo "Fix: git rebase -i --autosquash $(git rev-parse --short "$base") && git push --force-with-lease"
exit 1
fi Checking out the pull request head, rather than the merge ref, matters here: the check is about the pull request’s own commits, not the merge result.
Step 3 — Allow them during review, block them at merge Jump to heading
The check fails as soon as a fixup commit is pushed, which is noisy while review is in progress. Two approaches keep it quiet until it matters: skip it for draft pull requests, or make it informational until approval and required for the merge queue.
jobs:
unfinished-commits:
if: github.event.pull_request.draft == false If you use a merge queue, require the check only for the queue, so it blocks landing but not reviewing; the setup is in choosing required status checks that actually gate.
Step 4 — Make the fix one command Jump to heading
The fix is almost always an autosquash rebase onto the base branch. Make it easy and safe with configuration and an alias.
git config --global rebase.autoSquash true
git config --global alias.squash-fixups '!f() { git rebase -i --autosquash "$(git merge-base origin/${1:-main} HEAD)"; }; f'
git squash-fixups main && git push --force-with-lease ⚠️ SAFETY WARNING: Autosquashing rewrites the branch, so it must be force-pushed. Use
--force-with-leaseso a reviewer’s pushed commit is not silently overwritten. If the rebase goes wrong,git reset --hard ORIG_HEADrestores the branch to its state before the rebase.
The autosquash workflow itself is covered in using fixup commits and autosquash during review.
Step 5 — Catch them locally too Jump to heading
The same rule in a pre-push hook stops unfinished commits from being pushed to protected branches directly, and gives a faster reminder before opening a pull request for review.
# pre-push: refuse unfinished commits only when pushing to main or release branches
case "$remote_ref" in refs/heads/main|refs/heads/release/*)
git log --format='%s' "$range" | grep -qE '^(fixup!|squash!|amend!|WIP)' && {
echo "refusing to push unfinished commits to ${remote_ref#refs/heads/}"; exit 1; } ;;
esac The complete version of this hook is in checking commit messages before push.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Why not let the forge squash fixups automatically at merge? Jump to heading
Forges offer whole-branch squash merging but not autosquash. If you want each meaningful commit preserved, the author has to fold fixups into the right commits before merging.
What if a commit message legitimately starts with “WIP”? Jump to heading
Rare, but possible — for example a feature literally named WIP. Use precise patterns with word boundaries, and document an escape such as rewording the commit.
Should merge commits inside the pull request be checked? Jump to heading
The check skips them with --no-merges, because their messages are generated. Teams that disallow merge commits inside pull requests can add that rule separately.
Related Jump to heading
- Commit Message Hooks & Templates — the parent topic.
- Enforcing Subject Line Length and Format — the other message rules worth checking.
- Editing an Old Commit in the Middle of a Branch — the alternative to fixup commits.
- Using Draft Pull Requests Effectively — where WIP commits belong.