Testing every commit with rebase --exec Jump to heading
CI tests the tip of a branch. If the tip passes, the branch merges. But the commits in between may not build at all: a function renamed in one commit and its callers fixed two commits later, a test added before the code it tests. Nobody notices until someone runs git bisect months later and half the steps fail for reasons unrelated to the bug being hunted. git rebase --exec runs any command after each commit is applied, stopping at the first failure. It is the simplest way to guarantee every commit on a branch is a working state. This page shows how to use it locally and in CI, and how to keep it fast enough that people actually run it, within interactive rebase workflows.
When to use this approach Jump to heading
- Your team merges branches with their individual commits preserved, through merge commits or rebase merging.
- You rely on
git bisectto find regressions, as in automating git bisect with a test script. - You just rewrote a branch β reordered, split or edited commits β and want to confirm each step still works.
- If your team squash-merges every pull request, only the squash commit lands on main, and per-commit testing matters much less.
Step 1 β Run a command after every commit Jump to heading
--exec adds an exec line after each pick in the rebase todo list. Without -i, the rebase runs straight through. If the base is unchanged, the commits are replayed in place and their hashes stay the same.
git rebase --exec 'make build' main
# Executing: make build
# ...
# Successfully rebased and updated refs/heads/feature/pdf. When a command fails, the rebase stops right after the commit that broke it, with that commit as HEAD.
Executing: make build
error: undefined reference to 'render'
warning: execution failed: make build
You can fix the problem, and then run
git rebase --continue Step 2 β Fix the broken commit in place Jump to heading
At the stop, HEAD is the broken commit. Make the minimal change that makes it build, amend, and continue. The remaining commits are replayed on top.
git show --stat HEAD # the commit that broke the build
sed -i 's/\.render(/.render_pdf(/' src/invoice/pages.py
git add src/invoice/pages.py
git commit --amend --no-edit
git rebase --continue # later commits may now conflict with the same fix If a later commit made the same fix β as C4 did above β it will now conflict or become empty. Resolve by keeping the later commitβs other changes, or drop it if it only contained that fix.
Step 3 β Choose a command that is fast enough Jump to heading
Running the whole test suite per commit is slow on a branch of twenty commits. Use the fastest command that catches the common breakage: compile and type-check, plus the tests for changed areas.
# Build and type-check every commit; run only the tests touched by that commit
git rebase --exec 'make build typecheck && ./scripts/test-changed.sh HEAD^ HEAD' main #!/bin/sh
# scripts/test-changed.sh <from> <to> β run tests for files changed in the range
set -eu
files=$(git diff --name-only "$1" "$2" -- 'src/**/*.py' | sed 's#^src/#tests/#; s#\.py$#_test.py#')
existing=""; for f in $files; do [ -f "$f" ] && existing="$existing $f"; done
[ -z "$existing" ] && exit 0
pytest -q $existing Step 4 β Run it in CI for pull requests that keep their commits Jump to heading
Locally, the check depends on people remembering to run it. In CI, run it against the pull requestβs commits without rewriting anything: check out each commit in turn and run the command. Unlike a rebase, this does not change hashes.
jobs:
every-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: |
for c in $(git rev-list --reverse "origin/${{ github.base_ref }}..HEAD"); do
git checkout -q "$c"
echo "::group::$(git log -1 --format='%h %s')"
make build typecheck || { echo "::error::build fails at $(git log -1 --format=%h)"; exit 1; }
echo "::endgroup::"
done Step 5 β Make it a habit after every history rewrite Jump to heading
Any rebase that reorders, splits, squashes or edits commits can break intermediate states. Add --exec to those rebases by default with an alias.
git config --global alias.rbt '!f() { git rebase -i --exec "${REBASE_CHECK:-make build}" "$@"; }; f'
git rbt main # interactive rebase with a build check after every commit git rebase -i with --exec inserts the exec lines into the todo list you edit, so you can also delete them for commits you know are safe.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Does rebase --exec change my commits if everything passes? Jump to heading
If the base has not moved and no command modifies files, the rebase fast-forwards over the existing commits and nothing changes. If the base moved, the commits are rebased onto it as usual and get new hashes.
What if the command modifies files, such as a formatter? Jump to heading
The rebase stops with βdirty working treeβ after that commit. Either amend the changes in and continue, which is a way to format every commit, or make the command check-only so it never writes files.
Can I skip the check for one commit, such as a work-in-progress commit? Jump to heading
In interactive mode, delete that commitβs exec line from the todo list. If such commits are common, squash them into their neighbours instead, so every commit stands on its own.
Related Jump to heading
- Interactive Rebase Workflows β the parent topic.
- Bisecting a Flaky Failure β why bisectable history matters.
- Splitting a Large Commit into Reviewable Pieces β a rewrite that often breaks intermediate commits.
- Splitting a Long Pipeline into Parallel Jobs β keeping the per-commit job fast in CI.