cleaning-up-branches

Installation
SKILL.md

Branch Cleanup

Delete merged branches (local and optionally remote) with explicit user confirmation, and flag stale unmerged branches for manual review.

Auto-Invoke Triggers

This skill activates when:

  1. Keywords: "cleanup branches", "delete merged branches", "prune old branches", "remove stale branches", "branch cleanup", "remove dead branches"
  2. Command: /cleanup-branches

Arguments

  • --base <branch> — Base branch for merge check (default: main)
  • --threshold <months> — Inactivity threshold for stale detection (default: 3)
  • --remote — Include remote branch deletion
  • --dry-run — Show what would be deleted without acting

Safety Model

  • Merged branches: Deletable after explicit user confirmation
  • Unmerged branches: Never auto-deleted — reported with manual commands only
  • Dry-run: Available via --dry-run flag to preview actions
  • Confirmation: Before each destructive step, list branches and ask the user

Workflow

Execute each step below using the Bash tool.

Step 1: Validate Git Repository

git rev-parse --is-inside-work-tree 2>/dev/null || echo "NOT_A_GIT_REPO"

If not a git repo, stop and inform the user.

Step 2: Parse Arguments

Parse $ARGUMENTS for:

  • --base BRANCH → set BASE_BRANCH=BRANCH (default: main)
  • --threshold N → set THRESHOLD_MONTHS=N (default: 3)
  • --remote → set INCLUDE_REMOTE=true (default: false)
  • --dry-run → set DRY_RUN=true (default: false)

Verify the base branch exists:

git rev-parse --verify "$BASE_BRANCH" 2>/dev/null || echo "BASE_BRANCH_NOT_FOUND"

If the base branch doesn't exist, try master as fallback. If neither exists, stop and inform the user.

Step 3: Fetch Latest Remote State

if ! git fetch --prune 2>/dev/null; then
  echo "Warning: Could not reach remote. Remote branch data may be stale."
fi

Step 4: Display Branch Status Summary

current_branch=$(git branch --show-current)
total_local=$(git branch | wc -l | tr -d ' ')
total_remote=$(git branch -r | grep -v HEAD | wc -l | tr -d ' ')
remote=$(git config --get "branch.$BASE_BRANCH.remote" 2>/dev/null || echo "origin")
merged_local=$(git branch --merged "$BASE_BRANCH" | grep -v "^\*" | grep -vw "$BASE_BRANCH" | wc -l | tr -d ' ')
merged_remote=$(git branch -r --merged "$remote/$BASE_BRANCH" | grep -v "$remote/$BASE_BRANCH" | grep -v "$remote/HEAD" | wc -l | tr -d ' ')

echo "=== BRANCH STATUS ==="
echo "Current branch: $current_branch"
echo "Base branch: $BASE_BRANCH"
echo "Local branches: $total_local ($merged_local merged into $BASE_BRANCH)"
echo "Remote branches: $total_remote ($merged_remote merged into $BASE_BRANCH)"

Present this summary to the user.

Step 5: Local Merged Branch Cleanup

List local branches merged into base (excluding base and current branch):

git branch --merged "$BASE_BRANCH" | grep -v "^\*" | grep -vw "$BASE_BRANCH" | while IFS= read -r branch; do
  branch="${branch## }"
  last_commit=$(git log -1 --format='%ci' "$branch" 2>/dev/null | cut -d' ' -f1)
  echo "  $branch  (last commit: ${last_commit:-unknown})"
done

Count:

merged_count=$(git branch --merged "$BASE_BRANCH" | grep -v "^\*" | grep -vw "$BASE_BRANCH" | wc -l | tr -d ' ')
if [ "$merged_count" -eq 0 ]; then
  echo "  (none)"
fi
echo "Found $merged_count local merged branch(es)"

If merged branches exist and not --dry-run:

Ask the user for confirmation using natural conversation: "These N branches are merged into BASE_BRANCH. Delete them?"

If confirmed, delete each branch:

git branch --merged "$BASE_BRANCH" | grep -v "^\*" | grep -vw "$BASE_BRANCH" | while IFS= read -r branch; do
  branch="${branch## }"
  git branch -d "$branch"
done

If --dry-run: Display what would be deleted but skip the deletion.

Step 6: Squash-Merged Branch Cleanup

Detect branches whose changes are already in base via squash-and-merge or rebase-merge. Uses git cherry to compare patch-ids.

echo "=== SQUASH-MERGED BRANCHES ==="
squash_branches=""
for branch in $(git for-each-ref --format='%(refname:short)' refs/heads/); do
  [ "$branch" = "$BASE_BRANCH" ] && continue
  current=$(git branch --show-current)
  [ "$branch" = "$current" ] && continue

  # Skip branches already detected as merged
  merged=$(git branch --merged "$BASE_BRANCH" | grep -w "$branch" | wc -l | tr -d ' ')
  [ "$merged" -gt 0 ] && continue

  # Count commits on branch since merge-base
  merge_base=$(git merge-base "$BASE_BRANCH" "$branch" 2>/dev/null)
  [ -z "$merge_base" ] && continue
  unique_commits=$(git log --oneline "$merge_base".."$branch" --no-merges 2>/dev/null | wc -l | tr -d ' ')
  [ "$unique_commits" -eq 0 ] && continue

  # git cherry: + means NOT in base, - means equivalent exists in base
  unpicked=$(git cherry "$BASE_BRANCH" "$branch" 2>/dev/null | grep '^+' | wc -l | tr -d ' ')
  if [ "$unpicked" -eq 0 ]; then
    relative=$(git log -1 --format='%cr' "$branch")
    echo "  $branch ($relative)"
    squash_branches="$squash_branches $branch"
  fi
done
squash_count=$(echo "$squash_branches" | wc -w | tr -d ' ')
if [ "$squash_count" -eq 0 ]; then
  echo "  (none)"
fi
echo "Found $squash_count squash-merged branch(es)"

If squash-merged branches exist and not --dry-run:

Ask the user for confirmation: "These N branches were squash-merged into BASE_BRANCH (verified via git cherry). Delete them?"

If confirmed, delete each branch. Note: must use -D (force) since git doesn't recognize squash merges as merged:

for branch in $squash_branches; do
  git branch -D "$branch"
done

If --dry-run: Display what would be deleted but skip the deletion.

Step 7: Remote Merged Branch Cleanup (if --remote)

Only execute if --remote flag was provided.

List remote branches merged into base:

git branch -r --merged "$remote/$BASE_BRANCH" | grep -v "$remote/$BASE_BRANCH" | grep -v "$remote/HEAD" | while IFS= read -r branch; do
  branch="${branch## }"
  short_name="${branch#$remote/}"
  last_commit=$(git log -1 --format='%ci' "$branch" 2>/dev/null | cut -d' ' -f1)
  echo "  $short_name  (last commit: ${last_commit:-unknown})"
done

Count:

remote_merged=$(git branch -r --merged "$remote/$BASE_BRANCH" | grep -v "$remote/$BASE_BRANCH" | grep -v "$remote/HEAD" | wc -l | tr -d ' ')
if [ "$remote_merged" -eq 0 ]; then
  echo "  (none)"
fi
echo "Found $remote_merged remote merged branch(es)"

If remote merged branches exist and not --dry-run:

Ask the user for confirmation: "These N remote branches are merged. Delete them from $remote?"

If confirmed, delete each remote branch:

git branch -r --merged "$remote/$BASE_BRANCH" | grep -v "$remote/$BASE_BRANCH" | grep -v "$remote/HEAD" | while IFS= read -r branch; do
  branch="${branch## }"
  short_name="${branch#$remote/}"
  git push "$remote" --delete "$short_name"
done

If --dry-run: Display what would be deleted but skip the deletion.

Step 8: Stale Unmerged Branch Report

List inactive unmerged branches (past threshold) with ahead/behind counts. Never delete these — only display them.

Calculate threshold:

if [[ "$OSTYPE" == "darwin"* ]]; then
  threshold=$(date -v-${THRESHOLD_MONTHS}m +%s)
else
  threshold=$(date -d "${THRESHOLD_MONTHS} months ago" +%s)
fi

Scan for stale unmerged branches:

echo "=== STALE UNMERGED BRANCHES (manual review required) ==="
git for-each-ref --sort=committerdate --format='%(refname:short) %(committerdate:unix) %(committerdate:relative)' refs/heads/ | while IFS= read -r line; do
  branch=$(echo "$line" | awk '{print $1}')
  timestamp=$(echo "$line" | awk '{print $2}')
  relative=$(echo "$line" | cut -d' ' -f3-)

  # Skip base branch and squash-merged branches (already handled in Step 6)
  [ "$branch" = "$BASE_BRANCH" ] && continue
  echo "$squash_branches" | grep -qw "$branch" && continue

  if [[ "$timestamp" =~ ^[0-9]+$ ]] && [ "$timestamp" -lt "$threshold" ]; then
    merged=$(git branch --merged "$BASE_BRANCH" | grep -w "$branch" | wc -l | tr -d ' ')
    if [ "$merged" -eq 0 ]; then
      counts=$(git rev-list --left-right --count "$BASE_BRANCH"..."$branch" 2>/dev/null)
      behind=$(echo "$counts" | awk '{print $1}')
      ahead=$(echo "$counts" | awk '{print $2}')
      echo "  $branch ($relative) [ahead $ahead, behind $behind]"
    fi
  fi
done

After listing, suggest manual deletion commands (but never execute them):

To delete these branches manually:
  Local:   git branch -D <branch>
  Remote:  git push origin --delete <branch>

Step 9: Summary Report

Present a summary of all actions taken:

=== CLEANUP SUMMARY ===
Local merged branches deleted: N
Squash-merged branches deleted: N
Remote merged branches deleted: N (or "skipped — use --remote")
Stale unmerged branches flagged: N (manual review)

Important Caveats

  • Squash merges: Detected automatically using git cherry (patch-id comparison). These require -D (force delete) since git doesn't recognize them as merged. Edge cases: amended commits after squash or partial cherry-picks may not be detected.
  • Current branch: The current branch is never deleted, even if merged.
  • Protected branches: main, master, and the base branch are always excluded from deletion.
  • Remote permissions: Deleting remote branches requires push access to the remote.

Progressive Disclosure

For more details, see:

Version

1.1.0

Related skills

More from joaquimscosta/arkhe-claude-plugins

Installs
17
GitHub Stars
13
First Seen
Feb 15, 2026