Analyze git history, identify contiguous thematic groups, and interactively squash them with clean conventional commit messages. Use when user says /squash-commits, 'squash commits', 'clean up git history', 'compress commits', or asks to tidy commit history.
Analyze a range of git commits, identify contiguous groups that share a theme, and squash them non-interactively with clean commit messages following git conventions.
/squash-commits [N] [--all] [--batch] [--max-passes M]
N: number of recent commits to analyze (default: 20)--all: include pushed history (requires force push later)--batch: run repeated conservative squashes over multiple contiguous groups--max-passes M: cap batch iterations (default: 5, recommended <= 20)By default, only unpushed commits (ahead of the remote tracking branch) are analyzed. This prevents accidentally proposing to rewrite published history. Pass --all to include pushed commits in the scan (will require force push).
For large histories, prefer conservative iterative passes instead of one large rewrite:
git log --first-parent --oneline) to avoid side-branch noise.pre-big-band-<N>-YYYYMMDD).git range-diff plus health checks and tests after every pass.Contiguity is necessary for proposing a squash group, but it is not
sufficient to guarantee a conflict-free rebase. Interactive rebase rewrites the
selected commits and then replays later commits on top of the rewritten base.
That means a perfectly contiguous A/B/C squash can still trigger conflicts in
later D/E/F commits if those later commits touch the same regions or depend on
the original parent chain's exact file state.
Practical consequence:
Only commits that are adjacent in git log order are candidates. If commit A and C share a theme but commit B (unrelated) sits between them, A and C are NOT candidates. Never skip over unrelated commits to form a group.
These contiguous sequences should be squashed:
Yarli workspace merges + reapplies: A yarli: merge workspace result for tranche-XXX followed by yarli: reapply pre-existing workspace state after merge (and any intermediate yarli bookkeeping commits for the same run).
Handoff sequences: Multiple handoff: commits updating docs in the same session.
Fix + recovery pairs: A commit that broke something followed immediately by its fix (e.g., feat: add X then fix: correct X where X is the same feature).
Multi-step feature work: Implementation commit + its test commit + its doc commit, only if contiguous and clearly part of the same unit of work.
Identical repeated messages: Multiple commits with the same or near-identical message (e.g., repeated yarli: reapply pre-existing workspace state after merge).
Chore batches: Contiguous chore: commits doing related cleanup (e.g., lint fixes, formatting, dependency bumps in one session).
All squashed commit messages MUST follow these conventions:
feat:, fix:, chore:, docs:, refactor:, test:, yarli:, handoff:Co-Authored-By: Claude <[email protected]> when AI-assistedBefore anything else, capture and display the full HEAD commit hash:
ORIGINAL_HEAD=$(git rev-parse HEAD)
Always print this prominently at the start of output:
Original HEAD: <full 40-char SHA>
To restore: git reset --hard <full 40-char SHA>
This is the single source of truth for recovery. The backup tag (Step 4) is a convenience alias, but the SHA is authoritative because tags can be accidentally deleted or moved.
Default: unpushed commits only. Run:
UPSTREAM=$(git rev-parse --abbrev-ref @{upstream} 2>/dev/null)
--all was NOT passed: Use git log --oneline $UPSTREAM..HEAD to scan only unpushed commits. If user passed N, cap at N. If there are zero unpushed commits, report "No unpushed commits to squash" and stop (suggest --all if they want to include pushed history).--all WAS passed: Use git log --oneline -N for the full requested range. Warn prominently: "Including pushed commits — squashing will require git push --force-with-lease."git log --oneline -N for the requested range (no remote to diverge from).git log --oneline <base>..<tip> regardless of upstream.Display the scan range and commit count before proceeding.
Before proposing any groups, also state whether this is a tip-only cleanup or a deeper rewrite that will force many later commits to be replayed. If the history is large or conflict-prone, bias toward tip-only cleanup first.
Identify contiguous squash groups using the rules above. For each group, record:
git diff --shortstat <base>..<tip>Present a table to the user:
| # | Group Label | Commits | SHA Range | Proposed Message |
|---|--------------------------|---------|--------------------|-----------------------------------------|
| 1 | Yarli YRLI-52 workspace | 3 | abc1234..def5678 | yarli: Complete YRLI-52 auth middleware |
| 2 | Handoff docs update | 2 | 111aaaa..222bbbb | handoff: Update session handoff docs |
Also show commits that will be left untouched (not in any group) so the user can verify nothing was missed or incorrectly excluded.
Flag groups as high risk if any of these are true:
Also explicitly call out:
task, merge, auto-repair, or
reapply commits, since these are often replay-sensitiveAsk the user to approve, modify, or reject the plan. Offer options:
Do NOT proceed without explicit user approval.
If --batch is used, collect one explicit approval for:
--max-passes)Recommend Conservative by default on large branches or replay-sensitive
histories.
Build a GIT_SEQUENCE_EDITOR shell script:
#!/bin/bash
# Auto-generated squash editor
sed -i '
# For each group: keep first as "pick", change rest to "fixup"
# Then use "exec" to amend the message
<sed commands here>
' "$1"
Save it to /tmp/haake-squash-editor.sh, make it executable, then run:
GIT_SEQUENCE_EDITOR=/tmp/haake-squash-editor.sh git rebase -i <base-sha>^
For the commit message amendments, use exec git commit --amend -m "..." lines in the rebase todo.
For batch mode:
/tmp/haake-squash-editor-<pass>.sh)For any non-trivial cleanup, prefer running on a dedicated cleanup branch first.
For conflict-heavy batch mode on an isolated branch, enable rerere before passes:
git config rerere.enabled true
git config rerere.autoupdate true
Prefer the merge backend behavior and avoid apply-style workflows for conflict prone cleanups, because merge-aware replay handles renames and context more robustly than apply-style patching.
After rebase completes:
git log --oneline -N to show the cleaned-up historygit range-diff against the pre-pass backup ref to verify what changedcargo test, npm test, pytest, etc.)If tests fail, warn the user and suggest git rebase --abort or git reflog to recover.
Remove the temp script and verify the repository is in a healthy state:
# Remove temp files
rm -f /tmp/haake-squash-editor.sh
# Ensure no rebase is in progress
if [ -d .git/rebase-merge ] || [ -d .git/rebase-apply ]; then
echo "WARNING: Rebase state directory still exists!"
fi
# Ensure no stale lock files
if [ -f .git/index.lock ]; then
echo "WARNING: .git/index.lock exists — git may be locked!"
fi
if [ -f .git/refs/heads/*.lock ] 2>/dev/null; then
echo "WARNING: Stale ref lock files found!"
fi
# Verify working tree is clean
git status --porcelain
# Verify HEAD is valid
git rev-parse --verify HEAD
Run all of the above checks. If any warnings fire:
git rebase --abort. If that fails, inform the user.rm .git/index.lock only after confirming no other git process is running (ps aux | grep git).Always print the final summary including the original HEAD for recovery:
Squash complete.
Original HEAD: <full 40-char SHA>
Current HEAD: <full 40-char SHA>
Backup tag: pre-squash-backup
To restore: git reset --hard <original HEAD SHA>
Repo health: OK (clean tree, no locks, no dangling rebase)
$UPSTREAM..HEAD) unless --all is passed. This prevents accidentally proposing to rewrite published history. When --all is used, warn prominently that a force push will be required.git status --porcelain shows uncommitted changes, refuse to proceed. Ask the user to commit or stash first.git push --force-with-lease and let them do it.git tag -f pre-squash-backup pointing at the original HEAD so the user can recover with git reset --hard pre-squash-backup.pre-big-band-<N>-YYYYMMDD before each pass.git config rerere.enabled true
and git config rerere.autoupdate true before multi-pass cleanup.git range-diff before attempting another pass.git rebase --abort and report the recovery SHA..git/rebase-merge, .git/rebase-apply), no stale lock files (.git/index.lock, .git/refs/heads/*.lock), and working tree is clean. Report any anomalies.User: /squash-commits
Claude:
Original HEAD: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
To restore: git reset --hard a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Upstream: origin/master (8 commits ahead)
Scanning 8 unpushed commits...
Found 2 squash groups among 8 unpushed commits:
| # | Group Label | Commits | SHA Range | Proposed Message |
|---|--------------------------|---------|--------------------|-------------------------------------------------|
| 1 | Ideate feature | 3 | abc1234..def5678 | feat: Add ideate subcommand with pager |
| 2 | Clippy fix + recovery | 2 | 333cccc..444dddd | fix: Resolve clippy warnings in rest.rs |
3 commits remain untouched (standalone).
Result: 8 commits → 5 commits. No force push needed.
Approve this plan? [Approve / Conservative (3+ only) / Custom / Cancel]
User: /squash-commits 30 --all
Claude:
Original HEAD: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
To restore: git reset --hard a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
⚠ Including pushed commits — squashing will require force push.
Scanning last 30 commits...
Found 4 squash groups among 30 commits:
...
Squash complete.
Original HEAD: a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Current HEAD: f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5
Backup tag: pre-squash-backup
To restore: git reset --hard a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
Repo health: OK (clean tree, no locks, no dangling rebase)
Tests: 67 passed, 0 failed
/handoff: Session handoff with commit and docs update