Created
March 3, 2026 12:29
-
-
Save svandragt/a83803cbb6ef29f9df6b45b1eb28d63f to your computer and use it in GitHub Desktop.
Detects and resolves case-insensitive filename collisions in a directory tree.
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 | |
| # fix-case-collisions.sh | |
| # Detects and resolves case-insensitive filename collisions in a directory tree. | |
| # | |
| # Usage: | |
| # ./fix-case-collisions.sh [--apply] <root-directory> | |
| # | |
| # Dry-run by default. Pass --apply to perform actual renames. | |
| set -euo pipefail | |
| # ───────────────────────────────────────────── | |
| # BASH GOTCHAS documented here for maintainers | |
| # ───────────────────────────────────────────── | |
| # | |
| # GOTCHA #1 — pipe subshells: | |
| # "find ... | while read" runs the while body in a subshell. | |
| # Writes to variables inside would be lost to the parent shell. | |
| # Fix: use process substitution "while read; done < <(find ...)" instead. | |
| # | |
| # GOTCHA #2 — set -e + arithmetic: | |
| # "(( n++ ))" returns exit code 1 when n=0 (expression = 0 = false). | |
| # Under set -e this silently kills the script. | |
| # Fix: always use "n=$(( n + 1 ))" for increments. | |
| # | |
| # GOTCHA #3 — mv into existing directory: | |
| # "mv foo/ bar/" where bar/ already exists moves foo/ *inside* bar/, | |
| # producing bar/foo/ instead of renaming foo/ to bar/. | |
| # Fix: if the desired target is an existing directory, treat slot as occupied. | |
| # | |
| # PERF NOTE — single find pass for scan was attempted but sort does not | |
| # guarantee siblings are contiguous (subdirs interleave). Per-directory | |
| # find with -maxdepth 1 is retained; subshell forks eliminated elsewhere. | |
| # ───────────────────────────────────────────── | |
| # Utilities | |
| # ───────────────────────────────────────────── | |
| usage() { | |
| echo "Usage: $0 [--apply] <root-directory>" | |
| echo "" | |
| echo " --apply Perform renames (default: dry-run)" | |
| echo "" | |
| exit 1 | |
| } | |
| # Write to stdout and log file. | |
| # Avoids "echo | tee" pipe subshell by using tee with process substitution. | |
| log() { | |
| echo "$1" | tee -a "$LOG_FILE" | |
| } | |
| # Pure-bash lowercase via parameter expansion — no fork, no subshell. | |
| # Mutates the named variable in place. Requires bash 4+ (Ubuntu ships bash 5). | |
| # Usage: lowercase_name varname | |
| lowercase_name() { | |
| local -n _ln_ref="$1" | |
| _ln_ref="${_ln_ref,,}" | |
| } | |
| # Split "stem.ext" into parts; sets $stem and $ext in caller scope. | |
| split_stem_ext() { | |
| local name="$1" | |
| if [[ "$name" == *.* ]]; then | |
| stem="${name%.*}" | |
| ext=".${name##*.}" | |
| else | |
| stem="$name" | |
| ext="" | |
| fi | |
| } | |
| # Find the next free name for a given entry being renamed. | |
| # Starts at desired (lowercased) name; increments to -2, -3 ... if occupied. | |
| # Args: dir, desired_name, src_name, occupied (nameref to associative array) | |
| # Returns result via stdout (subshell) — kept simple to avoid nameref fragility. | |
| next_free_name() { | |
| local dir="$1" | |
| local desired="$2" | |
| local src_name="$3" | |
| local -n _nfn_occupied="$4" | |
| local stem ext | |
| split_stem_ext "$desired" | |
| local counter=2 | |
| local candidate="$desired" | |
| local key="$candidate" | |
| lowercase_name key | |
| while true; do | |
| local target_path="${dir}/${candidate}" | |
| if [[ ! -v "_nfn_occupied[$key]" ]] \ | |
| && { [[ ! -e "$target_path" ]] || [[ "$target_path" == "${dir}/${src_name}" ]]; }; then | |
| echo "$candidate" | |
| return | |
| fi | |
| candidate="${stem}-${counter}${ext}" | |
| key="$candidate" | |
| lowercase_name key | |
| counter=$(( counter + 1 )) | |
| done | |
| } | |
| # ───────────────────────────────────────────── | |
| # Scanning | |
| # ───────────────────────────────────────────── | |
| # Scan one directory for case collisions among its direct children. | |
| # Uses pure-bash ${entry##*/} to strip path — no basename fork. | |
| # For each collision group found, writes to COLLISION_FILE: | |
| # SIBLINGS:<dir>§<all-sibling-names-pipe-separated> | |
| # COLLISION:<dir>§<colliding-names-pipe-separated> | |
| scan_directory() { | |
| local dir="$1" | |
| declare -A seen_lower=() | |
| declare -A groups=() | |
| local all_names=() | |
| while IFS= read -r -d '' entry; do | |
| # Pure-bash path stripping — no basename subprocess (perf: -fork) | |
| local name="${entry##*/}" | |
| [[ "$entry" == "$LOG_FILE" ]] && continue | |
| if [[ -L "$entry" ]]; then | |
| echo "SKIP_SYMLINK:${entry}" >> "$COLLISION_FILE" | |
| continue | |
| fi | |
| all_names+=("$name") | |
| local lower="$name" | |
| lowercase_name lower # pure-bash, no fork | |
| if [[ -v "seen_lower[$lower]" ]]; then | |
| groups["$lower"]="${groups[$lower]}|${name}" | |
| else | |
| seen_lower["$lower"]=1 | |
| groups["$lower"]="$name" | |
| fi | |
| done < <(find "$dir" -maxdepth 1 -mindepth 1 -print0 2>/dev/null) | |
| local has_collision=false | |
| local lower | |
| for lower in "${!groups[@]}"; do | |
| [[ "${groups[$lower]}" == *"|"* ]] && { has_collision=true; break; } | |
| done | |
| if $has_collision; then | |
| local siblings | |
| siblings="$(printf '%s|' "${all_names[@]}")" | |
| siblings="${siblings%|}" | |
| echo "SIBLINGS:${dir}§${siblings}" >> "$COLLISION_FILE" | |
| for lower in "${!groups[@]}"; do | |
| local val="${groups[$lower]}" | |
| [[ "$val" == *"|"* ]] && echo "COLLISION:${dir}§${val}" >> "$COLLISION_FILE" | |
| done | |
| fi | |
| } | |
| # Walk the tree bottom-up, scanning each directory. | |
| scan_tree() { | |
| while IFS= read -r dir; do | |
| scan_directory "$dir" | |
| done < <(find "$ROOT_DIR" -depth -type d) | |
| } | |
| # ───────────────────────────────────────────── | |
| # Reporting | |
| # ───────────────────────────────────────────── | |
| print_header() { | |
| { | |
| echo "================================================" | |
| echo " fix-case-collisions | $(date)" | |
| if $DRY_RUN; then | |
| echo " Mode: DRY-RUN (no changes will be made)" | |
| else | |
| echo " Mode: APPLY" | |
| fi | |
| echo " Root: $ROOT_DIR" | |
| echo "================================================" | |
| echo "" | |
| } | tee -a "$LOG_FILE" | |
| } | |
| report_symlinks() { | |
| local count | |
| count="$(grep -c '^SKIP_SYMLINK:' "$COLLISION_FILE" || true)" | |
| [[ "$count" -eq 0 ]] && return | |
| log "Skipped ${count} symlink(s):" | |
| while IFS= read -r line; do | |
| [[ "$line" != SKIP_SYMLINK:* ]] && continue | |
| log " ${line#SKIP_SYMLINK:}" | |
| done < "$COLLISION_FILE" | |
| log "" | |
| } | |
| report_collisions() { | |
| log "Found ${COLLISION_COUNT} collision group(s):" | |
| log "" | |
| local group_num=0 | |
| while IFS= read -r line; do | |
| [[ "$line" != COLLISION:* ]] && continue | |
| group_num=$(( group_num + 1 )) | |
| local payload dir names_raw | |
| payload="${line#COLLISION:}" | |
| dir="${payload%%§*}" | |
| names_raw="${payload#*§}" | |
| log " Group ${group_num}: ${dir}" | |
| IFS='|' read -ra names <<< "$names_raw" | |
| local name | |
| for name in "${names[@]}"; do | |
| log " - ${dir}/${name}" | |
| done | |
| log "" | |
| done < "$COLLISION_FILE" | |
| } | |
| # ───────────────────────────────────────────── | |
| # Confirmation prompt | |
| # ───────────────────────────────────────────── | |
| confirm_proceed() { | |
| if $DRY_RUN; then | |
| echo "─────────────────────────────────────────────" | |
| echo " DRY-RUN — no files will be changed." | |
| echo " Re-run with --apply to perform renames." | |
| echo "─────────────────────────────────────────────" | |
| echo "" | |
| fi | |
| read -r -p "Proceed with resolving all ${COLLISION_COUNT} group(s)? [y/N] " confirm | |
| echo "" | |
| [[ "$confirm" =~ ^[Yy]$ ]] | |
| } | |
| # ───────────────────────────────────────────── | |
| # Resolution | |
| # ───────────────────────────────────────────── | |
| # Resolve a single collision group. | |
| # Args: dir, names_raw (pipe-separated colliding names), siblings_raw (all dir members) | |
| # Reads/writes rename_count and skip_count from caller scope. | |
| resolve_group() { | |
| local dir="$1" | |
| local names_raw="$2" | |
| local siblings_raw="$3" | |
| IFS='|' read -ra names <<< "$names_raw" | |
| # Sort for determinism. Winner: first already-lowercase member, else sorted[0]. | |
| IFS=$'\n' sorted=($(printf '%s\n' "${names[@]}" | sort)) | |
| unset IFS | |
| local winner="" name | |
| for name in "${sorted[@]}"; do | |
| local lc="$name" | |
| lowercase_name lc | |
| if [[ "$name" == "$lc" ]]; then | |
| winner="$name" | |
| break | |
| fi | |
| done | |
| [[ -z "$winner" ]] && winner="${sorted[0]}" | |
| # Build occupied map from pre-scanned sibling list — no disk access. | |
| declare -A occupied=() | |
| declare -A collision_members=() | |
| for name in "${names[@]}"; do | |
| collision_members["$name"]=1 | |
| done | |
| IFS='|' read -ra siblings <<< "$siblings_raw" | |
| local sibling | |
| for sibling in "${siblings[@]}"; do | |
| [[ -v "collision_members[$sibling]" ]] && continue | |
| local lc="$sibling" | |
| lowercase_name lc | |
| occupied["$lc"]=1 | |
| done | |
| # Process winner first so its slot is reserved before other members run | |
| local processing_order=("$winner") | |
| for name in "${sorted[@]}"; do | |
| [[ "$name" == "$winner" ]] && continue | |
| processing_order+=("$name") | |
| done | |
| for name in "${processing_order[@]}"; do | |
| local lower="$name" | |
| lowercase_name lower | |
| local desired | |
| desired="$(next_free_name "$dir" "$lower" "$name" occupied)" | |
| local lc_desired="$desired" | |
| lowercase_name lc_desired | |
| occupied["$lc_desired"]=1 | |
| local src="${dir}/${name}" | |
| local dst="${dir}/${desired}" | |
| if [[ "$src" == "$dst" ]]; then | |
| log "KEEP: ${src}" | |
| skip_count=$(( skip_count + 1 )) | |
| continue | |
| fi | |
| rename_count=$(( rename_count + 1 )) | |
| if $DRY_RUN; then | |
| log "[DRY-RUN] RENAME: ${src}" | |
| log " -> ${dst}" | |
| else | |
| if mv -- "$src" "$dst"; then | |
| log "RENAMED: ${src}" | |
| log " -> ${dst}" | |
| else | |
| log "ERROR: Failed to rename ${src} -> ${dst}" | |
| rename_count=$(( rename_count - 1 )) | |
| fi | |
| fi | |
| done | |
| unset occupied collision_members | |
| } | |
| # Iterate all collision groups and resolve each. | |
| # Single pass through collision file: accumulate siblings map then resolve inline. | |
| resolve_all() { | |
| rename_count=0 | |
| skip_count=0 | |
| declare -A siblings_map=() | |
| while IFS= read -r line; do | |
| if [[ "$line" == SIBLINGS:* ]]; then | |
| local payload="${line#SIBLINGS:}" | |
| siblings_map["${payload%%§*}"]="${payload#*§}" | |
| elif [[ "$line" == COLLISION:* ]]; then | |
| local payload="${line#COLLISION:}" | |
| local dir="${payload%%§*}" | |
| resolve_group "$dir" "${payload#*§}" "${siblings_map[$dir]:-}" | |
| fi | |
| done < "$COLLISION_FILE" | |
| unset siblings_map | |
| } | |
| # ───────────────────────────────────────────── | |
| # Summary | |
| # ───────────────────────────────────────────── | |
| print_summary() { | |
| echo "" | |
| if $DRY_RUN; then | |
| log "Dry-run complete. ${rename_count} rename(s) would be performed, ${skip_count} already correct." | |
| else | |
| log "Done. ${rename_count} rename(s) performed, ${skip_count} already correct." | |
| fi | |
| log "Log: $LOG_FILE" | |
| echo "" | |
| } | |
| # ───────────────────────────────────────────── | |
| # Argument parsing | |
| # ───────────────────────────────────────────── | |
| DRY_RUN=true | |
| ROOT_DIR="" | |
| for arg in "$@"; do | |
| case "$arg" in | |
| --apply) DRY_RUN=false ;; | |
| --help|-h) usage ;; | |
| *) | |
| if [[ -z "$ROOT_DIR" ]]; then | |
| ROOT_DIR="$arg" | |
| else | |
| echo "Error: unexpected argument '$arg'"; usage | |
| fi | |
| ;; | |
| esac | |
| done | |
| [[ -z "$ROOT_DIR" ]] && usage | |
| [[ ! -d "$ROOT_DIR" ]] && { echo "Error: '$ROOT_DIR' is not a directory."; exit 1; } | |
| ROOT_DIR="$(realpath "$ROOT_DIR")" | |
| LOG_FILE="${ROOT_DIR}/case-fix-$(date +%Y-%m-%d).log" | |
| COLLISION_FILE="$(mktemp)" | |
| trap 'rm -f "$COLLISION_FILE"' EXIT | |
| # ───────────────────────────────────────────── | |
| # Main | |
| # ───────────────────────────────────────────── | |
| echo "" | |
| echo "Scanning: $ROOT_DIR ..." | |
| echo "" | |
| scan_tree | |
| COLLISION_COUNT="$(grep -c '^COLLISION:' "$COLLISION_FILE" || true)" | |
| print_header | |
| report_symlinks | |
| if [[ "$COLLISION_COUNT" -eq 0 ]]; then | |
| log "No case collisions found. Nothing to do." | |
| log "Log: $LOG_FILE" | |
| echo "" | |
| exit 0 | |
| fi | |
| report_collisions | |
| if ! confirm_proceed; then | |
| log "Aborted by user." | |
| echo "Aborted." | |
| exit 0 | |
| fi | |
| rename_count=0 | |
| skip_count=0 | |
| resolve_all | |
| print_summary |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment