-
-
Save Seklfreak/209374b10a4a12582b9917f16a6bf8c9 to your computer and use it in GitHub Desktop.
Git worktree cleanup script - Safely removes worktrees with finished work using worktrunk.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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