Squash & Fixup Strategies: Atomic Commit Consolidation in Git Jump to heading

Squash and fixup operations are the commit-hygiene arm of Conflict Resolution & Safe Merge Operations, working in parallel with structural merge techniques to ensure that every commit reaching main is a complete, auditable unit rather than a stream of WIP saves.

Prerequisites Jump to heading


How Consolidation Relates to History Quality Jump to heading

Feature branches accumulate WIP commits, typo corrections, and incremental test adjustments during active development. Merging these directly into the mainline pollutes history, complicates git bisect, and obscures the actual scope of deployed changes.

The diagram below maps the lifecycle of a fixup commit from creation through the autosquash rebase to the final consolidated commit on main.

Squash and fixup commit lifecycleFour-stage flow: feature branch with WIP commits, git commit --fixup tagging a target, git rebase --autosquash collapsing them, and the single atomic commit landing on main.Feature branchabcdefghi--fixupWith fixup! commitabcdefghifix!autosquashRebase todo listabcfix!def(ghi dropped)mergedmainabc+fixatomic unit

Consolidation also improves code review quality. Reviewers evaluate complete functional deltas rather than fragmented iterations. Changelog generators and semantic versioning tools produce accurate output when commit messages map cleanly to deployed artifacts.


Step 1 — Understand the --fixup and --squash Flags Jump to heading

git commit --fixup=<sha> creates a commit prefixed with fixup! whose message matches the target commit. Git uses this prefix to locate and collapse the commit during git rebase --autosquash. git commit --squash=<sha> does the same with squash! but preserves the child’s message for editing during the rebase.

The distinction matters for changelog generation: fixup leaves no trace in the final history; squash lets you incorporate reviewer-requested notes into the consolidated message.

Verification: After running git commit --fixup abc1234, confirm the prefix is correct:

git log --oneline -5
# You should see a line beginning: fixup! <message of abc1234>

Step 2 — Enable Autosquash and Run the Consolidating Rebase Jump to heading

When paired with git rebase -i --autosquash <base>, Git automatically reorders and collapses fixup! and squash! commits without manual editor intervention.

# Mark a follow-up fix against commit abc1234
git commit --fixup abc1234

# Collapse fixup commits during rebase — autosquash reorders automatically
git rebase -i --autosquash main

Enable globally so you never need the flag:

git config --global rebase.autoSquash true

Verification: After the rebase completes, the fixup! commit must no longer appear in the log:

git log --oneline main..HEAD | grep -E '^[a-f0-9]+ (fixup|squash)!'
# Should produce no output

Safety Warning: Never execute git rebase --autosquash on a branch that has already been pulled by another contributor. Rewriting published history forces every downstream clone to diverge. That recovery requires git fetch && git reset --hard origin/<branch>, which discards any local work the other contributor made on top of your commits. Restrict squash/fixup rebases strictly to local feature branches before the first git push.


Step 3 — Use Extended --fixup Variants (Git 2.32+) Jump to heading

Git 2.32 introduced two surgical variants that avoid a full squash when only the message needs updating:

  • --fixup=amend:<sha> — folds the tree change into the parent commit and replaces the parent’s message with the current commit’s message
  • --fixup=reword:<sha> — modifies the parent commit’s message only, without touching the tree
# Correct a typo in the message of abc1234 without touching code
git commit --fixup=reword:abc1234 --allow-empty -m "feat: implement rate limiter with correct spelling"

# Fold a code patch into abc1234 and also update its message
git commit --fixup=amend:abc1234 -m "feat: implement rate limiter (add edge-case handling)"

Verification: After git rebase -i --autosquash main, inspect the target commit:

git show --stat abc1234^{/feat: implement rate limiter}
# Confirm message and diff match expectations

For teams needing manual control over the full rebase todo list, interactive rebase workflows provide the override framework — you can combine squash and fixup rebase actions with edit, drop, and reorder in the same session.


Step 4 — Add a Pre-Push Guard for Unresolved Fixup Commits Jump to heading

Pre-push hooks should scan commit messages for unresolved fixup! or squash! prefixes and block pushes that contain them — these indicate an autosquash rebase was not run. Pair this with pre-push validation rules for a complete gate layer.

