Last active
July 15, 2026 12:44
-
-
Save mecograph/c41da87b350b11e2ed1b8814e4070296 to your computer and use it in GitHub Desktop.
A script for conveniently cleaning up both local and remote branches, helping to keep the working directory organized and uncluttered
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 | |
| # Define ANSI color codes | |
| green="\033[32m" | |
| bright_black="\033[90m" | |
| red="\033[31m" | |
| blue="\033[34m" | |
| reset="\033[0m" | |
| # Branches that must never be deleted, locally or on the remote | |
| # (the default branch and merge target are additionally protected dynamically) | |
| protected_branches="{ADD YOUR BRANCH NAMES HERE}" | |
| cb_is_protected() { | |
| local branch=$1 p | |
| for p in $protected_branches; do | |
| [ "$branch" = "$p" ] && return 0 | |
| done | |
| return 1 | |
| } | |
| # Utility Functions | |
| format_repo_name() { | |
| local repo_name=${1//[-\/]/ } | |
| echo $repo_name | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2));}1' | |
| } | |
| capitalize() { | |
| echo "$1" | awk '{ for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2)); }1' | |
| } | |
| clear_lines() { | |
| local count=$1 | |
| while [ $count -gt 0 ]; do | |
| echo -en "\033[1A\033[2K" | |
| ((count--)) | |
| done | |
| } | |
| get_formatted_date() { | |
| local branch=$1 | |
| local reflog_output | |
| # Use --date=iso for consistent parsing across platforms | |
| # Oldest reflog entry = branch creation; entries older than the reflog | |
| # expiry (default 90 days) are gone, hence the fallback text | |
| if ! reflog_output=$(git reflog --date=iso "$branch" 2>/dev/null | sed -n '$p'); then | |
| echo "more than 90 days ago" | |
| return | |
| fi | |
| if [[ -z "$reflog_output" ]]; then | |
| echo "more than 90 days ago" | |
| return | |
| fi | |
| # Extract ISO date (format: 2025-12-10 14:02:56 +0100) | |
| local iso_date=$(echo "$reflog_output" | sed -n 's/.*@{\([0-9-]* [0-9:]*\).*/\1/p') | |
| if [[ -z "$iso_date" ]]; then | |
| echo "more than 90 days ago" | |
| return | |
| fi | |
| # Extract just the date part (YYYY-MM-DD) | |
| local date_only=$(echo "$iso_date" | cut -d' ' -f1) | |
| # Platform-independent date formatting (macOS, Linux, Windows Git Bash) | |
| if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then | |
| # Windows Git Bash - uses GNU date | |
| date -d "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago" | |
| elif date --version &>/dev/null 2>&1; then | |
| # GNU date (Linux) | |
| date -d "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago" | |
| else | |
| # BSD date (macOS) | |
| date -jf "%Y-%m-%d" "$date_only" "+%d.%m.%Y" 2>/dev/null || echo "more than 90 days ago" | |
| fi | |
| } | |
| # List local branches, excluding the given branches (exact match) and any | |
| # branch checked out in a linked worktree (git refuses to delete those; | |
| # they get their own cleanup step later) | |
| cb_list_branches() { | |
| local top wt_branches branch skip excl | |
| top=$(git rev-parse --show-toplevel 2>/dev/null) | |
| wt_branches=$(git worktree list --porcelain 2>/dev/null | awk -v top="$top" ' | |
| /^worktree /{path=substr($0,10)} | |
| /^branch refs\/heads\//{if(path!=top) print substr($0,19)}') | |
| git for-each-ref refs/heads --format='%(refname:short)' 2>/dev/null | while read -r branch; do | |
| cb_is_protected "$branch" && continue | |
| skip="" | |
| for excl; do | |
| [ "$branch" = "$excl" ] && skip=1 && break | |
| done | |
| [ -n "$skip" ] && continue | |
| if [ -n "$wt_branches" ] && echo "$wt_branches" | grep -Fxq "$branch"; then | |
| continue | |
| fi | |
| echo "$branch" | |
| done | |
| } | |
| # Is <branch> merged into <target>? Detects all three merge styles: | |
| # merge commit / fast-forward (ancestry), rebase merge (patch-equivalence), | |
| # and squash merge (merging the branch into target would change nothing) | |
| cb_is_merged() { | |
| local branch=$1 target=$2 | |
| local cherry merged_tree | |
| if git merge-base --is-ancestor "$branch" "$target" 2>/dev/null; then | |
| return 0 | |
| fi | |
| cherry=$(git cherry "$target" "$branch" 2>/dev/null) | |
| if [ -n "$cherry" ] && ! echo "$cherry" | grep -q '^+'; then | |
| return 0 | |
| fi | |
| # merge-tree --write-tree needs git >= 2.38; silently skipped on older git | |
| if merged_tree=$(git merge-tree --write-tree "$target" "$branch" 2>/dev/null); then | |
| if [ -n "$merged_tree" ] && [ "$merged_tree" = "$(git rev-parse "$target^{tree}" 2>/dev/null)" ]; then | |
| return 0 | |
| fi | |
| fi | |
| return 1 | |
| } | |
| # Remote state of <branch>: | |
| # tracked - upstream configured and remote-tracking ref exists | |
| # gone - upstream configured but remote branch was deleted | |
| # untracked - no upstream, but origin/<branch> exists by name | |
| # none - no trace of a remote (may still have been squash/rebase-merged | |
| # and deleted; tracking info is simply absent) | |
| cb_remote_state() { | |
| local branch=$1 | |
| local upstream | |
| upstream=$(git for-each-ref --format='%(upstream:short)' "refs/heads/$branch" 2>/dev/null) | |
| if [ -n "$upstream" ]; then | |
| if git show-ref --verify --quiet "refs/remotes/$upstream"; then | |
| echo "tracked" | |
| else | |
| echo "gone" | |
| fi | |
| elif git show-ref --verify --quiet "refs/remotes/origin/$branch"; then | |
| echo "untracked" | |
| else | |
| echo "none" | |
| fi | |
| } | |
| # Safe delete with force fallback; surfaces git's reason on failure and | |
| # records force deletions (relevant for the deep-cleanup warning at the end). | |
| # When $2 is non-empty the script's own merge detection already confirmed the | |
| # branch as merged (incl. squash/rebase merges, which git branch -d cannot | |
| # see), so -D is used directly without the contradictory force prompt. | |
| delete_local_branch() { | |
| local branch=$1 | |
| local merged=${2:-} | |
| local del_err force_confirm | |
| if cb_is_protected "$branch"; then | |
| echo -e "${red}Branch $branch is protected and cannot be deleted${reset}" | |
| return 1 | |
| fi | |
| if git branch -d "$branch" 2>/dev/null; then | |
| echo "Deleted local branch $branch" | |
| return 0 | |
| fi | |
| if [ -n "$merged" ]; then | |
| if del_err=$(git branch -D "$branch" 2>&1); then | |
| echo "Deleted local branch $branch" | |
| return 0 | |
| fi | |
| echo -e "${red}Failed to delete local branch $branch${reset}" | |
| echo "$del_err" | sed -n '1p' | |
| return 1 | |
| fi | |
| echo -en "${red}Branch not fully merged. Force delete? (y/n)${reset} " | |
| read force_confirm | |
| if [[ $force_confirm =~ ^[Yy]$ ]]; then | |
| if del_err=$(git branch -D "$branch" 2>&1); then | |
| force_deleted_any=1 | |
| echo "Deleted local branch $branch" | |
| return 0 | |
| fi | |
| echo -e "${red}Failed to delete local branch $branch${reset}" | |
| echo "$del_err" | sed -n '1p' | |
| fi | |
| return 1 | |
| } | |
| # Delete the remote branch behind <remote_ref> (e.g. origin/feature/x); | |
| # derives remote name and branch name from the ref instead of assuming origin | |
| delete_remote_branch() { | |
| local branch=$1 | |
| local remote_ref=$2 | |
| local push_err | |
| if [ -z "$remote_ref" ]; then | |
| echo "No remote branch for $branch" | |
| return 1 | |
| fi | |
| if cb_is_protected "${remote_ref#*/}"; then | |
| echo -e "${red}Branch ${remote_ref#*/} is protected and cannot be deleted${reset}" | |
| return 1 | |
| fi | |
| if push_err=$(git push "${remote_ref%%/*}" --delete "${remote_ref#*/}" 2>&1); then | |
| echo "Deleted remote branch ${remote_ref#*/}" | |
| return 0 | |
| fi | |
| echo -e "${red}Failed to delete remote branch ${remote_ref#*/}${reset}" | |
| echo "$push_err" | sed -n '$p' | |
| return 1 | |
| } | |
| handle_worktree_deletion() { | |
| local choice=$1 | |
| local wt_path=$2 | |
| local wt_branch=$3 | |
| local merged=${4:-} | |
| local confirm_choice force_confirm wt_err | |
| if [[ $choice == "skip" ]]; then | |
| return | |
| fi | |
| read -p "you selected $choice. Are you sure? (y/n) " confirm_choice | |
| if [[ ! $confirm_choice =~ ^[Yy]$ ]]; then | |
| return | |
| fi | |
| if wt_err=$(git worktree remove "$wt_path" 2>&1); then | |
| echo "Removed worktree $wt_path" | |
| else | |
| echo "$wt_err" | sed -n '1p' | |
| echo -en "${red}Force remove worktree? (y/n)${reset} " | |
| read force_confirm | |
| if [[ ! $force_confirm =~ ^[Yy]$ ]]; then | |
| return | |
| fi | |
| if wt_err=$(git worktree remove --force "$wt_path" 2>&1); then | |
| echo "Removed worktree $wt_path" | |
| else | |
| echo -e "${red}Failed to remove worktree $wt_path${reset}" | |
| echo "$wt_err" | sed -n '1p' | |
| return | |
| fi | |
| fi | |
| if [[ $choice == "worktree + branch" && -n $wt_branch ]]; then | |
| delete_local_branch "$wt_branch" "$merged" | |
| fi | |
| } | |
| handle_branch_deletion() { | |
| local choice=$1 | |
| local branch=$2 | |
| local remote=$3 | |
| local current_branch=$4 | |
| local merged=${5:-} | |
| if [[ $choice == "skip" ]]; then | |
| return | |
| fi | |
| if cb_is_protected "$branch"; then | |
| echo -e "${red}Branch $branch is protected and cannot be deleted${reset}" | |
| return | |
| fi | |
| # Prevent deletion of current branch (if not in detached HEAD state) | |
| if [[ -n "$current_branch" && "$branch" == "$current_branch" ]]; then | |
| echo -e "${red}Cannot delete currently checked-out branch${reset}" | |
| return | |
| fi | |
| read -p "you selected $choice. Are you sure? (y/n) " confirm_choice | |
| if [[ ! $confirm_choice =~ ^[Yy]$ ]]; then | |
| return | |
| fi | |
| case $choice in | |
| "local branch") | |
| delete_local_branch "$branch" "$merged" | |
| ;; | |
| "remote branch") | |
| delete_remote_branch "$branch" "$remote" | |
| ;; | |
| both) | |
| local local_deleted=false | |
| local remote_deleted=false | |
| local push_err | |
| # Try safe delete first | |
| if git branch -d "$branch" 2>/dev/null; then | |
| local_deleted=true | |
| elif [ -n "$merged" ]; then | |
| # Merged per our detection (git -d can't see squash/rebase | |
| # merges) - force delete without the contradictory prompt | |
| if git branch -D "$branch" 2>/dev/null; then | |
| local_deleted=true | |
| fi | |
| else | |
| echo -en "${red}Branch not fully merged. Force delete? (y/n)${reset} " | |
| read force_confirm | |
| if [[ $force_confirm =~ ^[Yy]$ ]]; then | |
| if git branch -D "$branch" 2>/dev/null; then | |
| local_deleted=true | |
| force_deleted_any=1 | |
| fi | |
| fi | |
| fi | |
| if [ -n "$remote" ]; then | |
| if push_err=$(git push "${remote%%/*}" --delete "${remote#*/}" 2>&1); then | |
| remote_deleted=true | |
| else | |
| echo "$push_err" | sed -n '$p' | |
| fi | |
| fi | |
| if $local_deleted && $remote_deleted; then | |
| echo "Deleted both local and remote branches for $branch" | |
| elif $local_deleted; then | |
| echo "Deleted local branch $branch (no remote branch found)" | |
| elif $remote_deleted; then | |
| echo "Deleted remote branch $branch (failed to delete local)" | |
| else | |
| echo -e "${red}Failed to delete branch $branch${reset}" | |
| fi | |
| ;; | |
| *) | |
| echo "Invalid choice. Skipping $branch" | |
| ;; | |
| esac | |
| } | |
| select_option() { | |
| # little helpers for terminal print control and key input | |
| ESC=$( printf "\033") | |
| cursor_blink_on() { printf "$ESC[?25h"; } | |
| cursor_blink_off() { printf "$ESC[?25l"; } | |
| cursor_to() { printf "$ESC[$1;${2:-1}H"; } | |
| print_option() { printf " $1 "; } | |
| print_selected() { | |
| # ANSI Red color start | |
| local red="\033[31m" | |
| local blue="\033[34m" | |
| # ANSI Color reset (to return to default after printing) | |
| local reset="\033[0m" | |
| printf "${blue} βΈ ${red}$1${reset} " | |
| } | |
| get_cursor_row() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#*[}; } | |
| key_input() { read -s -n3 key 2>/dev/null >&2 | |
| if [[ $key = $ESC[A ]]; then echo up; fi | |
| if [[ $key = $ESC[B ]]; then echo down; fi | |
| if [[ $key = "" ]]; then echo enter; fi; } | |
| # initially print empty new lines (scroll down if at bottom of screen) | |
| for opt; do printf "\n"; done | |
| # determine current screen position for overwriting the options | |
| local lastrow=`get_cursor_row` | |
| local startrow=$(($lastrow - $#)) | |
| # ensure cursor and input echoing back on upon a ctrl+c during read -s | |
| trap "cursor_blink_on; stty echo; printf '\n'; exit" 2 | |
| cursor_blink_off | |
| local selected=0 | |
| while true; do | |
| # print options by overwriting the last lines | |
| local idx=0 | |
| for opt; do | |
| cursor_to $(($startrow + $idx)) | |
| if [ $idx -eq $selected ]; then | |
| print_selected "$opt" | |
| else | |
| print_option "$opt" | |
| fi | |
| ((idx++)) | |
| done | |
| # user key control | |
| case `key_input` in | |
| enter) break;; | |
| up) ((selected--)); | |
| if [ $selected -lt 0 ]; then selected=$(($# - 1)); fi;; | |
| down) ((selected++)); | |
| if [ $selected -ge $# ]; then selected=0; fi;; | |
| esac | |
| done | |
| # cursor position back to normal | |
| cursor_to $lastrow | |
| printf "\n" | |
| cursor_blink_on | |
| return $selected | |
| } | |
| draw_screen() { | |
| clear | |
| echo -e "\n⨠Starting branch cleanup for $formatted_name..." | |
| echo -e "π Found $local_branch_count local branches\n" | |
| for info in "${branch_info[@]}"; do | |
| echo -e "$info" | |
| done | |
| } | |
| # Test mode: stop after function definitions so a test harness can | |
| # source this file without triggering the interactive flow | |
| if [ -n "${CLEANUP_BRANCHES_TEST:-}" ]; then | |
| return 0 2>/dev/null || exit 0 | |
| fi | |
| # Main | |
| if [ "$#" -ne 1 ]; then | |
| echo "Usage: $0 <target_git_repo_directory>" | |
| exit 1 | |
| fi | |
| repo_path="$1" | |
| # Validate directory exists | |
| if [ ! -d "$repo_path" ]; then | |
| echo "β Directory does not exist: $repo_path" | |
| exit 1 | |
| fi | |
| cd "$repo_path" || exit 1 | |
| if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then | |
| echo "β Not a git repo, exiting." | |
| exit 1 | |
| fi | |
| clear | |
| formatted_name=$(format_repo_name "$repo_path") | |
| formatted_name=$(capitalize "$formatted_name") | |
| echo -e "\n⨠Starting branch cleanup for $formatted_name..." | |
| echo -e "π Fetching latest remote information..." | |
| # Fetch latest remote information to ensure accurate branch status | |
| if git fetch --prune 2>/dev/null; then | |
| echo -e "β Remote information updated" | |
| else | |
| echo -e "β οΈ Warning: Failed to fetch remote updates, continuing with local information" | |
| fi | |
| # Determine the default branch (master or main) | |
| default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') | |
| if [ -z "$default_branch" ]; then | |
| # Fallback: try to detect main or master | |
| if git show-ref --verify --quiet refs/heads/main; then | |
| default_branch="main" | |
| elif git show-ref --verify --quiet refs/heads/master; then | |
| default_branch="master" | |
| else | |
| echo "β Cannot determine default branch (neither main nor master found)" | |
| exit 1 | |
| fi | |
| fi | |
| # Determine the merge target for "merged" checks: prefer origin/{DEFAULT_BRANCHNAME} | |
| # (integration branch workflow), fall back to the default branch | |
| if git show-ref --verify --quiet refs/remotes/origin/{DEFAULT_BRANCHNAME}; then | |
| merge_target="origin/{DEFAULT_BRANCHNAME}" | |
| merge_target_local="{DEFAULT_BRANCHNAME}" | |
| elif git show-ref --verify --quiet "refs/remotes/origin/$default_branch"; then | |
| merge_target="origin/$default_branch" | |
| merge_target_local="$default_branch" | |
| else | |
| merge_target="$default_branch" | |
| merge_target_local="$default_branch" | |
| fi | |
| # Get current branch to prevent deletion (handle detached HEAD) | |
| current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) | |
| if [[ "$current_branch" == "HEAD" ]]; then | |
| # In detached HEAD state - no branch is currently checked out | |
| current_branch="" | |
| fi | |
| # Track whether any branch was force-deleted (warned about before deep cleanup) | |
| force_deleted_any="" | |
| # Get all local branches except the default/integration branches and | |
| # branches checked out in linked worktrees (handled in the worktree step) | |
| local_branches_array=() | |
| while IFS= read -r line; do | |
| [ -n "$line" ] && local_branches_array[${#local_branches_array[@]}]=$line | |
| done <<< "$(cb_list_branches "$default_branch" "$merge_target_local")" | |
| local_branch_count="${#local_branches_array[@]}" | |
| # No exit here: with zero branches the loop below is a no-op and the | |
| # worktree cleanup step still gets its chance | |
| if [ "$local_branch_count" -eq 0 ]; then | |
| echo -e "β No branches to clean up!" | |
| else | |
| echo -e "π Found $local_branch_count local branches\n" | |
| fi | |
| current_branch_num=0 | |
| # Iterate over the local branches | |
| for branch_name in "${local_branches_array[@]}"; do | |
| # Skip empty lines | |
| [ -z "$branch_name" ] && continue | |
| branch_info=() | |
| current_branch_num=$((current_branch_num + 1)) | |
| # Remote state and the remote ref usable for deletion (upstream if | |
| # configured, otherwise origin/<branch> when it exists by name) | |
| remote_state=$(cb_remote_state "$branch_name") | |
| remote="" | |
| case $remote_state in | |
| tracked|gone) | |
| remote=$(git for-each-ref --format='%(upstream:short)' "refs/heads/$branch_name" 2>/dev/null) | |
| ;; | |
| untracked) | |
| remote="origin/$branch_name" | |
| ;; | |
| esac | |
| # Calculate commits behind/ahead, both against the merge target | |
| behind="0" | |
| ahead="0" | |
| if git merge-base "$branch_name" "$merge_target" &>/dev/null; then | |
| behind=$(git rev-list --count "$branch_name..$merge_target" 2>/dev/null) | |
| behind=${behind:-0} # Fallback if empty | |
| ahead=$(git rev-list --count "$merge_target..$branch_name" 2>/dev/null) | |
| ahead=${ahead:-0} # Fallback if empty | |
| fi | |
| formatted_date=$(get_formatted_date "$branch_name") | |
| # Check if branch is already merged (detects merge commits, rebase | |
| # merges and squash merges) | |
| is_merged="" | |
| branch_merged="" | |
| if cb_is_merged "$branch_name" "$merge_target"; then | |
| branch_merged=1 | |
| is_merged="${green}β merged${bright_black} | " | |
| fi | |
| # Check remote status and provide detailed information | |
| case $remote_state in | |
| gone) | |
| # Upstream configured but remote branch was deleted | |
| if [ -n "$branch_merged" ]; then | |
| remote_status="${green}β remote merged & deleted${reset}" | |
| else | |
| remote_status="${red}β remote deleted unmerged${reset}" | |
| fi | |
| ;; | |
| tracked|untracked) | |
| remote_status="${bright_black}(has remote: $remote)${reset}" | |
| ;; | |
| *) | |
| # No trace of a remote (note: a squash/rebase-merged branch whose | |
| # remote is long gone can land here too if tracking was never set) | |
| remote_status="${bright_black}(local only)${reset}" | |
| ;; | |
| esac | |
| # Mark current branch (only if not in detached HEAD state) | |
| if [[ -n "$current_branch" && "$branch_name" == "$current_branch" ]]; then | |
| remote_status="${remote_status} ${blue}(current)${reset}" | |
| fi | |
| # Accumulate branch details | |
| branch_detail="Branch ($current_branch_num/$local_branch_count): ${green}$branch_name${reset}" | |
| action_detail="${is_merged}${bright_black}Created $formatted_date | behind by $behind | ahead by $ahead${reset}" | |
| status_detail="$remote_status" | |
| branch_info+=("$branch_detail\n$action_detail\n$status_detail") | |
| # Redraw the screen with updated information after each action | |
| draw_screen | |
| # Build options dynamically based on remote status | |
| case $remote_state in | |
| tracked|untracked) | |
| # Branch has an existing remote - show all options | |
| options=("skip" "local branch" "remote branch" "both") | |
| ;; | |
| *) | |
| # Remote gone or never existed - only local delete available | |
| options=("skip" "local branch") | |
| ;; | |
| esac | |
| echo "Delete?" | |
| select_option "${options[@]}" | |
| selected_option=$? | |
| selected_text=${options[$selected_option]} | |
| handle_branch_deletion "$selected_text" "$branch_name" "$remote" "$current_branch" "$branch_merged" | |
| done | |
| # Worktree cleanup: offer removal of linked worktrees (and their branches). | |
| # Branches checked out in worktrees were excluded from the loop above since | |
| # git refuses to delete them while the worktree exists. | |
| worktree_paths=() | |
| worktree_branches=() | |
| repo_toplevel=$(git rev-parse --show-toplevel 2>/dev/null) | |
| wt_path="" | |
| wt_branch="" | |
| while IFS= read -r line; do | |
| case $line in | |
| "worktree "*) wt_path=${line#worktree } ;; | |
| "branch refs/heads/"*) wt_branch=${line#branch refs/heads/} ;; | |
| "") | |
| if [ -n "$wt_path" ] && [ "$wt_path" != "$repo_toplevel" ]; then | |
| worktree_paths[${#worktree_paths[@]}]=$wt_path | |
| worktree_branches[${#worktree_branches[@]}]=$wt_branch | |
| fi | |
| wt_path="" | |
| wt_branch="" | |
| ;; | |
| esac | |
| done <<< "$(git worktree list --porcelain 2>/dev/null)" | |
| # Flush the last stanza (command substitution strips the trailing blank line) | |
| if [ -n "$wt_path" ] && [ "$wt_path" != "$repo_toplevel" ]; then | |
| worktree_paths[${#worktree_paths[@]}]=$wt_path | |
| worktree_branches[${#worktree_branches[@]}]=$wt_branch | |
| fi | |
| worktree_count="${#worktree_paths[@]}" | |
| if [ "$worktree_count" -gt 0 ]; then | |
| echo -e "\nπ³ Found $worktree_count linked worktrees\n" | |
| wt_idx=0 | |
| while [ $wt_idx -lt $worktree_count ]; do | |
| wt_path=${worktree_paths[$wt_idx]} | |
| wt_branch=${worktree_branches[$wt_idx]} | |
| wt_idx=$((wt_idx + 1)) | |
| wt_state="clean" | |
| if [ -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)" ]; then | |
| wt_state="${red}uncommitted changes${bright_black}" | |
| fi | |
| is_merged="" | |
| wt_merged="" | |
| if [ -n "$wt_branch" ] && cb_is_merged "$wt_branch" "$merge_target"; then | |
| wt_merged=1 | |
| is_merged="${green}β merged${bright_black} | " | |
| fi | |
| echo -e "Worktree ($wt_idx/$worktree_count): ${green}$wt_path${reset}" | |
| if [ -n "$wt_branch" ]; then | |
| echo -e "${is_merged}${bright_black}branch: $wt_branch | $wt_state${reset}" | |
| options=("skip" "worktree" "worktree + branch") | |
| else | |
| echo -e "${bright_black}(detached HEAD) | $wt_state${reset}" | |
| options=("skip" "worktree") | |
| fi | |
| echo "Delete?" | |
| select_option "${options[@]}" | |
| selected_option=$? | |
| selected_text=${options[$selected_option]} | |
| handle_worktree_deletion "$selected_text" "$wt_path" "$wt_branch" "$wt_merged" | |
| echo | |
| done | |
| fi | |
| read -p "πΏ Do you want to prune stale remote-tracking branches again? (y/n) " prune_choice | |
| if [[ $prune_choice =~ ^[Yy]$ ]]; then | |
| echo -e "\n${blue}Pruning stale remote-tracking branches...${reset}" | |
| if git remote prune origin; then | |
| echo -e "β Remote pruning complete!" | |
| else | |
| echo -e "${red}Failed to prune remote branches${reset}" | |
| fi | |
| fi | |
| read -p "π§Ή Do you want to clean up unreachable local objects? (y/n) " cleanup_choice | |
| if [[ $cleanup_choice =~ ^[Yy]$ ]]; then | |
| echo -e "\n${blue}Running local cleanup...${reset}" | |
| # First run gc which is safer and does most of the work | |
| echo "Running git gc (garbage collection)..." | |
| if git gc --auto; then | |
| echo -e "β Garbage collection complete!" | |
| else | |
| echo -e "${red}Warning: Garbage collection had issues${reset}" | |
| fi | |
| # Only run fsck + prune if user really wants deep cleanup | |
| if [ -n "$force_deleted_any" ]; then | |
| echo -e "${red}β You force-deleted unmerged branches this run β deep cleanup would erase their commits permanently (no recovery via reflog).${reset}" | |
| fi | |
| read -p "Run deep cleanup (fsck + prune)? This is aggressive. (y/n) " deep_choice | |
| if [[ $deep_choice =~ ^[Yy]$ ]]; then | |
| echo "Checking object database integrity..." | |
| git fsck --full 2>&1 | head -n 20 | |
| echo "Pruning unreachable objects..." | |
| if git prune --expire=now; then | |
| echo -e "β Deep cleanup complete!" | |
| else | |
| echo -e "${red}Warning: Prune had issues${reset}" | |
| fi | |
| fi | |
| else | |
| echo -e "${bright_black}Skipping local cleanup.${reset}" | |
| fi | |
| echo -e "\nβ Script completed successfully." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment