Skip to content

Instantly share code, notes, and snippets.

@svandragt
Created March 3, 2026 12:29
Show Gist options
  • Select an option

  • Save svandragt/a83803cbb6ef29f9df6b45b1eb28d63f to your computer and use it in GitHub Desktop.

Select an option

Save svandragt/a83803cbb6ef29f9df6b45b1eb28d63f to your computer and use it in GitHub Desktop.
Detects and resolves case-insensitive filename collisions in a directory tree.
#!/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