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 bisect to 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
Where --exec stopsThe branch has four commits. The build passes after C1 and C2, fails after C3 because a rename left a caller broken, and would pass again after C4 which fixed the caller. Tip-only CI sees only C4 and passes; rebase --exec stops at C3.tip-only CI: pass β€” per-commit: stops at C3branchC1C2C3C4build passesC1C2C4build failsC3C4 hid the break from CI, but bisect will still land on C3 one day Where --exec stopsThe branch has four commits. The build passes after C1 and C2, fails after C3 because a rename left a caller broken, and would pass again after C4 which fixed the caller. Tip-only CI sees only C4 and passes; rebase --exec stops at C3.tip-only CI: pass β€” per-commit: stops at C3branchC1C2C3C4build passesC1C2C4build failsC3C4 hid the break from CI, but bisect will still land on C3 one day

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.

Per-commit check time on a twelve-commit branchAn illustrative branch of twelve commits. Running the full test suite at every commit takes most of an hour. A build plus type-check takes a few minutes. Adding only the tests affected by each commit keeps it under ten minutes while catching most breakage.total time across 12 commits (illustrative)full test suite54 minbuild + type-check4 min+ affected tests9 minrun the cheap check per commit and the full suite on the tip Per-commit check time on a twelve-commit branchAn illustrative branch of twelve commits. Running the full test suite at every commit takes most of an hour. A build plus type-check takes a few minutes. Adding only the tests affected by each commit keeps it under ten minutes while catching most breakage.total time across 12 commits (illustrative)full test suite54 minbuild + type-check4 min+ affected tests9 minrun the cheap check per commit and the full suite on the tip
# 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
rebase --exec locally against a commit loop in CILocally, rebase --exec stops at the failing commit so you can fix it in place. In CI, a loop over the pull request's commits reports which commit fails without rewriting anything, which is what an automated check should do.rebase --exec (local)commit loop (CI)on failurestops there to fixreports the hashrewrites historyonly if you amendneverwho runs itthe authorevery PRuse both: the loop enforces, --exec makes fixing easy rebase --exec locally against a commit loop in CILocally, rebase --exec stops at the failing commit so you can fix it in place. In CI, a loop over the pull request's commits reports which commit fails without rewriting anything, which is what an automated check should do.rebase --exec (local)commit loop (CI)on failurestops there to fixreports the hashrewrites historyonly if you amendneverwho runs itthe authorevery PRuse both: the loop enforces, --exec makes fixing easy

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.