Skip to content

Instantly share code, notes, and snippets.

@Seklfreak
Forked from dviersel/cleanup-worktrees.sh
Last active October 1, 2026 16:24
Show Gist options
  • Select an option

  • Save Seklfreak/209374b10a4a12582b9917f16a6bf8c9 to your computer and use it in GitHub Desktop.

Select an option

Save Seklfreak/209374b10a4a12582b9917f16a6bf8c9 to your computer and use it in GitHub Desktop.
Git worktree cleanup script - Safely removes worktrees with finished work using worktrunk.
#!/bin/bash
# Cleans up finished git worktrees across one or more repositories.
# Uses worktrunk (wt) for worktree management.
#
# For each non-main worktree, this script checks:
# - Open PRs: skipped (worktree is still in use)
# - Uncommitted changes: skipped (would lose work)
# - Unpushed commits: skipped (would lose work)
# - Merged/closed PRs: noted, then evaluated for removal
# - Branch integrated: safe to remove (direct merge or squash/rebase merge)
# - Detached HEAD: reported and skipped
#
# The PR is found by head branch name, then by PR number for review checkouts
# named pr-NNNN, then by the worktree's tip commit (renamed branches).
#
# Worktrees that pass all checks are listed and optionally removed along with
# their local branches.
#
# It also offers to delete "shell" directories: <repo>.<branch> siblings that
# are no longer git worktrees. An IDE (RubyMine) still open on a removed
# worktree writes its .idea files back into the path and recreates it.
#
# Usage:
# cleanup-worktrees.sh # Scan all child directories for git repositories with worktrees
# cleanup-worktrees.sh <repository-dir> # Check worktrees in a specific repository
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Prints "STATE NUMBER URL" for the PR that belongs to a worktree, or nothing.
# Open PRs win over merged ones, merged over closed.
find_pr() {
local gh_repo="$1" branch="$2" wt_path="$3"
local pick='sort_by(if .state == "OPEN" then 0 elif .state == "MERGED" then 1 else 2 end)
| .[0] | select(.) | "\(.state) \(.number) \(.url)"'
local result=""
result=$(gh pr list --repo "$gh_repo" --head "$branch" --state all \
--json number,url,state --jq "$pick" 2>/dev/null) || true
# Review checkouts (gh pr checkout / wt switch pr:N) use a local branch
# name that differs from the PR's head branch.
if [[ -z "$result" && "$branch" =~ ^pr-([0-9]+)$ ]]; then
result=$(gh pr view "${BASH_REMATCH[1]}" --repo "$gh_repo" \
--json number,url,state --jq '"\(.state) \(.number) \(.url)"' 2>/dev/null) || true
fi
# Renamed branches: find the PR that contains the worktree's tip commit.
if [[ -z "$result" ]]; then
local sha
sha=$(git -C "$wt_path" rev-parse HEAD 2>/dev/null) || true
if [[ -n "$sha" ]]; then
result=$(gh pr list --repo "$gh_repo" --state all --search "$sha" \
--json number,url,state --jq "$pick" 2>/dev/null) || true
fi
fi
echo "$result"
}
# Offers to delete <repo>.* sibling directories that are not git worktrees.
sweep_shells() {
local repo_dir="${1%/}"
local parent name shells=()
parent=$(dirname "$repo_dir")
name=$(basename "$repo_dir")
local d
for d in "$parent/$name".*/; do
[[ -d "$d" ]] || continue
[[ -e "${d}.git" ]] && continue
shells+=("${d%/}")
done
[[ ${#shells[@]} -eq 0 ]] && return
echo -e "\n${YELLOW}Leftover directories that are no longer worktrees:${NC}"
for d in "${shells[@]}"; do
echo " - $(basename "$d") [$(ls -A "$d" | tr '\n' ' ')]"
done
echo -e "${YELLOW}Delete these directories? (y/N)${NC}"
local response
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
for d in "${shells[@]}"; do
rm -rf "$d"
done
echo -e "${GREEN}✓ Deleted ${#shells[@]} directories${NC}"
else
echo -e "${YELLOW}Kept.${NC}"
fi
}
# Function to check worktrees in a repository directory
check_worktrees() {
local repo_dir="$1"
# Get all worktrees as JSON
local worktrees_json
worktrees_json=$(wt -C "$repo_dir" list --format=json 2>/dev/null) || {
echo -e "${RED}✗ Failed to list worktrees via wt${NC}"
return
}
# Get main branch name for display
local main_branch
main_branch=$(git -C "$repo_dir" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')
if [[ -z "$main_branch" ]]; then
main_branch=$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD)
fi
# Get GitHub repo identifier for gh pr list
local gh_repo
gh_repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null \
| sed -E 's|.*github\.com[/:]||;s|\.git$||') || true
# Store branches to remove as "branch|force" (force=true means -D needed)
local removable_branches=()
# Process each non-main worktree
while IFS= read -r wt_entry; do
local path branch is_main kind
path=$(echo "$wt_entry" | jq -r '.worktree.path // empty')
branch=$(echo "$wt_entry" | jq -r '.branch // empty')
is_main=$(echo "$wt_entry" | jq -r '.worktree.main // false')
kind=$(echo "$wt_entry" | jq -r 'if .worktree then "worktree" else "branch" end')
# Skip the main worktree and non-worktree entries (bare branches)
[[ "$is_main" == "true" ]] && continue
[[ "$kind" != "worktree" ]] && continue
[[ -z "$path" ]] && continue
echo -e "\nChecking: $path"
if [[ -z "$branch" ]]; then
echo -e "${YELLOW}⚠ Detached HEAD at $(git -C "$path" rev-parse --short HEAD 2>/dev/null), skipped (remove by hand: wt -C $repo_dir remove $path)${NC}"
continue
fi
echo "Branch: $branch"
# Find the PR (open, merged or closed) — skip the worktree if it is open
local pr_merged=false pr_done=false
local pr_info pr_state="" pr_number="" pr_url=""
pr_info=$(find_pr "$gh_repo" "$branch" "$path")
if [[ -n "$pr_info" ]]; then
read -r pr_state pr_number pr_url <<< "$pr_info"
fi
case "$pr_state" in
OPEN)
echo -e "${YELLOW}⚠ Has open PR #$pr_number: $pr_url${NC}"
continue
;;
MERGED)
echo -e "${GREEN}✓ PR #$pr_number merged: $pr_url${NC}"
pr_merged=true
pr_done=true
;;
CLOSED)
echo -e "${GREEN}✓ PR #$pr_number closed: $pr_url${NC}"
pr_done=true
;;
*)
echo -e "${GREEN}✓ No PR found${NC}"
;;
esac
# worktrunk's verdict on whether the branch's content is already in the
# default branch (ancestor, or patch-id match for squash/rebase merges).
# Computed before the unpushed-commit guards so that an integrated branch
# is not blocked by commits that only differ by hash from main's.
local main_state integration_reason integrated=false
main_state=$(echo "$wt_entry" | jq -r 'if .default_branch.integration then "integrated" elif (.default_branch.ahead // 1) == 0 then "same_commit" else (.display.state // "") end')
integration_reason=$(echo "$wt_entry" | jq -r '.default_branch.integration.reason // ""')
[[ "$main_state" == "integrated" || "$main_state" == "same_commit" || "$main_state" == "empty" ]] && integrated=true
# Check for uncommitted changes (staged or modified) from wt JSON
local staged modified needs_stash=false
staged=$(echo "$wt_entry" | jq -r '.worktree.changes.staged')
modified=$(echo "$wt_entry" | jq -r '.worktree.changes.modified')
if [[ "$staged" == "true" || "$modified" == "true" ]]; then
echo -e "${YELLOW}⚠ Has uncommitted changes (will stash before removal)${NC}"
git -C "$path" status --short | sed 's/^/ /'
needs_stash=true
else
echo -e "${GREEN}✓ No uncommitted changes${NC}"
fi
# Warn about untracked files (don't skip)
local untracked
untracked=$(echo "$wt_entry" | jq -r '.worktree.changes.untracked')
if [[ "$untracked" == "true" ]]; then
echo -e "${YELLOW}⚠ Has untracked files (will stash before removal)${NC}"
git -C "$path" ls-files --others --exclude-standard | sed 's/^/ /'
needs_stash=true
fi
# Check for unpushed commits from wt JSON
local has_remote remote_ahead
has_remote=$(echo "$wt_entry" | jq -r '.upstream != null')
remote_ahead=$(echo "$wt_entry" | jq -r '.upstream.ahead // 0')
if [[ "$has_remote" == "true" ]]; then
if [[ "$remote_ahead" -gt 0 ]]; then
if [[ "$integrated" == "true" ]]; then
echo -e "${YELLOW}⚠ Has $remote_ahead unpushed commit(s) (content already in $main_branch, offering removal anyway)${NC}"
elif [[ "$pr_done" == "true" ]]; then
echo -e "${YELLOW}⚠ Has $remote_ahead unpushed commit(s) (PR closed/merged, offering removal anyway)${NC}"
else
echo -e "${RED}✗ Has $remote_ahead unpushed commit(s)${NC}"
continue
fi
else
echo -e "${GREEN}✓ All commits pushed${NC}"
fi
else
echo -e "${YELLOW}⚠ No upstream branch set${NC}"
local main_ahead
main_ahead=$(echo "$wt_entry" | jq -r '.default_branch.ahead // 0')
if [[ "$main_ahead" -gt 0 ]]; then
if [[ "$integrated" == "true" ]]; then
echo -e "${YELLOW}⚠ Has $main_ahead commit(s) not in $main_branch by hash, but their content is${NC}"
elif [[ "$pr_done" == "true" ]]; then
echo -e "${YELLOW}⚠ Has $main_ahead commit(s) not in $main_branch (PR closed/merged, offering removal anyway)${NC}"
else
echo -e "${RED}✗ Has $main_ahead commit(s) not in $main_branch${NC}"
continue
fi
fi
fi
# Use worktrunk's main_state (computed above) to decide removal
case "$main_state" in
integrated)
if [[ "$integration_reason" == "ancestor" ]]; then
echo -e "${GREEN}✓ Branch is merged into $main_branch${NC}"
else
echo -e "${GREEN}✓ PR was squash/rebase merged into $main_branch${NC}"
fi
removable_branches+=("$branch|safe|$path|$needs_stash")
;;
same_commit|empty)
echo -e "${GREEN}✓ Branch is at same commit as $main_branch${NC}"
removable_branches+=("$branch|safe|$path|$needs_stash")
;;
ahead)
if [[ "$pr_done" == "true" ]]; then
# PR merged/closed but local main hasn't been fetched yet — safe to force-remove
local msg="PR merged"
[[ "$pr_merged" != "true" ]] && msg="PR closed"
echo -e "${GREEN}✓ $msg (fetch origin to sync local state)${NC}"
removable_branches+=("$branch|force|$path|$needs_stash")
else
local unique_commits
unique_commits=$(echo "$wt_entry" | jq -r '.default_branch.ahead // 0')
echo -e "${YELLOW}⚠ Branch is not merged into $main_branch${NC}"
echo -e "${RED} Has $unique_commits unique commit(s)${NC}"
fi
;;
*)
if [[ "$pr_done" == "true" ]]; then
local msg="PR merged"
[[ "$pr_merged" != "true" ]] && msg="PR closed"
echo -e "${GREEN}✓ $msg (main_state: $main_state)${NC}"
removable_branches+=("$branch|force|$path|$needs_stash")
else
echo -e "${YELLOW}⚠ Branch is not merged into $main_branch (state: $main_state)${NC}"
fi
;;
esac
done < <(echo "$worktrees_json" | jq -c '.items[]')
echo -e "\n================================"
echo "Summary:"
echo "================================"
if [[ ${#removable_branches[@]} -eq 0 ]]; then
echo -e "${YELLOW}No worktrees are ready for cleanup.${NC}"
return
fi
echo -e "${GREEN}The following worktrees can be safely removed:${NC}"
for item in "${removable_branches[@]}"; do
local b="${item%%|*}"
echo " - $b"
done
echo -e "\n${YELLOW}Remove worktrees and delete branches? (y/N)${NC}"
read -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
for item in "${removable_branches[@]}"; do
local b="${item%%|*}"
local rest="${item#*|}"
local mode="${rest%%|*}"
rest="${rest#*|}"
local wt_path="${rest%%|*}"
local do_stash="${rest##*|}"
echo -e "\nRemoving worktree for branch: $b"
if [[ "$do_stash" == "true" ]]; then
echo -e "${YELLOW}Stashing uncommitted changes...${NC}"
git -C "$wt_path" stash push --include-untracked -m "cleanup-worktrees: auto-stash before worktree removal"
echo -e "${GREEN}✓ Changes stashed (recover with: git stash pop)${NC}"
fi
# Remove gitignored/untracked files so git worktree remove can delete the directory cleanly
git -C "$wt_path" clean -fdx --quiet
local remove_flags="--yes --foreground"
[[ "$mode" == "force" ]] && remove_flags="$remove_flags -D"
[[ "$do_stash" == "true" ]] && remove_flags="$remove_flags --force"
wt -C "$repo_dir" remove $remove_flags "$b"
# Anything left is ignored junk recreated after the clean (IDE files)
[[ -d "$wt_path" && ! -e "$wt_path/.git" ]] && rm -rf "$wt_path"
echo -e "${GREEN}✓ Removed${NC}"
done
echo -e "\n${GREEN}Cleanup complete!${NC}"
else
echo -e "${YELLOW}Cleanup cancelled.${NC}"
fi
}
# Function to process a single repository
process_repository() {
local repo_dir="$1"
local repo_name
repo_name=$(basename "$repo_dir")
# Check if it's a git repository
if ! git -C "$repo_dir" rev-parse --git-dir > /dev/null 2>&1; then
return
fi
# Skip linked worktrees — only process the main worktree of each repo
local git_dir
git_dir=$(git -C "$repo_dir" rev-parse --git-dir)
if [[ "$git_dir" == *"/worktrees/"* ]]; then
return
fi
# Check if this repo has non-main worktrees
local worktree_count
worktree_count=$(wt -C "$repo_dir" list --format=json 2>/dev/null \
| jq '[.items[] | select(.worktree != null and .worktree.main == false)] | length' 2>/dev/null \
|| echo "0")
if [[ "$worktree_count" -gt 0 ]]; then
echo -e "\n${YELLOW}Repository: $repo_name${NC}"
echo -e "${YELLOW}Path: $repo_dir${NC}"
check_worktrees "$repo_dir"
fi
sweep_shells "$repo_dir"
}
# Check if directory parameter is provided
if [ $# -eq 0 ]; then
echo -e "${GREEN}Scanning for git repositories in current directory...${NC}"
echo "================================"
START_DIR=$(pwd)
for dir in */; do
if [ -d "$START_DIR/$dir" ]; then
process_repository "$START_DIR/$dir"
fi
done
else
REPO_DIR="$1"
if [ ! -d "$REPO_DIR" ]; then
echo -e "${RED}Error: Directory '$REPO_DIR' does not exist${NC}"
exit 1
fi
if ! git -C "$REPO_DIR" rev-parse --git-dir > /dev/null 2>&1; then
echo -e "${RED}Error: '$REPO_DIR' is not a git repository${NC}"
exit 1
fi
echo "Checking worktrees for cleanup..."
echo "================================"
check_worktrees "$REPO_DIR"
sweep_shells "$(cd "$REPO_DIR" && pwd)"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment