Git Squash Skill
Purpose: Safely squash multiple consecutive commits into a single commit with automatic backup, verification, and cleanup.
When to Use:
- Cleaning up commit history before merge
- Combining related commits into logical units
- Simplifying pull request history
- Preparing feature branch for main merge
Squash vs Fixup: Choosing the Right Command
CRITICAL: When combining commits via interactive rebase, use the correct command:
| Command | Message Behavior | When to Use |
|---|---|---|
squash |
Opens editor with ALL commit messages combined | Different features/changes being combined |
fixup |
Discards secondary commit messages | Incremental fixes (typos, "oops forgot file") |
⚠️ COMMON MISTAKE: Using fixup when combining commits about different features.
# ❌ WRONG - Using fixup loses important context
pick abc123 Add FQN rule example
fixup def456 Add assert that() pattern # Message discarded!
fixup ghi789 Add Instant/Duration patterns # Message discarded!
# Result: Commit message only mentions FQN rule
# ✅ CORRECT - Using squash preserves all messages
pick abc123 Add FQN rule example
squash def456 Add assert that() pattern # Message preserved
squash ghi789 Add Instant/Duration patterns # Message preserved
# Result: Editor opens with all 3 messages for editing
Decision Rule:
- Same logical change (fix typo, add forgotten file, fix test) → Use
fixup - Different logical changes (different features, different rules) → Use
squash
When in doubt: Use squash - you can always edit the combined message, but fixup irreversibly
discards messages.
Default Behavior: Squash Into Earliest Commit
When squashing multiple commits, the default is to squash into the EARLIEST (first) commit in the sequence.
Example: Given commits in chronological order:
abc123 First commit ← EARLIEST (squash target)
def456 Second commit ← squashed into abc123
ghi789 Third commit ← squashed into abc123
Running squash abc123 def456 ghi789 produces:
xyz999 Combined commit (contains changes from all three)
Why earliest?:
- Preserves original authorship timestamp
- Maintains logical sequence (feature start → incremental changes)
- Matches git's native squash behavior in interactive rebase
- The "base commit" in this skill is the PARENT of the earliest commit
To squash into a different commit: Explicitly specify by reordering in interactive rebase, or use the cherry-pick approach documented in "Squashing Non-Adjacent Commits" section.
Multiple Independent Squash Requests
CRITICAL: When multiple squash requests are received in sequence, treat each as an independent operation unless the user explicitly relates them.
Example:
User: Squash commits A, B, C into one
User: Squash commits X, Y into one
Correct interpretation: Two separate squash operations
- First squash: Combine A, B, C
- Second squash: Combine X, Y
Wrong interpretation: Second request replaces or modifies first request
When requests ARE related: User will explicitly state the relationship:
- "Actually, squash A, B, C, X, Y all together" (combines both requests)
- "Instead of A, B, C, squash just A and B" (replaces first request)
- "In addition to the previous squash, also squash..." (adds to previous)
Default assumption: Each squash request is independent and should be executed separately.
Archival Files in Multi-Commit Patterns {#archival-multi-commit}
⚠️ CRITICAL: When organizing commits into a multi-commit pattern (e.g., code/docs/config separation),
archival files (todo.md and changelog.md) MUST be included in the appropriate existing commit - NOT
added as a separate commit.
Archival File Placement Rules
| Commit Pattern | Where Archival Goes | Rationale |
|---|---|---|
| Single commit | Same commit | All changes together |
| code / docs | [docs] commit |
Archival is documentation |
| code / docs / config | [docs] commit |
Archival is documentation |
| implementation / config | Implementation commit | No separate docs commit |
Common Mistake
# ❌ WRONG - Archival as separate 4th commit
[code] feat: Add feature implementation
[docs] docs: Update JavaDoc
[config] chore: Update CLAUDE.md
[archival] docs: Update todo.md and changelog.md # UNNECESSARY 4TH COMMIT!
# ✅ CORRECT - Archival included in docs commit
[code] feat: Add feature implementation
[docs] docs: Update JavaDoc, todo.md, changelog.md # ARCHIVAL INCLUDED HERE
[config] chore: Update CLAUDE.md
When Hook Blocks Merge for Missing Archival
If merge is blocked because archival files are missing AFTER commits are already organized:
# Scenario: 3 commits already organized, hook blocks for missing archival
# ✅ CORRECT: Amend the docs commit to include archival
git log --oneline -3
# abc123 [config] chore: Update CLAUDE.md
# def456 [docs] docs: Update JavaDoc ← AMEND THIS ONE
# ghi789 [code] feat: Add feature
# Step 1: Interactive rebase to edit the docs commit
git rebase -i ghi789^ # Parent of first commit
# Step 2: Mark docs commit for edit (change 'pick' to 'edit')
# pick ghi789 [code] feat: Add feature
# edit def456 [docs] docs: Update JavaDoc ← Change to 'edit'
# pick abc123 [config] chore: Update CLAUDE.md
# Step 3: When rebase stops, add archival and amend
# (Edit todo.md and changelog.md)
git add todo.md changelog.md
git commit --amend --no-edit
git rebase --continue
# ❌ WRONG: Create new commit for archival
git add todo.md changelog.md
git commit -m "docs: Update archival" # Creates unnecessary 4th commit!
Pre-Squash Archival Checklist
Before finalizing a multi-commit pattern, verify:
- Archival files (todo.md, changelog.md) are updated
- Archival is included in an existing commit (not separate)
- If docs commit exists: archival is in docs commit
- If no docs commit: archival is in implementation commit
-
git show --stat <docs-commit>shows both todo.md AND changelog.md
Supports Both Tip and Mid-History Squashing
This skill now handles both scenarios:
✅ Tip Squashing (commits at branch HEAD)
# Current state:
# HEAD → 043a992 (commit 7) ← Last to squash
# ... (commits 1-6) ← Squash these
# 61edb5a (base)
# Simple case: No commits after squash range
# Workflow: Squash → Move branch pointer
✅ Mid-History Squashing (commits have others after them)
# Current state:
# HEAD → 39f3981 (commit 10)
# a25506b (commit 9)
# 4a2e49a (commit 8)
# 043a992 (commit 7) ← Last to squash
# ... (commits 1-6) ← Squash these
# 61edb5a (base)
# Mid-history case: Commits exist after squash range
# Workflow: Squash → Rebase commits after → Move branch pointer
# Commits 8-10 are automatically rebased on top of squashed commit
The skill automatically detects which scenario and handles it appropriately.
Alternative: Interactive Rebase
You can also use interactive rebase for mid-history squashing:
git rebase -i <base-commit>
# Change "pick" to "squash" for commits to combine
Both approaches work. git-squash provides more safety checks and verification, while interactive rebase is the traditional git approach.
⚡ Performance: Optimized Script Available
RECOMMENDED: Use the optimized batch script for 85% faster execution
Performance Comparison:
- Traditional workflow: 9-11 LLM round-trips, 45-90 seconds
- Optimized script: 2-3 LLM round-trips, 5-10 seconds
- Safety checks: All preserved (no reduction)
When to use optimized script:
- ✅ Squashing adjacent commits (common case)
- ✅ Simple squash without conflicts
- ✅ Want minimal LLM involvement
When to use manual workflow:
- Complex mid-history squashing with potential conflicts
- Need to understand each step in detail
- Learning the git squash process
Optimized Script Reference: See Usage § Optimized Script section below
⚠️ Critical Safety Rules
MANDATORY BACKUP: Always create timestamped backup before squashing VERIFICATION REQUIRED: Verify no changes lost or added beyond original commits NON-SQUASHED COMMITS: Verify commits outside squash range remain completely unchanged FINAL STATE VERIFICATION: Verify working tree state matches original HEAD AUTOMATIC CLEANUP: Remove backup only after verification passes WORKING DIRECTORY: Must be clean (no uncommitted changes)
Prerequisites
Before using this skill, verify:
- Working directory is clean:
git statusshows no uncommitted changes - Know base commit (where to squash back to - parent of first commit to squash)
- Know first and last commits to squash
- HEAD is positioned at the LAST commit to squash (not beyond it)
- Current branch is correct
⚠️ CRITICAL: If HEAD has commits beyond the last commit you want to squash, you will squash MORE commits than intended. Ensure HEAD is exactly at the last commit to include in the squash before running this skill.
Skill Workflow
Step 1: Position HEAD (MANDATORY - DO NOT SKIP)
🚨 CRITICAL: This step is MANDATORY and must be done FIRST
# YOU MUST checkout the last commit to squash BEFORE creating backup
git checkout <last-commit-to-squash>
# Example: If squashing commits abc123 and def456, checkout def456
git checkout def456
Why this is mandatory:
- If HEAD is beyond the last commit to squash, you'll squash MORE commits than intended
- Example: HEAD at main with commits after def456 → squashes everything from base to main
- This was the exact mistake that occurred and required adding this checkpoint
Verification:
# Verify you're at the right commit
git log --oneline -1
# Should show the LAST commit you want to include in the squash
⚠️ DO NOT PROCEED to Step 2 until you've verified HEAD position
Step 2: Create Mandatory Backup
⚠️ CRITICAL - Always Create Backup First:
# Create timestamped backup branch
BACKUP_BRANCH="backup-before-squash-$(date +%Y%m%d-%H%M%S)"
git branch "$BACKUP_BRANCH"
# Verify backup created
if ! git rev-parse --verify "$BACKUP_BRANCH" >/dev/null 2>&1; then
echo "❌ ERROR: Failed to create backup - STOP"
exit 1
fi
echo "✅ Backup created: $BACKUP_BRANCH"
echo " Restore command if needed: git reset --hard $BACKUP_BRANCH"
Step 3: Verify Preconditions
Check Working Directory:
# Ensure no uncommitted changes
if [[ -n "$(git status --porcelain)" ]]; then
echo "❌ ERROR: Working directory not clean"
echo " Commit or stash changes first"
exit 1
fi
echo "✅ Working directory clean"
Identify Commits to Squash:
# Specify the range to squash
BASE_COMMIT="<commit-sha>" # Parent of first commit to squash
EXPECTED_LAST_COMMIT="<commit-sha>" # Last commit to include in squash (optional but recommended)
# Verify HEAD position if EXPECTED_LAST_COMMIT is specified
if [[ -n "$EXPECTED_LAST_COMMIT" ]]; then
CURRENT_HEAD=$(git rev-parse HEAD)
EXPECTED_HEAD=$(git rev-parse "$EXPECTED_LAST_COMMIT")
if [[ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]]; then
echo "❌ ERROR: HEAD is not at expected last commit!"
echo " Current HEAD: $(git log --oneline -1 HEAD)"
echo " Expected HEAD: $(git log --oneline -1 "$EXPECTED_LAST_COMMIT")"
echo ""
echo " You may be about to squash MORE commits than intended."
echo " Checkout the last commit before squashing:"
echo " git checkout $EXPECTED_LAST_COMMIT"
exit 1
fi
echo "✅ HEAD positioned at expected last commit"
# Check for commits AFTER the squash range (mid-history squashing)
if git rev-parse --verify "$ORIGINAL_BRANCH" >/dev/null 2>&1; then
COMMITS_AFTER_COUNT=$(git rev-list --count HEAD.."$ORIGINAL_BRANCH" 2>/dev/null || echo "0")
if [[ "$COMMITS_AFTER_COUNT" -gt 0 ]]; then
echo "⚠️ Mid-history squash detected: $COMMITS_AFTER_COUNT commits exist after squash range"
echo " Will automatically rebase them after squashing"
echo ""
# Record the original branch HEAD and commits after squash range
ORIGINAL_BRANCH_HEAD=$(git rev-parse "$ORIGINAL_BRANCH")
NEED_REBASE="true"
echo "ORIGINAL_BRANCH_HEAD=$ORIGINAL_BRANCH_HEAD" >> /tmp/squash-vars.sh
echo "NEED_REBASE=true" >> /tmp/squash-vars.sh
echo "COMMITS_AFTER_COUNT=$COMMITS_AFTER_COUNT" >> /tmp/squash-vars.sh
echo " Commits to preserve after squash:"
git log --oneline HEAD.."$ORIGINAL_BRANCH"
echo ""
else
echo "✅ No commits after squash range (tip squashing)"
echo "NEED_REBASE=false" >> /tmp/squash-vars.sh
fi
else
echo "NEED_REBASE=false" >> /tmp/squash-vars.sh
fi
fi
# Show commits to be squashed
echo "Commits to squash:"
git log --oneline "$BASE_COMMIT..HEAD"
echo ""
# Count commits
COMMIT_COUNT=$(git rev-list --count "$BASE_COMMIT..HEAD")
echo "Will squash $COMMIT_COUNT commits into 1"
Step 4: Record Original State
⚠️ CRITICAL - Record State for Verification:
# Record original HEAD for final state verification
ORIGINAL_HEAD=$(git rev-parse HEAD)
echo "Original HEAD: $ORIGINAL_HEAD"
# Verify BASE_COMMIT exists
if ! git rev-parse --verify "$BASE_COMMIT" >/dev/null 2>&1; then
echo "❌ ERROR: Base commit $BASE_COMMIT does not exist"
exit 1
fi
# CRITICAL: Verify HEAD is at or within the squash range
# If HEAD is beyond the commits to squash, we'll squash too many commits
COMMITS_AFTER_ORIGINAL=$(git rev-list HEAD --not "$BACKUP_BRANCH" 2>/dev/null || true)
if [[ -n "$COMMITS_AFTER_ORIGINAL" ]]; then
echo "❌ ERROR: HEAD has moved beyond backup branch!"
echo " This indicates commits exist after the squash range"
echo " Current HEAD: $(git rev-parse --short HEAD)"
echo " Backup HEAD: $(git rev-parse --short "$BACKUP_BRANCH")"
exit 1
fi
# Display final confirmation of squash range
echo "═══════════════════════════════════════"
echo "Squash Summary:"
echo " Base commit: $(git log --oneline -1 "$BASE_COMMIT")"
echo " Commits to squash: $COMMIT_COUNT"
echo " Final result: 1 commit"
echo "═══════════════════════════════════════"
# Record commits before base (these must remain unchanged)
if git rev-parse "${BASE_COMMIT}~1" >/dev/null 2>&1; then
COMMITS_BEFORE_BASE=$(git rev-list "${BASE_COMMIT}~1")
echo "Commits before base recorded for verification"
else
COMMITS_BEFORE_BASE=""
echo "Base commit is first commit (no prior commits to verify)"
fi
Step 5: Execute Squash
Soft Reset to Base:
# Reset to base commit, keeping all changes staged
git reset --soft "$BASE_COMMIT"
# Verify changes are staged
git status --short
Step 6: Verify No Changes Lost or Added
⚠️ MANDATORY VERIFICATION:
# Compare staged changes with backup branch
# No output = no differences = verification passed
DIFF_OUTPUT=$(git diff --stat "$BACKUP_BRANCH")
if [[ -n "$DIFF_OUTPUT" ]]; then
echo "❌ ERROR: Staged changes don't match original commits!"
echo "$DIFF_OUTPUT"
echo ""
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Verification passed: No changes lost or added"
Step 7: Create Squashed Commit
⚠️ CRITICAL: Write Meaningful Commit Message
The commit message must describe WHAT the changes DO, not just say "squashed".
⚠️ CRITICAL: Verify IMMEDIATELY After This Step
DO NOT mark task as "completed" or move to another todo item before verifying the squashed commit contains all expected changes. Verification must be ATOMIC with commit creation.
Step 7a: Review Commits Being Squashed:
# Review all commits to understand their combined purpose
echo "Commits being squashed:"
git log --format="%h %s" "$BASE_COMMIT..HEAD"
⚠️ CRITICAL: Commit Message Must Describe FINAL STATE
The squashed commit message must describe what the final code does after all changes are applied, not just list what each individual commit did.
❌ WRONG - Lists individual commits:
Fix typo in README
Update model name from Sonnet to Opus
Add missing import statement
Fix test that was broken by import change
✅ CORRECT - Describes final state:
Update model configuration to use Opus for analysis phase
Changes model selection from Sonnet to Opus in REQUIREMENTS phase to improve
analysis quality. Includes necessary import updates and test fixes to support
the model change.
Changes:
- Replace Sonnet 4.5 with Opus in model configuration
- Add Opus-specific imports and remove deprecated Sonnet references
- Fix test assertions to match Opus output format
- Update README to reflect current model usage
Final configuration uses Opus for analysis/decisions, Haiku for code generation.
Key difference: The second message tells you what the code does now (uses Opus for analysis), not just a chronological list of fixes. When someone reads this commit in 6 months, they understand the purpose, not just the implementation steps.
Workflow for Crafting Final-State Messages:
- Review all commits being squashed
- Look at the actual code changes (not just commit messages)
- Ask: "What does this code do now that it didn't do before?"
- Write subject line answering that question
- Write body explaining why this matters and listing key changes
- Verify: Does message describe final capability, not just implementation steps?
⚠️ ANTI-PATTERN: Do NOT Include Commit Bodies in Bash Commands
❌ WRONG - Causes parse errors:
# Including %b or %B outputs commit bodies with special characters
git log --format="%h %s%n%b" "$BASE_COMMIT..HEAD"
# ERROR: Parse errors if commit messages contain parentheses, quotes, etc.
✅ CORRECT - Safe alternatives:
# Option 1: Subject only (shown above)
git log --format="%h %s" "$BASE_COMMIT..HEAD"
# Option 2: Redirect to file first (if bodies needed)
git log --format="%h %s%n%b" "$BASE_COMMIT..HEAD" > /tmp/commits.txt
cat /tmp/commits.txt
# Option 3: Use git show for individual commits
for commit in $(git rev-list "$BASE_COMMIT..HEAD"); do
git show --stat "$commit"
done
Why this matters: Commit message bodies may contain special characters (parentheses, quotes, backticks) that cause bash parse errors when output directly in command pipelines.
Step 7b: Craft Message Following git-commit Skill Guidelines:
See git-commit skill for complete guidance on writing commit messages.
Key principles for squashed commits:
- MOST IMPORTANT: Describe what the final code DOES, not what commits were made
- Subject line: Summarize the final capability/change (imperative mood, 50-72 chars)
- Body: Explain the final result and why it matters
- Changes section: List key changes that achieve the final result (use bullet points)
- Rationale: Explain why these changes work together and what problem they solve
❌ WRONG - Generic placeholder:
git commit -m "Squashed: feature improvements"
✅ CORRECT - Describes what the code does:
# After reviewing commits, synthesize meaningful message
git commit -m "$(cat <<'EOF'
Enhance optimize-doc with iterative loop and session ID
Adds multi-pass optimization capability with proper session
management for agent continuity between optimization phases.
Changes:
- Add iterative optimization loop for multi-pass document refinement
- Integrate session ID capture from Phase 1 agent for Phase 2 resume
- Clarify that main agent optimizes directly (not via Task tool)
- Strengthen to use general-purpose agent only for both phases
- Fix Phase 2 to resume Phase 1 agent (not create new agent)
- Remove unnecessary agent ID file storage
These enhancements enable proper multi-pass optimization with agent
continuity between phases while maintaining session context.
EOF
)"
echo "✅ Squashed commit created"
Reference: See git-commit skill for detailed examples and anti-patterns
Step 7c: Rewriting Squashed Commit Message (If Needed)
⚠️ CRITICAL: Do NOT use git commit --amend after rebase completes
If you need to rewrite the squashed commit message after the rebase has finished:
❌ WRONG - Amends wrong commit:
# Rebase completes, HEAD is at branch tip
git commit --amend -m "New message" # ← Amends HEAD, NOT the squashed commit!
Why this fails:
- Interactive rebase with squashing creates the squashed commit
- Rebase continues, rebasing commits that came after the squash range
- When rebase completes, HEAD is at the branch tip (last commit)
- The squashed commit is back in history, NOT at HEAD
git commit --amendmodifies commit at HEAD, not a historical commit
✅ CORRECT - Use interactive rebase with edit:
# After rebase completes, identify the squashed commit
git log --oneline -10
# Example output:
# aa4f0c6 Latest commit (HEAD)
# ...
# 28b1631 Add bash-to-script skill ← The squashed commit (back in history)
# af98b4f Base commit
# Create script to mark squashed commit for editing
cat > /tmp/edit-squash.sh << 'EOF'
#!/bin/bash
# Change pick to edit for the squashed commit
sed -i 's/^pick 28b1631/edit 28b1631/' "$1"
EOF
chmod +x /tmp/edit-squash.sh
# Start interactive rebase from base of squashed commit
BASE_COMMIT="af98b4f" # Parent of squashed commit
GIT_SEQUENCE_EDITOR=/tmp/edit-squash.sh git rebase -i "$BASE_COMMIT"
# Rebase stops at squashed commit
# Now you can amend it
git commit --amend -m "$(cat <<'EOF'
New commit message
...
EOF
)"
# Continue rebase to reapply subsequent commits
git rebase --continue
Alternative 1 - Use reword in original interactive rebase:
Instead of fixing the message after rebase completes, you can specify "reword" during the initial rebase:
# During initial squash rebase, the todo list looks like:
# pick 2df90f4 First commit
# squash edbf606 Second commit
# squash e98d3b0 Third commit
# squash 625cc88 Fourth commit
#
# Git will prompt for combined message after squashing
# This is the BEST time to write the final message
Alternative 2 - Reset-Amend-Cherry-pick (SIMPLER):
If the squashed commit is in history with other commits after it, the simplest approach is:
# Step 1: Identify commits
SQUASHED_COMMIT="07fe839" # The squashed commit with wrong message
COMMITS_AFTER=("c3132c8" "70bd297" "2c536b6") # Commits that come after
# Step 2: Create new message file
cat > /tmp/new-message.txt << 'EOF'
New unified commit message
Describes what the final code does...
EOF
# Step 3: Reset to squashed commit
git reset --hard "$SQUASHED_COMMIT"
# Step 4: Amend the message
git commit --amend -F /tmp/new-message.txt
# Step 5: Cherry-pick commits that came after
for commit in "${COMMITS_AFTER[@]}"; do
git cherry-pick "$commit"
done
# Step 6: Verify
git log --oneline -5
Why this is simpler:
- No interactive rebase complications
- Clear, sequential steps
- Easy to verify at each stage
- No risk of rebase conflicts
⚠️ NEVER use exec commands to amend commits:
❌ WRONG - Creates duplicate commits:
# DON'T DO THIS - exec runs on wrong commit
cat > /tmp/bad-script.sh << 'EOF'
sed -i '/^pick 07fe839/c\exec git commit --amend -F /tmp/msg.txt\npick 07fe839' "$1"
EOF
GIT_SEQUENCE_EDITOR=/tmp/bad-script.sh git rebase -i BASE
# Result: Creates NEW commit, doesn't modify 07fe839
# You end up with 2 commits instead of 1!
Why exec approach fails:
- exec runs commands at current HEAD position
- When exec runs before pick, HEAD is at previous commit
- Amend creates new commit instead of modifying target
- Results in duplicate commits and history divergence
✅ Verification after message change:
# Always verify immediately after changing commit message
git log --oneline -5
# Check commit count didn't increase
EXPECTED_COUNT=4
ACTUAL_COUNT=$(git rev-list --count BASE..HEAD)
if [[ "$ACTUAL_COUNT" -ne "$EXPECTED_COUNT" ]]; then
echo "❌ ERROR: Expected $EXPECTED_COUNT commits, got $ACTUAL_COUNT"
echo " Likely created duplicate - rollback and retry"
git reset --hard BACKUP_BRANCH
exit 1
fi
echo "✅ Commit message updated successfully"
Best Practice: Write the final commit message during the initial squash operation when Git prompts you. This avoids the need for any post-squash message changes.
Step 7d: Immediate Post-Commit Verification (MANDATORY)
⚠️ CRITICAL: Verify squashed commit content IMMEDIATELY after creation
This verification MUST happen BEFORE marking the task as complete or moving to another todo item.
# Verify squashed commit exists
SQUASHED_COMMIT=$(git rev-parse HEAD)
echo "Squashed commit: $SQUASHED_COMMIT"
# Verify commit message is meaningful (not generic)
COMMIT_MSG=$(git log -1 --format=%B)
if echo "$COMMIT_MSG" | grep -qiE "^(squash|fixup|wip|tmp)"; then
echo "⚠️ WARNING: Generic commit message detected"
echo " Consider revising with more specific description"
fi
# CRITICAL: Verify squashed commit contains changes from ALL original commits
# Method 1: Check file count matches
FILES_IN_ORIGINAL=$(git diff --name-only "$BASE_COMMIT" "$BACKUP_BRANCH" | wc -l)
FILES_IN_SQUASHED=$(git diff --name-only "$BASE_COMMIT" HEAD | wc -l)
if [[ "$FILES_IN_ORIGINAL" -ne "$FILES_IN_SQUASHED" ]]; then
echo "❌ ERROR: File count mismatch!"
echo " Original commits affected $FILES_IN_ORIGINAL files"
echo " Squashed commit affects $FILES_IN_SQUASHED files"
echo " Changes may have been lost!"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ File count matches: $FILES_IN_SQUASHED files"
# Method 2: Compare actual content
CONTENT_DIFF=$(git diff "$BACKUP_BRANCH" HEAD)
if [[ -n "$CONTENT_DIFF" ]]; then
echo "❌ ERROR: Content doesn't match backup!"
echo " Squashed commit has different changes than original commits"
echo "$CONTENT_DIFF" | head -20
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Content verification passed: Squashed commit matches original commits"
# Method 3: Sample key changes (manual spot-check)
echo ""
echo "=== SPOT-CHECK: Review key changes in squashed commit ==="
echo "Files changed:"
git diff --name-status "$BASE_COMMIT" HEAD | head -10
echo ""
echo "Sample diff (first 30 lines):"
git diff "$BASE_COMMIT" HEAD | head -30
echo ""
echo "⚠️ MANUAL VERIFICATION REQUIRED:"
echo " Review above output to confirm all expected changes present"
echo " If anything looks wrong, ROLLBACK: git reset --hard $BACKUP_BRANCH"
echo ""
read -p "Does the squashed commit look correct? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborting due to user verification failure"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Manual verification passed"
Why Immediate Verification Matters:
- Catches lost changes BEFORE proceeding to next steps
- Prevents marking task as "completed" with incomplete squash
- Allows quick rollback before additional operations
- Separating "squash" and "verify" into different todo items risks missing issues
Lesson from 2025-11-11 Mistake: During non-adjacent commit squash, rebase completed successfully but changes from one commit were lost. Issue was discovered only after marking task complete and moving to separate "verification" phase, requiring full restoration from backup. Always verify IMMEDIATELY after commit creation, not as separate todo item.
Step 8: Verify Non-Squashed Commits Unchanged
⚠️ CRITICAL - Verify Commits Outside Squash Range:
# Verify commits before base remain completely unchanged
if [[ -n "$COMMITS_BEFORE_BASE" ]]; then
CURRENT_COMMITS_BEFORE_BASE=$(git rev-list "${BASE_COMMIT}~1")
if [[ "$COMMITS_BEFORE_BASE" != "$CURRENT_COMMITS_BEFORE_BASE" ]]; then
echo "❌ ERROR: Commits before base were modified!"
echo " This should never happen - non-squashed commits changed"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Verification passed: Commits before base unchanged"
fi
# Verify final working tree state matches original HEAD
TREE_DIFF=$(git diff --stat "$ORIGINAL_HEAD")
if [[ -n "$TREE_DIFF" ]]; then
echo "❌ ERROR: Working tree state doesn't match original HEAD!"
echo "$TREE_DIFF"
echo " Final contents should be identical to original"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Verification passed: Final working tree matches original HEAD"
Step 9: Final Count Verification
Verify Squash Success:
# Check commit count
NEW_COMMIT_COUNT=$(git rev-list --count "$BASE_COMMIT..HEAD")
if [[ "$NEW_COMMIT_COUNT" -ne 1 ]]; then
echo "❌ ERROR: Expected 1 commit after squash, got $NEW_COMMIT_COUNT"
exit 1
fi
# Show result
git log --oneline -3
echo "✅ Squash successful: $COMMIT_COUNT commits → 1 commit"
Step 10: Rebase Commits After Squash Range (Mid-History Only)
If commits existed after the squash range, rebase them on top:
source /tmp/squash-vars.sh
if [[ "$NEED_REBASE" == "true" ]]; then
echo ""
echo "═══════════════════════════════════════"
echo "Mid-history squash: Rebasing $COMMITS_AFTER_COUNT commits on top"
echo "═══════════════════════════════════════"
# Current state: We're at the squashed commit
SQUASHED_COMMIT=$(git rev-parse HEAD)
# Rebase commits that came after the squash range onto the squashed commit
# Uses --onto to replay commits from (last-to-squash) to (original-branch-head)
# onto the squashed commit
echo "Rebasing commits from $EXPECTED_LAST_COMMIT to $ORIGINAL_BRANCH_HEAD"
echo "onto squashed commit $SQUASHED_COMMIT"
echo ""
if ! git rebase --onto "$SQUASHED_COMMIT" "$EXPECTED_LAST_COMMIT" "$ORIGINAL_BRANCH_HEAD"; then
echo "" >&2
echo "❌ ERROR: Rebase encountered conflicts!" >&2
echo "" >&2
echo " Squashed commit created successfully: $SQUASHED_COMMIT" >&2
echo " But rebasing commits after squash range failed." >&2
echo "" >&2
echo " MANUAL RESOLUTION REQUIRED:" >&2
echo " 1. Resolve conflicts: git status" >&2
echo " 2. Stage resolved files: git add <files>" >&2
echo " 3. Continue rebase: git rebase --continue" >&2
echo " 4. Or abort: git rebase --abort && git reset --hard $BACKUP_BRANCH" >&2
echo "" >&2
echo " Backup available: $BACKUP_BRANCH" >&2
exit 1
fi
echo "✅ Successfully rebased $COMMITS_AFTER_COUNT commits"
echo ""
# Update branch pointer to the final rebased state
if [[ -n "$ORIGINAL_BRANCH" ]]; then
git branch -f "$ORIGINAL_BRANCH" HEAD
git checkout "$ORIGINAL_BRANCH"
echo "✅ Updated $ORIGINAL_BRANCH to final state"
fi
# Verify final commit count
FINAL_COUNT=$(git rev-list --count "$BASE_COMMIT..HEAD")
EXPECTED_FINAL=$((COMMITS_AFTER_COUNT + 1)) # Squashed commit + commits after
if [[ "$FINAL_COUNT" -ne "$EXPECTED_FINAL" ]]; then
echo "⚠️ WARNING: Expected $EXPECTED_FINAL commits, got $FINAL_COUNT" >&2
echo " This may indicate an issue - verify history" >&2
fi
echo ""
echo "Final history:"
git log --oneline "$BASE_COMMIT..HEAD"
else
# Tip squashing - just update branch pointer
if [[ -n "$ORIGINAL_BRANCH" ]] && [[ "$(git rev-parse --abbrev-ref HEAD)" == "HEAD" ]]; then
git branch -f "$ORIGINAL_BRANCH" HEAD
git checkout "$ORIGINAL_BRANCH"
echo "✅ Updated $ORIGINAL_BRANCH to squashed commit"
fi
fi
Step 11: Remove Backup
⚠️ Only After Verification Passes:
# Delete backup branch (verification passed)
source /tmp/squash-vars.sh
git branch -D "$BACKUP_BRANCH"
rm -f /tmp/squash-vars.sh
echo "✅ Backup removed after successful verification"
⚠️ CRITICAL: Do NOT Run Immediate Reflog/GC Cleanup
NEVER run these commands immediately after squash:
# ❌ DANGEROUS - Destroys recovery safety net
git reflog expire --expire=now --all
git gc --prune=now
Why this is dangerous:
- The reflog keeps references to ALL previous HEAD positions (~90 days default)
- If the squash had issues (lost changes, wrong commits), reflog lets you recover
- Running
reflog expire --expire=nowPERMANENTLY destroys this safety net
Safe approach:
- Let git's natural gc handle cleanup (90-day default expiration)
- Only consider aggressive cleanup after verifying operation was 100% correct AND you have a remote backup
Complete Example
Example: Squash 2 Commits
#!/bin/bash
set -euo pipefail
# Configuration
BASE_COMMIT="4d3e19e" # Parent of first commit to squash
EXPECTED_LAST_COMMIT="b732939" # Last commit to include in squash
ORIGINAL_BRANCH="main" # Branch to update after squash
echo "=== Git Squash: Multiple commits → 1 commit ==="
echo ""
# Step 1: Position HEAD at last commit to squash
git checkout "$EXPECTED_LAST_COMMIT"
echo "✅ Checked out last commit to squash"
# Step 2: Create backup
BACKUP_BRANCH="backup-before-squash-$(date +%Y%m%d-%H%M%S)"
git branch "$BACKUP_BRANCH"
echo "✅ Backup created: $BACKUP_BRANCH"
# Step 3: Verify preconditions
if [[ -n "$(git status --porcelain)" ]]; then
echo "❌ ERROR: Working directory not clean"
exit 1
fi
echo "✅ Working directory clean"
# Verify HEAD position
CURRENT_HEAD=$(git rev-parse HEAD)
EXPECTED_HEAD=$(git rev-parse "$EXPECTED_LAST_COMMIT")
if [[ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]]; then
echo "❌ ERROR: HEAD is not at expected last commit"
exit 1
fi
echo "✅ HEAD positioned at expected last commit"
COMMIT_COUNT=$(git rev-list --count "$BASE_COMMIT..HEAD")
echo "Will squash $COMMIT_COUNT commits into 1"
# Step 4: Record original state
ORIGINAL_HEAD=$(git rev-parse HEAD)
if git rev-parse "${BASE_COMMIT}~1" >/dev/null 2>&1; then
COMMITS_BEFORE_BASE=$(git rev-list "${BASE_COMMIT}~1")
else
COMMITS_BEFORE_BASE=""
fi
# Step 5: Execute squash
git reset --soft "$BASE_COMMIT"
echo "✅ Soft reset to base commit"
# Step 6: Verify no changes lost
DIFF_OUTPUT=$(git diff --stat "$BACKUP_BRANCH")
if [[ -n "$DIFF_OUTPUT" ]]; then
echo "❌ ERROR: Changes don't match!"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Verification passed: No changes lost or added"
# Step 7: Create squashed commit
git commit -m "$(cat <<'EOF'
Combined commit message
[Details from all squashed commits]
EOF
)"
echo "✅ Squashed commit created"
# Step 8: Verify non-squashed commits unchanged
if [[ -n "$COMMITS_BEFORE_BASE" ]]; then
CURRENT_COMMITS_BEFORE_BASE=$(git rev-list "${BASE_COMMIT}~1")
if [[ "$COMMITS_BEFORE_BASE" != "$CURRENT_COMMITS_BEFORE_BASE" ]]; then
echo "❌ ERROR: Commits before base were modified!"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Commits before base unchanged"
fi
TREE_DIFF=$(git diff --stat "$ORIGINAL_HEAD")
if [[ -n "$TREE_DIFF" ]]; then
echo "❌ ERROR: Working tree doesn't match original!"
echo "ROLLBACK: git reset --hard $BACKUP_BRANCH"
exit 1
fi
echo "✅ Final working tree matches original HEAD"
# Step 9: Final verification
NEW_COUNT=$(git rev-list --count "$BASE_COMMIT..HEAD")
if [[ "$NEW_COUNT" -ne 1 ]]; then
echo "❌ ERROR: Expected 1 commit, got $NEW_COUNT"
exit 1
fi
echo "✅ Final verification passed"
# Step 10: Update original branch (if detached)
if [[ -n "$ORIGINAL_BRANCH" ]] && [[ "$(git rev-parse --abbrev-ref HEAD)" == "HEAD" ]]; then
git branch -f "$ORIGINAL_BRANCH" HEAD
git checkout "$ORIGINAL_BRANCH"
echo "✅ Updated $ORIGINAL_BRANCH to squashed commit"
fi
# Step 11: Remove backup
git branch -D "$BACKUP_BRANCH"
echo "✅ Backup removed"
echo ""
echo "=== Squash complete ==="
git log --oneline -3
Safety Guarantees
This skill guarantees:
- ✅ Backup always created before any destructive operation
- ✅ Verification that staged changes = original commits (no loss, no additions)
- ✅ Verification that commits before base remain completely unchanged
- ✅ Verification that final working tree matches original HEAD state
- ✅ Verification that only intended commits are squashed (not commits beyond)
- ✅ HEAD position validation to prevent squashing beyond intended range
- ✅ Backup removed only after all verifications pass
- ✅ Clear rollback instructions on any error
- ✅ Atomic operation (all or nothing)
Error Recovery:
# If anything goes wrong, restore from backup:
git reset --hard backup-before-squash-YYYYMMDD-HHMMSS
Common Mistakes and How to Avoid Them
Mistake #1: Target Commit Hash Unchanged After Squash (DATA LOSS)
⚠️ CRITICAL: This is the most serious mistake - causes silent data loss
Problem: When squashing commits INTO another commit, the target commit's hash MUST change because its content changes. If the target hash remains the same, the squash DID NOT HAPPEN - commits were dropped instead of squashed.
Real Example from 2025-11-26:
# Requested: Squash b325852, 3178bcb, 6a707bc INTO 778b26b
# Used custom sed script with GIT_SEQUENCE_EDITOR
# After rebase completed:
git log --oneline | grep 778b26b
# 778b26b [config] Comprehensive shrink-doc... ← SAME HASH!
# ❌ FAILURE: Hash unchanged means content unchanged
# The commits were DROPPED, not squashed!
# ~4400 lines of changes silently lost
Why Hash Must Change:
- Squashing INTO a commit adds content to that commit
- Git commits are content-addressed (SHA-1 hash of content)
- Different content = different hash (always)
- Same hash = same content = squash didn't happen
MANDATORY Validation After Squash:
# Record target hash BEFORE squash
TARGET_BEFORE="778b26b"
TARGET_HASH_BEFORE=$(git rev-parse "$TARGET_BEFORE")
# After squash operation...
# Find the new commit at target's position
# (It should have a DIFFERENT hash)
TARGET_HASH_AFTER=$(git rev-parse HEAD~X) # Position where target should be
if [[ "$TARGET_HASH_BEFORE" == "$TARGET_HASH_AFTER" ]]; then
echo "❌ CRITICAL ERROR: Target commit hash unchanged!"
echo " Squash DID NOT HAPPEN - commits were dropped, not squashed"
echo " IMMEDIATE ROLLBACK REQUIRED"
git reset --hard "$BACKUP_BRANCH"
exit 1
fi
echo "✅ Target commit hash changed: squash successful"
echo " Before: $TARGET_HASH_BEFORE"
echo " After: $TARGET_HASH_AFTER"
Prevention:
- NEVER use untested sed/awk scripts for GIT_SEQUENCE_EDITOR
- ALWAYS verify target hash changed immediately after squash
- Prefer git-squash skill over custom rebase scripts - it has built-in safety checks
- If using custom scripts, test on a throw-away branch first
Root Cause of 2025-11-26 Mistake:
- Custom sed script had flawed logic for moving and squashing commits
- Script deleted commit lines but didn't properly reinsert them as squash
- No immediate hash validation caught the silent failure
- User correctly identified: "How is it possible you squashed if hash hasn't changed?"
Mistake #2: Squashin
…(truncated)