#!/usr/bin/env bash
# .git/hooks/pre-push  (or managed via husky — see husky local hook configuration)
# Block pushes that contain unresolved fixup!/squash! commits

remote="$1"
z40=0000000000000000000000000000000000000000

while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
  # Skip branch deletions
  if [ "$local_sha" = "$z40" ]; then continue; fi

  base="${remote_sha:-$(git merge-base HEAD main)}"
  if git log --format="%s" "${base}..${local_sha}" | grep -qE '^(fixup|squash)!'; then
    echo "ERROR: Unresolved fixup/squash commits detected between ${base} and ${local_sha}."
    echo "Run: git rebase -i --autosquash main"
    exit 1
  fi
done
exit 0

Make the hook executable:

chmod +x .git/hooks/pre-push

Verification: Create a test fixup commit without rebasing and attempt a push — the hook must exit non-zero and print the error message.


Step 5 — Automate Non-Interactively in CI/CD Jump to heading

Embedding squash strategies into CI/CD pipelines requires non-interactive execution. Set GIT_SEQUENCE_EDITOR=true in automated rebase scripts to accept the rebase todo list without opening an editor:

#!/usr/bin/env bash
# Consolidate fixup commits in a CI job before running the test suite
# Requires: git 2.30+, a clean working tree, and a non-shared branch

set -euo pipefail

BASE_BRANCH="${BASE_BRANCH:-main}"

# Confirm no uncommitted changes before rewriting history
if ! git diff --quiet; then
  echo "ERROR: Working tree is dirty — commit or stash changes first."
  exit 1
fi

# Autosquash without opening an editor
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash "origin/${BASE_BRANCH}"

# Verify no fixup!/squash! prefixes remain
if git log --format="%s" "origin/${BASE_BRANCH}..HEAD" | grep -qE '^(fixup|squash)!'; then
  echo "ERROR: Fixup commits still present after autosquash. Investigate the rebase log."
  exit 1
fi

echo "Autosquash complete. Branch is clean."

When integrating with merge queues, squashed commits must preserve original authorship metadata. Configure squash merges to retain the original author attribution, not the merge bot identity:

# Preserve the original author when squashing via a script
git commit --amend --reset-author --no-edit

Safety Warning: Automated history rewriting in CI environments must run in isolated, ephemeral job contexts where the branch is exclusively owned by the pipeline. Never trigger interactive rebases on production infrastructure or on branches in a merge queue managed by another system.


Step 6 — Verify Tree State Parity Before and After Jump to heading

Squashing alters commit SHAs but must not alter the final tree state. Git resolves the squashed commit against the original merge base using the same algorithm described in 3-way merge fundamentals, so squashed histories do not introduce phantom conflicts during subsequent merges.

# Capture the tree hash and diff stat BEFORE squashing
BEFORE_TREE=$(git rev-parse HEAD^{tree})
git diff --stat main...HEAD > /tmp/before_stat.txt

# Run the squash/autosquash rebase here...

# Verify the tree is byte-for-byte identical
AFTER_TREE=$(git rev-parse HEAD^{tree})
if [ "$BEFORE_TREE" != "$AFTER_TREE" ]; then
  echo "ERROR: Tree state changed during squash. Investigating..."
  git diff "$BEFORE_TREE" "$AFTER_TREE"
  exit 1
fi

# Verify the diff stat against main is unchanged
git diff --stat main...HEAD > /tmp/after_stat.txt
diff /tmp/before_stat.txt /tmp/after_stat.txt || echo "WARNING: Diff stat changed — review carefully."

When Not to Squash Jump to heading

Squash and fixup operations are the right tool for local, unshared branches. They are the wrong tool in these situations:

  • Shared branches: Another contributor has pulled your commits. Rewriting forces manual recovery on their end.
  • Regulatory environments: Compliance frameworks (SOC 2, PCI-DSS, financial audit) often require immutable, granular commit trails with individual timestamps and authorship. Disable squash merge options in branch protection settings and configure repositories to reject non-fast-forward updates on compliance-critical branches.
  • Long-lived release branches: External documentation, dependency manifests, or cherry-pick backporting scripts may reference specific SHAs that squashing will invalidate. Coordinate with downstream consumers before consolidating.
  • Tagged releases: Squashing changes the SHA of commits that may already be tagged. Tags on rewritten commits point to unreachable objects until the tag is also moved.

