Skip to content

Instantly share code, notes, and snippets.

@prateekmedia
Last active March 22, 2026 11:51
Show Gist options
  • Select an option

  • Save prateekmedia/b66d533071a97e3e25497658813d9271 to your computer and use it in GitHub Desktop.

Select an option

Save prateekmedia/b66d533071a97e3e25497658813d9271 to your computer and use it in GitHub Desktop.
Cleanup build artifacts across worktrees
#!/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