Last active
September 27, 2026 14:04
-
-
Save dzogrim/5d26b2d47622ddb42191261c984f1082 to your computer and use it in GitHub Desktop.
Rename files according to their filesystem creation (birth) date
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 | |
| # 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