If a squash is executed incorrectly or introduces regression, reach for non-destructive corrective actions rather than another rewrite. When to use git revert vs git reset covers post-squash remediation strategies that preserve audit integrity without rewriting shared history.


Integration with Adjacent Workflows Jump to heading

Interactive rebase: Interactive rebase workflows and squash/fixup are complementary tools within the same git rebase -i session. Use --autosquash for mechanical cleanup of fixup!-prefixed commits, and manually edit the todo list when you need to split, reorder, or compose commits that do not follow the prefix convention.

Pre-push hooks: Pre-push validation rules provide the enforcement layer that prevents unresolved fixup! commits from reaching a shared remote. Wire the pre-push hook from Step 4 into your Husky configuration (see local hook configuration with Husky) so it applies consistently across the team rather than relying on individual developers to install it manually.

CI trigger optimisation: Squashed commits that touch a narrower set of files can improve CI/CD pipeline trigger mapping accuracy. Path-filtered workflows fire fewer redundant jobs when each commit represents a complete feature rather than an incremental file save.


Troubleshooting Jump to heading

SymptomLikely causeFix
fixup! commits still visible after rebaserebase.autoSquash not set and --autosquash flag omittedRun git rebase -i --autosquash main explicitly, or set git config --global rebase.autoSquash true
Rebase aborts with “could not apply”Fixup target commit SHA no longer exists (was previously squashed or rebased)Run git log --oneline to locate the new SHA of the target commit, then re-create the fixup with the updated SHA
CI rebase fails with “cannot rebase: You have unstaged changes”Pipeline cloned with uncommitted changes or dirty submodulesAdd git checkout -- . or git clean -fd before the rebase step; ensure the CI workspace is fresh
Tree state differs after squash (git diff outputs changes)A squash! commit’s message was edited and contained a conflict marker that changed file contentRun git show HEAD to inspect the squashed commit, then git reset HEAD~1 and re-squash carefully
Author becomes the CI bot after squash mergePlatform squash-merge created a new commit attributed to the botConfigure the merge button to use the PR author’s identity, or use git commit --amend --author="Original Author <email>" --no-edit
--fixup=amend not recognisedGit version below 2.32Upgrade Git: git --version to check; 2.32+ required for extended fixup variants

Frequently Asked Questions Jump to heading

What is the difference between git squash and git fixup? Jump to heading

Both collapse a child commit into its parent during an interactive rebase. fixup silently discards the child’s commit message. squash preserves it and opens an editor so you can combine messages from both commits. Use fixup for purely mechanical corrections; use squash when the child commit message contains information worth preserving in the final history.

Is it safe to squash commits that have been pushed to a remote branch? Jump to heading

Only if no other contributor has pulled those commits. Squashing rewrites SHAs; any downstream clone that already has those commits will diverge and require a git fetch && git reset --hard origin/<branch>, discarding local work. Always squash on private feature branches before the first git push. If you have already pushed and the branch is in a draft PR with no other contributors, a git push --force-with-lease is acceptable — --force-with-lease fails if anyone else pushed after you, preventing silent history loss.

How do I run autosquash non-interactively in CI? Jump to heading

Set GIT_SEQUENCE_EDITOR=true before the rebase command:

GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main

This accepts the rebase todo list without opening an editor. Combine it with set -euo pipefail so the CI job fails immediately if the rebase encounters a conflict.

Does squashing change the final tree state? Jump to heading

It must not. Squashing only rewrites commit metadata and history topology; the working tree after the squash should be byte-for-byte identical to the pre-squash tip. Verify with git diff <before-sha> HEAD after the rebase. If the diff is non-empty, the squash introduced an unintended change — use git reflog to locate the pre-squash tip and reset to it.

When should I use --fixup=amend versus plain --fixup? Jump to heading

Use plain --fixup when you only need to fold a code change into the parent commit and the parent’s message is already correct. Use --fixup=amend:<sha> when the fix also requires updating the parent commit’s message — it folds the tree change and replaces the message in one step, avoiding a separate git commit --amend on the target commit later.