Last active
March 22, 2026 11:51
-
-
Save prateekmedia/b66d533071a97e3e25497658813d9271 to your computer and use it in GitHub Desktop.
Cleanup build artifacts across worktrees
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
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| scan_dirs=(target build node_modules .dart_tool) | |
| usage() { | |
| local scan_dir | |
| cat <<'EOF' | |
| Usage: clean-git-worktrees.sh [--dry-run] [PATH] | |
| Clean gitignored build artifacts across every worktree that belongs to the same git repo | |
| as PATH. PATH can point at the main checkout, any linked worktree, or any | |
| subdirectory inside one of them. Defaults to the current directory. | |
| Removed directories: | |
| EOF | |
| for scan_dir in "${scan_dirs[@]}"; do | |
| printf ' - %s\n' "$scan_dir" | |
| done | |
| cat <<'EOF' | |
| Only directories whose names match the list above and are ignored by git are | |
| removed. This lets the script clean Rust, Kotlin, Swift, Flutter, Tauri, | |
| Electron, web, and other generated build output directories while avoiding | |
| tracked directories with the same names. | |
| EOF | |
| } | |
| count_label() { | |
| local count="$1" | |
| local singular="$2" | |
| local plural="${3:-${singular}s}" | |
| if [ "$count" -eq 1 ]; then | |
| printf '%d %s' "$count" "$singular" | |
| else | |
| printf '%d %s' "$count" "$plural" | |
| fi | |
| } | |
| dry_run=0 | |
| repo_path="." | |
| trash_available=0 | |
| removal_mode="remove" | |
| missing_count=0 | |
| while [ "$#" -gt 0 ]; do | |
| case "$1" in | |
| -n|--dry-run) | |
| dry_run=1 | |
| ;; | |
| -h|--help) | |
| usage | |
| exit 0 | |
| ;; | |
| --) | |
| shift | |
| break | |
| ;; | |
| -*) | |
| printf 'Unknown option: %s\n\n' "$1" >&2 | |
| usage >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| if [ "$repo_path" != "." ]; then | |
| printf 'Only one PATH argument is supported.\n\n' >&2 | |
| usage >&2 | |
| exit 1 | |
| fi | |
| repo_path="$1" | |
| ;; | |
| esac | |
| shift | |
| done | |
| if [ "$#" -gt 0 ]; then | |
| if [ "$repo_path" != "." ]; then | |
| printf 'Only one PATH argument is supported.\n\n' >&2 | |
| usage >&2 | |
| exit 1 | |
| fi | |
| repo_path="$1" | |
| fi | |
| if ! git -C "$repo_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| printf 'Not inside a git worktree: %s\n' "$repo_path" >&2 | |
| exit 1 | |
| fi | |
| if command -v trash >/dev/null 2>&1; then | |
| trash_available=1 | |
| fi | |
| worktrees_file="$(mktemp)" | |
| dirs_file="$(mktemp)" | |
| cleanup_tmp() { | |
| rm -f "$worktrees_file" "$dirs_file" | |
| } | |
| trap cleanup_tmp EXIT | |
| refresh_worktree_list() { | |
| git -C "$repo_path" worktree list --porcelain | | |
| while IFS= read -r line; do | |
| case "$line" in | |
| worktree\ *) | |
| printf '%s\n' "${line#worktree }" | |
| ;; | |
| esac | |
| done > "$worktrees_file" | |
| } | |
| prune_missing_worktrees() { | |
| refresh_worktree_list | |
| missing_count=0 | |
| while IFS= read -r worktree_path; do | |
| [ -n "$worktree_path" ] || continue | |
| if [ -d "$worktree_path" ]; then | |
| continue | |
| fi | |
| missing_count=$((missing_count + 1)) | |
| printf 'Missing worktree, would prune entry: %s\n' "$worktree_path" | |
| done < "$worktrees_file" | |
| if [ "$missing_count" -eq 0 ]; then | |
| return 0 | |
| fi | |
| } | |
| collect_dir() { | |
| local dir_path="$1" | |
| if [ ! -d "$dir_path" ]; then | |
| return 1 | |
| fi | |
| printf 'Would clean %s\n' "$dir_path" | |
| printf '%s\n' "$dir_path" >> "$dirs_file" | |
| return 0 | |
| } | |
| remove_dir() { | |
| local dir_path="$1" | |
| local attempt | |
| local max_attempts=3 | |
| local remove_cmd | |
| local success_label | |
| local retry_label | |
| local failure_label | |
| if [ ! -d "$dir_path" ]; then | |
| return 1 | |
| fi | |
| if [ "$removal_mode" = "trash" ]; then | |
| remove_cmd=(trash "$dir_path") | |
| success_label='Trashed' | |
| retry_label='Retrying trash move for' | |
| failure_label='Failed to move %s to Trash after %d attempts.\n' | |
| else | |
| remove_cmd=(rm -rf -- "$dir_path") | |
| success_label='Removed' | |
| retry_label='Retrying removal of' | |
| failure_label='Failed to remove %s after %d attempts.\n' | |
| fi | |
| for attempt in $(seq 1 "$max_attempts"); do | |
| if "${remove_cmd[@]}"; then | |
| : | |
| fi | |
| if [ ! -e "$dir_path" ]; then | |
| printf '%s %s\n' "$success_label" "$dir_path" | |
| return 0 | |
| fi | |
| if [ "$attempt" -lt "$max_attempts" ]; then | |
| printf '%s %s (%d/%d)\n' \ | |
| "$retry_label" \ | |
| "$dir_path" \ | |
| "$((attempt + 1))" \ | |
| "$max_attempts" >&2 | |
| sleep 1 | |
| fi | |
| done | |
| printf "$failure_label" \ | |
| "$dir_path" \ | |
| "$max_attempts" >&2 | |
| return 2 | |
| } | |
| confirm_removal() { | |
| local total_dirs="$1" | |
| local prompt | |
| local response | |
| if [ "$total_dirs" -gt 0 ]; then | |
| prompt="Do you want to clean $(count_label "$total_dirs" directory directories)" | |
| else | |
| prompt='Do you want to prune' | |
| fi | |
| if [ "$missing_count" -gt 0 ] && [ "$total_dirs" -gt 0 ]; then | |
| prompt="${prompt} and prune $(count_label "$missing_count" 'stale worktree entry' 'stale worktree entries')" | |
| elif [ "$missing_count" -gt 0 ] && [ "$total_dirs" -eq 0 ]; then | |
| prompt="${prompt} $(count_label "$missing_count" 'stale worktree entry' 'stale worktree entries')" | |
| fi | |
| if [ "$total_dirs" -gt 0 ] && [ "$trash_available" -eq 1 ]; then | |
| prompt="${prompt}? [y=remove/t=trash/N] " | |
| else | |
| prompt="${prompt}? [y/N] " | |
| fi | |
| while true; do | |
| if [ -t 0 ]; then | |
| printf '%s' "$prompt" >&2 | |
| IFS= read -r response | |
| elif [ -r /dev/tty ]; then | |
| printf '%s' "$prompt" > /dev/tty | |
| IFS= read -r response < /dev/tty | |
| else | |
| printf 'Confirmation required, but no interactive terminal is available.\n' >&2 | |
| return 1 | |
| fi | |
| case "$response" in | |
| y|Y|yes|YES|Yes) | |
| removal_mode='remove' | |
| return 0 | |
| ;; | |
| t|T|trash|TRASH|Trash) | |
| if [ "$trash_available" -eq 1 ] && [ "$total_dirs" -gt 0 ]; then | |
| removal_mode='trash' | |
| return 0 | |
| fi | |
| printf 'Trash is not available here.\n' >&2 | |
| ;; | |
| ''|n|N|no|NO|No) | |
| return 1 | |
| ;; | |
| *) | |
| if [ "$total_dirs" -gt 0 ] && [ "$trash_available" -eq 1 ]; then | |
| printf 'Please answer y, t, or n.\n' >&2 | |
| else | |
| printf 'Please answer y or n.\n' >&2 | |
| fi | |
| ;; | |
| esac | |
| done | |
| } | |
| find_candidate_dirs() { | |
| local worktree_path="$1" | |
| local scan_dir | |
| if command -v fd >/dev/null 2>&1; then | |
| for scan_dir in "${scan_dirs[@]}"; do | |
| fd \ | |
| --hidden \ | |
| --no-ignore \ | |
| --type d \ | |
| --glob \ | |
| --prune \ | |
| --exclude .git \ | |
| "$scan_dir" \ | |
| "$worktree_path" | |
| done | |
| return | |
| fi | |
| for scan_dir in "${scan_dirs[@]}"; do | |
| find "$worktree_path" \ | |
| -name .git -prune -o \ | |
| -type d -name "$scan_dir" -print -prune | |
| done | |
| } | |
| is_cleanup_dir() { | |
| local worktree_path="$1" | |
| local dir_path="$2" | |
| local relative_path | |
| local ignore_output | |
| local ignore_rule | |
| local ignore_pattern | |
| local scan_dir | |
| relative_path="${dir_path#$worktree_path/}" | |
| ignore_output="$(git -C "$worktree_path" check-ignore -v -- "$relative_path" 2>/dev/null || true)" | |
| [ -n "$ignore_output" ] || return 1 | |
| ignore_rule="${ignore_output%%$'\t'*}" | |
| ignore_pattern="${ignore_rule##*:}" | |
| for scan_dir in "${scan_dirs[@]}"; do | |
| if [[ "$ignore_pattern" == *"$scan_dir"* ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| filter_top_level_dirs() { | |
| local candidate_dir | |
| local kept_dir | |
| local skip_candidate | |
| local -a kept_dirs=() | |
| while IFS= read -r candidate_dir; do | |
| [ -n "$candidate_dir" ] || continue | |
| candidate_dir="${candidate_dir%/}" | |
| skip_candidate=0 | |
| if [ "${#kept_dirs[@]}" -gt 0 ]; then | |
| for kept_dir in "${kept_dirs[@]}"; do | |
| case "$candidate_dir" in | |
| "$kept_dir"/*) | |
| skip_candidate=1 | |
| break | |
| ;; | |
| esac | |
| done | |
| fi | |
| if [ "$skip_candidate" -eq 0 ]; then | |
| printf '%s\n' "$candidate_dir" | |
| kept_dirs+=("$candidate_dir") | |
| fi | |
| done | |
| } | |
| discover_cleanup_dirs() { | |
| local worktree_path="$1" | |
| local candidate_dir | |
| while IFS= read -r candidate_dir; do | |
| [ -n "$candidate_dir" ] || continue | |
| if is_cleanup_dir "$worktree_path" "$candidate_dir"; then | |
| printf '%s\n' "$candidate_dir" | |
| fi | |
| done < <(find_candidate_dirs "$worktree_path" | sort -u | filter_top_level_dirs) | |
| } | |
| processed=0 | |
| preview_total=0 | |
| removed_total=0 | |
| prune_missing_worktrees | |
| refresh_worktree_list | |
| : > "$dirs_file" | |
| while IFS= read -r worktree_path; do | |
| local_found=0 | |
| [ -n "$worktree_path" ] || continue | |
| if [ ! -d "$worktree_path" ]; then | |
| continue | |
| fi | |
| processed=$((processed + 1)) | |
| printf '\n[%d] %s\n' "$processed" "$worktree_path" | |
| while IFS= read -r dir_path; do | |
| [ -n "$dir_path" ] || continue | |
| if collect_dir "$dir_path"; then | |
| local_found=$((local_found + 1)) | |
| fi | |
| done < <(discover_cleanup_dirs "$worktree_path") | |
| if [ "$local_found" -eq 0 ]; then | |
| printf 'No gitignored build artifacts found.\n' | |
| else | |
| printf 'Found %s in %s\n' "$(count_label "$local_found" directory directories)" "$worktree_path" | |
| fi | |
| preview_total=$((preview_total + local_found)) | |
| done < "$worktrees_file" | |
| if [ "$dry_run" -eq 0 ]; then | |
| if [ "$preview_total" -eq 0 ] && [ "$missing_count" -eq 0 ]; then | |
| printf '\nNothing to do.\n' | |
| exit 0 | |
| fi | |
| printf '\n' | |
| if ! confirm_removal "$preview_total"; then | |
| printf 'Aborted.\n' | |
| exit 0 | |
| fi | |
| if [ "$missing_count" -gt 0 ]; then | |
| git -C "$repo_path" worktree prune >/dev/null | |
| printf 'Pruned %s.\n' "$(count_label "$missing_count" 'stale worktree entry' 'stale worktree entries')" | |
| fi | |
| while IFS= read -r dir_path; do | |
| [ -n "$dir_path" ] || continue | |
| if remove_dir "$dir_path"; then | |
| removed_total=$((removed_total + 1)) | |
| fi | |
| done < "$dirs_file" | |
| else | |
| removed_total="$preview_total" | |
| fi | |
| printf '\nProcessed %s. %s %s.\n' \ | |
| "$(count_label "$processed" worktree)" \ | |
| "$( | |
| if [ "$dry_run" -eq 1 ]; then | |
| printf 'Would clean' | |
| else | |
| if [ "$removal_mode" = "trash" ]; then | |
| printf 'Trashed' | |
| else | |
| printf 'Removed' | |
| fi | |
| fi | |
| )" \ | |
| "$(count_label "$removed_total" directory directories)" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment