Why this matters
Files arrive with names chosen by cameras, scanners, colleagues and download dialogs: IMG_0042.JPG, Report (Final) (2).PDF, notes from meeting .txt. Names with spaces, parentheses and mixed case are awkward in the shell, in URLs and in scripts. Cleaning up a folder by hand takes an afternoon; a renamer does it in a second. But renaming is also destructive in a quiet way: mv overwrites without asking, and a bug can turn a hundred files into one. This project is about doing a bulk change safely: preview it, detect every collision, never overwrite, and report problems through the exit status so other scripts can rely on it.
The plan
rename.sh [-n] DIR
for each regular file in DIR, sorted:
new = sanitize(old)
unchanged? → skip silently
nothing usable left? → warn, remember failure
new name taken? → warn, remember failure
otherwise → rename (or, with -n, say what would happen)
exit 1 if anything was skipped, else 0
Three parts: the pure function that computes names, the loop that applies it with a dry run, and the safety checks.
Part 1: a pure function
sanitize takes a name and prints a new one. It reads no files and changes nothing, which makes it trivial to test: feed names in, compare names out. Separating "decide" from "act" is the most useful design habit in this project.
sanitize() {
local s=${1,,} # 1. lowercase
s=${s//[[:space:]]/-} # 2. whitespace -> dash
s=${s//[^a-z0-9._-]/} # 3. drop everything else
while [[ $s == *--* ]]; do # 4. squeeze dashes
s=${s//--/-}
done
s=${s//-./.} # 5. no dash before a dot
s=${s#-} # 6. trim one dash at each end
s=${s%-}
printf '%s\n' "$s"
}
Walk through Spaced Out .txt:
lowercase " spaced out .txt"
whitespace -> - "--spaced---out-.txt"
drop others "--spaced---out-.txt"
squeeze "-spaced-out-.txt"
-. -> . "-spaced-out.txt"
trim "spaced-out.txt"
The order matters. Squeezing before step 3 would leave doubled dashes where a deleted character sat between two spaces (a & b becomes a--b only after deletion). Writing out a trace like this, on paper or with set -x, is how you check that a sequence of transformations does what you think.
A note on non-ASCII names: ${s,,} follows the locale, and step 3 then removes any letter outside a-z, so Café becomes caf (or cafe if the é was stored as e plus a combining accent, as in the exercise). A production tool might transliterate (iconv -t ascii//TRANSLIT) instead; here we keep the rule simple and predictable.
Part 2: the loop and the dry run
shopt -s nullglob
for path in "$dir"/*; do
[[ -f $path ]] || continue # regular files only: skip directories
name=${path##*/}
new=$(sanitize "$name")
[[ $new == "$name" ]] && continue # already clean
if [[ $dry == yes ]]; then
echo "would rename '$name' -> '$new'"
else
mv -n -- "$path" "$dir/$new"
echo "renamed '$name' -> '$new'"
fi
done
Details that matter:
- The glob, not
ls."$dir"/*gives each name intact, spaces and all, sorted. Parsinglsoutput breaks on exactly the names this tool exists to fix. *skips hidden files, which is what we want: dotfiles are usually configuration.-fskips directories. Renaming the directory a user is working in is a surprise.--before the names so a file called-nis not taken as an option bymv.mv -nrefuses to overwrite. The script checks collisions itself (part 3), but defense in depth costs nothing.- Same code path for the dry run and the real run; only the action differs.
Each $(sanitize ...) forks a subshell. For a few thousand files that is fine. If it ever matters, have the function store its result in a variable instead of printing it (for instance with printf -v or a nameref), which avoids the fork.
Part 3: collisions
Two different names can sanitize to the same result, and a target may already exist:
"Report (Final).PDF" → report-final.pdf but report-final.pdf already exists
"My File.txt" → my-file.txt
"my file.TXT" → my-file.txt claimed a moment ago by "My File.txt"
"###" → "" nothing usable left
The rules: never overwrite, report each skip on standard error, and continue with the other files. At the end the exit status says whether everything was handled. That lets a calling script do rename.sh "$dir" || notify "some files need attention".
The interesting part is the dry run. In a real run, after My File.txt is renamed, -e "$dir/my-file.txt" is true when my file.TXT comes along. In a dry run nothing was renamed, so the file test alone would wrongly report that my file.TXT can be renamed. The dry run would then promise something the real run will not do, which defeats its purpose. The fix is to remember every name claimed during this run, in both modes:
declare -A claimed=()
...
if [[ -e $dir/$new || -n ${claimed[$new]:-} ]]; then
echo "rename.sh: skipping '$name': '$new' already exists" >&2
status=1
continue
fi
claimed[$new]=1
Now dry run and real run make identical decisions, and the check for this part verifies exactly that: same standard output (apart from "would"), same standard error, same exit status.
There is one subtle limitation, worth knowing: in the real run, renaming file A frees A's old name, which a later file could have wanted. Our tool does not try to exploit that (the dry run could not know about it without simulating the whole directory), so it may skip a file that a cleverer algorithm could have renamed. Skipping is safe; being clever with renames is how files get lost.
What happens underneath
mv old new within one filesystem calls the kernel's rename() system call. It does not copy any data: it edits directory entries so that the inode formerly reachable as old is now reachable as new. If new already existed, its directory entry is replaced atomically, and if that was the last link to its inode, the old file's data is freed. That is why an overwrite is instant, silent and unrecoverable. mv -n checks for an existing target before renaming (newer coreutils use the renameat2() call with a no-replace flag, which makes the check atomic).
Common mistakes
for f in $(ls). Splits names on spaces. Use a glob.- Plain
mvwithout a collision check. Silent overwrites. - A dry run that ignores names claimed earlier in the run. It reports successes the real run will not have.
- Exiting 0 after skipping files. Callers cannot tell anything went wrong.
- Renaming recursively by accident (or renaming directories), changing paths that other things depend on.
- Forgetting
--. A file named-rfturnsmv $f ...into an option parade.
Exercises
A quiz on mv, the sanitize function as a code exercise, and /work/rename.sh in two parts. The checks build test directories in a temporary location; /work/inbox is yours to experiment on (it contains collisions, a directory and a name with nothing usable in it). Always run -n first; that is the point of having it.
Recap
- Separate deciding (a pure
sanitizefunction) from acting (the rename loop). - Iterate with a glob, skip non-files, use
--andmv -n. - Detect collisions before acting: existing files and names claimed earlier in the same run.
- A dry run must make the same decisions as the real run; track claimed names in both modes.
- Report skips on standard error and reflect them in the exit status.
mvisrename(): instant, atomic, and a silent overwrite of an existing target.
Going further: add -r for recursion with find "$dir" -depth -type f -print0 and a while IFS= read -r -d '' loop (why does -depth matter when directories are renamed too?), and an undo log: write each old<TAB>new pair to a file so a second script can reverse the run.