Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Last active September 27, 2026 14:04
Show Gist options
  • Select an option

  • Save dzogrim/5d26b2d47622ddb42191261c984f1082 to your computer and use it in GitHub Desktop.

Select an option

Save dzogrim/5d26b2d47622ddb42191261c984f1082 to your computer and use it in GitHub Desktop.
Rename files according to their filesystem creation (birth) date
#!/usr/bin/env bash
# shellcheck shell=bash
#
# ==============================================================================
# rename-by-creation-date
# ==============================================================================
#
# Description:
# Rename files according to their filesystem creation (birth) date.
#
# Dry-run is the default mode. Actual renaming requires --apply.
# Confirmations can be bypassed explicitly with --apply --force.
#
# Warnings:
# Existing destination files cause a collision.
# Multiple sources targeting the same name cause a collision.
#
# Notes:
# The complete plan is validated for collisions before any rename.
# '.DS_Store' file is always ignored.
#
# Set 'NO_COLOR=1' to disable colored help output.
#
# Requirements:
# - Bash 5.x
# - GNU coreutils (GNU `stat` 9.x)
#
# macOS:
# brew install coreutils
#
# ==============================================================================
set -o nounset
set -o pipefail
readonly VERSION="0.4.2"
readonly PROGRAM="${0##*/}"
STAT_CMD=''
# ------------------------------------------------------------------------------
# Terminal colors
# ------------------------------------------------------------------------------
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
readonly BOLD=$'\033[1m' DIM=$'\033[2m'
readonly RED=$'\033[31m' GREEN=$'\033[32m'
readonly YELLOW=$'\033[33m' BLUE=$'\033[34m'
readonly CYAN=$'\033[36m' RESET=$'\033[0m'
else
readonly BOLD='' DIM='' RED='' GREEN=''
readonly YELLOW='' BLUE='' CYAN='' RESET=''
fi
usage() {
cat <<EOF
${BOLD}${CYAN}$PROGRAM${RESET} ${DIM}v$VERSION${RESET}
${DIM}Rename files in this directory from their filesystem creation date.${RESET}
${BOLD}Usage${RESET}
${GREEN}$PROGRAM${RESET} ${YELLOW}[options] [pattern]${RESET}
${BOLD}Options${RESET}
${GREEN}--apply${RESET}
Apply the planned renames.
${GREEN}-f, --force${RESET}
Skip interactive confirmation. Requires ${GREEN}--apply${RESET}.
Does ${RED}not${RESET} bypass collision protection.
${GREEN}-h, --help${RESET}
Show this help and exit.
${GREEN}-V, --version${RESET}
Show version information and exit.
${BOLD}Arguments${RESET}
${YELLOW}pattern${RESET}
File glob to process.
Default: ${CYAN}*${RESET}
${BOLD}Behavior${RESET}
${CYAN}•${RESET} Dry-run is always the default.
${CYAN}•${RESET} Only regular files in the current directory are processed.
${CYAN}•${RESET} The complete rename plan is displayed before any changes.
${CYAN}•${RESET} The complete plan is checked for destination collisions.
${CYAN}•${RESET} A collision prevents all renames, including with ${GREEN}--force${RESET}.
${CYAN}•${RESET} ${GREEN}--apply${RESET} requires interactive confirmation.
${CYAN}•${RESET} ${GREEN}--apply --force${RESET} applies without confirmation.
${CYAN}•${RESET} ${DIM}.DS_Store${RESET} is always ignored.
${BOLD}Filename format${RESET}
${CYAN}YYYY-MM-DD_HH-MM-SS.ext${RESET}
${BOLD}Requirements${RESET}
GNU coreutils: ${BOLD}${GREEN}stat${RESET}
${BOLD}${BLUE}(i)${RESET} on macOS, use Homebrew:
${GREEN}brew install coreutils${RESET}
${BOLD}Examples${RESET}
${DIM}# Preview all files${RESET}
${GREEN}\$ $PROGRAM${RESET}
${DIM}# Preview PNG files${RESET}
${GREEN}\$ $PROGRAM '*.png'${RESET}
${DIM}# Apply after confirmation${RESET}
${GREEN}\$ $PROGRAM '*.png' --apply${RESET}
${DIM}# Apply without confirmation${RESET}
${GREEN}\$ $PROGRAM '*.png' --apply --force${RESET}
EOF
}
die() {
printf '%serror:%s %s\n' "$RED" "$RESET" "$*" >&2
exit 1
}
warn() {
printf '%swarning:%s %s\n' "$YELLOW" "$RESET" "$*" >&2
}
# ------------------------------------------------------------------------------
# Requirements
# ------------------------------------------------------------------------------
check_requirements() {
local candidate
# GNU stat is mandatory. Prefer gstat on macOS, but accept an
# unprefixed GNU stat when coreutils/gnubin is already in PATH.
for candidate in gstat stat; do
command -v "$candidate" >/dev/null 2>&1 || continue
if "$candidate" --version 2>/dev/null | grep -q 'GNU coreutils'; then
STAT_CMD="$(command -v "$candidate")"
break
fi
done
[[ -n "$STAT_CMD" ]] || {
cat >&2 <<EOF
${RED}error:${RESET} GNU stat is required but was not found.
$PROGRAM requires GNU coreutils to read filesystem creation timestamps.
On macOS with Homebrew:
${GREEN}brew install coreutils${RESET}
GNU stat should then be available as:
${GREEN}gstat${RESET}
Verify with:
${GREEN}gstat --version${RESET}
${RED}Aborting without making any changes.${RESET}
EOF
exit 127
}
# Native macOS implementations are sufficient for these commands.
command -v find >/dev/null 2>&1 || die "'find' was not found"
command -v mv >/dev/null 2>&1 || die "'mv' was not found"
}
# ------------------------------------------------------------------------------
# Collision validation
# ------------------------------------------------------------------------------
check_collisions() {
local i j target
local collisions=0
printf '\n%s%sCollision check%s\n' "$BOLD" "$BLUE" "$RESET"
for ((i = 0; i < ${#targets[@]}; i++)); do
target="${targets[i]}"
# Collision type 1: destination already exists.
#
# -e covers regular files/directories.
# -L also catches dangling symbolic links.
if [[ -e "$target" || -L "$target" ]]; then
printf ' %scollision:%s destination already exists\n' \
"$RED" "$RESET"
printf ' %s%s%s\n' "$DIM" "${sources[i]}" "$RESET"
printf ' %s→%s %s%s%s\n' \
"$RED" "$RESET" "$RED" "$target" "$RESET"
((collisions++))
fi
# Collision type 2: two planned sources resolve to the same target.
#
# Only compare with subsequent entries so each collision is reported
# once.
for ((j = i + 1; j < ${#targets[@]}; j++)); do
[[ "$target" == "${targets[j]}" ]] || continue
printf ' %scollision:%s multiple files target the same name\n' \
"$RED" "$RESET"
printf ' %s%s%s\n' "$DIM" "${sources[i]}" "$RESET"
printf ' %s%s%s\n' "$DIM" "${sources[j]}" "$RESET"
printf ' %s→%s %s%s%s\n' \
"$RED" "$RESET" "$RED" "$target" "$RESET"
((collisions++))
done
done
if ((collisions > 0)); then
printf '\n%s%d collision(s) detected.%s\n' \
"$RED" "$collisions" "$RESET"
printf '%sNo changes can be applied.%s\n' "$RED" "$RESET"
return 1
fi
printf ' %s✓ No collisions detected.%s\n' "$GREEN" "$RESET"
return 0
}
# ------------------------------------------------------------------------------
# Main
# ------------------------------------------------------------------------------
main() {
local pattern='*' apply=false force=false pattern_set=false
local f birth new ext answer i
local -a files=()
declare -ga sources=() targets=()
# Parse arguments first so --help and --version work without dependencies.
while (($#)); do
case "$1" in
--apply) apply=true ;;
-f|--force) force=true ;;
-h|--help) usage; exit 0 ;;
-V|--version) printf '%s %s\n' "$PROGRAM" "$VERSION"; exit 0 ;;
--)
shift
(($# <= 1)) || die "only one pattern may be specified"
(($# == 0)) || { pattern="$1"; pattern_set=true; }
break
;;
-*) die "unknown option '$1'; try '--help'" ;;
*)
[[ "$pattern_set" == false ]] ||
die "only one pattern may be specified"
pattern="$1"
pattern_set=true
;;
esac
shift
done
[[ "$force" != true || "$apply" == true ]] ||
die "'--force' requires '--apply'"
# Fail before inspecting or modifying files if requirements are missing.
check_requirements
# Collect regular files safely. .DS_Store is explicitly excluded.
while IFS= read -r -d '' f; do
files+=("$f")
done < <(
find . -maxdepth 1 -type f \
-name "$pattern" ! -name '.DS_Store' -print0
)
((${#files[@]} > 0)) || die "no files match '$pattern'"
# Build the complete rename plan before modifying anything.
for f in "${files[@]}"; do
f="${f#./}"
# Defensive second guard: Finder metadata must never be processed.
[[ "${f##*/}" == '.DS_Store' ]] && continue
birth="$("$STAT_CMD" -c '%w' -- "$f")" || {
warn "unable to read creation date: $f"
continue
}
if [[ -z "$birth" || "$birth" == '-' ]]; then
warn "no creation date: $f"
continue
fi
# Preserve the extension and convert:
# 2026-09-27 14:43:32.123... -> 2026-09-27_14-43-32.ext
ext=''
[[ "$f" == *.* ]] && ext=".${f##*.}"
new="${birth%%.*}"
new="${new/ /_}"
new="${new//:/-}"
new="${new}${ext}"
[[ "$f" == "$new" ]] && continue
sources+=("$f")
targets+=("$new")
done
((${#sources[@]} > 0)) || {
printf '%sNothing to rename.%s\n' "$DIM" "$RESET"
exit 0
}
# Display the complete plan before potentially applying it.
printf '\n%s%sRename plan%s\n\n' "$BOLD" "$BLUE" "$RESET"
for ((i = 0; i < ${#sources[@]}; i++)); do
printf ' %s%s%s\n' "$DIM" "${sources[i]}" "$RESET"
printf ' %s→%s %s%s%s\n' \
"$CYAN" "$RESET" "$GREEN" "${targets[i]}" "$RESET"
done
printf '\n%s%d file(s)%s to rename.\n' \
"$BOLD" "${#sources[@]}" "$RESET"
# Validate the entire plan. A collision makes the plan unsafe and blocks
# both normal --apply and --apply --force.
check_collisions || exit 2
# Dry-run is deliberately the default.
if [[ "$apply" != true ]]; then
printf '\n%sDry-run:%s no changes were made.\n' "$YELLOW" "$RESET"
printf "Run with %s--apply%s to apply this plan.\n" "$GREEN" "$RESET"
exit 0
fi
# Ask for confirmation unless --force was explicitly requested.
if [[ "$force" != true ]]; then
printf '\n%sApply these changes?%s [y/N] ' "$YELLOW" "$RESET"
read -r answer
case "$answer" in
y|Y|yes|YES) ;;
*)
printf '%sAborted.%s\n' "$DIM" "$RESET"
exit 0
;;
esac
fi
printf '\n'
# Apply only a fully validated plan.
#
# -n remains as a final defensive safeguard against an unexpected
# destination appearing between validation and the actual mv.
for ((i = 0; i < ${#sources[@]}; i++)); do
mv -vn -- "${sources[i]}" "${targets[i]}"
done
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment