git-squash
Safely squash multiple commits into one with automatic backup and verification
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- GitHub stars: 2
- Updated: 2025-11-21
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: git-squash
description: Safely squash multiple commits into one with automatic backup and verification
allowed-tools: Bash, Read
---
# 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
## Supports Both Tip and Mid-History Squashing
**This skill now handles both scenarios**:
### ✅ Tip Squashing (commits at branch HEAD)
```bash
# 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)
```bash
# 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:
```bash
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](#optimized-script-recommended) 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 status` shows 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**
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# Review all commits to understand their combined purpose
echo "Commits being squashed:"
git log --format="%h %s" "$BASE_COMMIT..HEAD"
```
**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**:
- Subject line: Summarize the combined purpose (imperative mood, 50-72 chars)
- Body: List key changes from all squashed commits (use bullet points)
- Rationale: Explain why these changes work together
❌ **WRONG - Generic placeholder**:
```bash
git commit -m "Squashed: feature improvements"
```
✅ **CORRECT - Describes what the code does**:
```bash
# 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.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
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**:
```bash
# 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 --amend` modifies commit at HEAD, not a historical commit
**✅ CORRECT - Use interactive rebase with edit**:
```bash
# 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 - Use reword in original interactive rebase**:
Instead of fixing the message after rebase completes, you can specify "reword" during the initial rebase:
```bash
# 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
```
**Best Practice**: Write the final commit message during the initial squash operation when Git prompts you. This avoids the need for a second interactive rebase.
### 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.
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
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"