Created
February 20, 2026 21:17
-
-
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
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/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