Skip to content

Instantly share code, notes, and snippets.

@gobinathm
Created February 20, 2026 21:17
Show Gist options
  • Select an option

  • Save gobinathm/6f0aaad518689850ed7ed1037c86bf63 to your computer and use it in GitHub Desktop.

Select an option

Save gobinathm/6f0aaad518689850ed7ed1037c86bf63 to your computer and use it in GitHub Desktop.
macOS zsh smart rename script with prefix suffix recursive dry-run undo numbering collision protection progress bar
#!/bin/zsh
# ----------------------------
# Smart Rename (MacOS Only)
# ----------------------------
# Features:
# - Prefix/Suffix
# - Recursive
# - Dry-run
# - Undo
# - Numbering
# - File-type filter
# - Collision protection
# - Progress bar
# - Interactive mode
# macOS zsh compatible
recursive=false
dryrun=false
numbering=false
undo=false
interactive=false
types=""
jobs=$(sysctl -n hw.ncpu)
counter=1
renamed=0
skipped=0
conflicts=0
total=0
current=0
logfile=".rename_log_$(date +%Y%m%d_%H%M%S).log"
# -------- Parse Flags --------
while [[ "$1" != "" ]]; do
case $1 in
-r | --recursive ) recursive=true ;;
-d | --dry-run ) dryrun=true ;;
-n | --number ) numbering=true ;;
-u | --undo ) undo=true ;;
-i | --interactive ) interactive=true ;;
-t | --types ) shift; types="$1" ;;
-j | --jobs ) shift; jobs="$1" ;;
* ) break ;;
esac
shift
done
# -------- Undo Mode --------
if $undo; then
latest_log=$(ls -t .rename_log_* 2>/dev/null | head -n 1)
[[ -z "$latest_log" ]] && echo "No rename log found." && exit 1
tac "$latest_log" | while IFS="|" read new old; do
[[ -f "$new" ]] && mv "$new" "$old"
done
echo "Undo complete."
exit 0
fi
echo -n "Enter prefix (leave empty for none): "
read prefix
echo -n "Enter suffix (leave empty for none): "
read suffix
[[ -n "$prefix" && "$prefix" != *_ ]] && prefix="${prefix}_"
[[ -n "$suffix" && "$suffix" != _* ]] && suffix="_${suffix}"
# -------- Collect Files --------
if $recursive; then
files=($(find . -type f))
else
files=(*(.))
fi
total=${#files[@]}
# -------- Type Filter --------
type_match() {
[[ -z "$types" ]] && return 0
local ext="${1##*.}"
IFS=',' read -A arr <<< "$types"
for t in "${arr[@]}"; do
[[ "$ext:l" == "$t:l" ]] && return 0
done
return 1
}
# -------- Safe Name --------
safe_name() {
local target="$1"
local base="${target%.*}"
local ext=""
[[ "$target" == *.* ]] && ext=".${target##*.}"
local i=1
while [[ -e "$target" ]]; do
target="${base}_$i$ext"
((i++))
((conflicts++))
done
echo "$target"
}
# -------- Progress Bar --------
progress() {
percent=$(( current * 100 / total ))
printf "\rProgress: [%3d%%] (%d/%d)" $percent $current $total
}
# -------- Process --------
for filepath in "${files[@]}"; do
((current++))
progress
[[ -f "$filepath" ]] || continue
type_match "$filepath" || { ((skipped++)); continue; }
dir="${filepath:h}"
filename="${filepath:t}"
if [[ "$filename" == *.* ]]; then
ext=".${filename##*.}"
name="${filename%.*}"
else
ext=""
name="$filename"
fi
[[ "$filename" == ${prefix}*${suffix}${ext} ]] && { ((skipped++)); continue; }
name="${name// /_}"
name="${name//[^a-zA-Z0-9_-]/-}"
name="${name//-/_-_}"
while [[ "$name" == *__* ]]; do
name="${name//__/_}"
done
name="${name##_}"
name="${name%%_}"
if $numbering; then
formatted=$(printf "%03d" $counter)
name="${formatted}_${name}"
((counter++))
fi
newname="${prefix}${name}${suffix}"
final="$dir/$newname$ext"
final=$(safe_name "$final")
[[ "$filepath" == "$final" ]] && { ((skipped++)); continue; }
if $interactive; then
echo
echo "Rename:"
echo " $filepath"
echo "→ $final"
read -q "confirm?Proceed? (y/n): "
echo
[[ "$confirm" != "y" ]] && { ((skipped++)); continue; }
fi
if $dryrun; then
echo
echo "DRYRUN: $filepath → $final"
else
mv "$filepath" "$final"
echo "$final|$filepath" >> "$logfile"
((renamed++))
fi
done
echo
echo "---------------------------------"
echo "Summary:"
echo " Total files scanned : $total"
echo " Renamed : $renamed"
echo " Skipped : $skipped"
echo " Name conflicts : $conflicts"
[[ $dryrun == false ]] && echo " Log file : $logfile"
echo "Done."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment