Skip to content

Instantly share code, notes, and snippets.

@dzogrim
Last active August 29, 2026 14:06
Show Gist options
  • Select an option

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

Select an option

Save dzogrim/cbf7f8e4c5e4d24deff065bf61751034 to your computer and use it in GitHub Desktop.
Safely shorten overly long filenames while preserving extensions
#!/usr/bin/env bash
set -Eeuo pipefail
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 dzogrim
# SPDX-FileContributor: dzogrim <dzogrim@dzogrim.pw>
# ---------------------------------------------------------------------------
# File: truncate-long-filenames.sh
# Purpose:
# Safely shorten overly long filenames while preserving extensions.
#
# Default mode:
# Dry-run only. No files are modified.
#
# Notes:
# - One or more directories can be supplied as positional arguments.
# - Individual files can be supplied with --file FILE.
# - If no path and no file are supplied, DEFAULT_BASE is scanned.
# - Extensions are preserved.
# - A short SHA-256 suffix is added to avoid collisions.
# - Generated .terraform trees are skipped deliberately.
# ---------------------------------------------------------------------------
readonly VERSION="2.1.0"
readonly VERSION_DATE="2026-08-29"
readonly PROGRAM_NAME="${0##*/}"
readonly DEFAULT_BASE="${HOME}/Documents/Personnels"
readonly DEFAULT_MAX_BYTES=180
readonly HASH_LENGTH=8
APPLY=0
MAX_BYTES="$DEFAULT_MAX_BYTES"
declare -a BASES=()
declare -a EXPLICIT_FILES=()
declare -A SEEN_FILES=()
usage() {
cat <<EOF
${PROGRAM_NAME} ${VERSION} (${VERSION_DATE})
Safely shorten overly long filenames while preserving their extensions.
Usage:
${PROGRAM_NAME} [OPTIONS] [PATH...]
${PROGRAM_NAME} [OPTIONS] --file FILE [--file FILE...]
Options:
--apply Actually rename files.
Default mode is dry-run.
--max-bytes N Maximum allowed filename size in bytes.
Default: ${DEFAULT_MAX_BYTES}
--file FILE Process one specific file.
May be specified multiple times.
-V, --version Show version information and exit.
-h, --help Show this help and exit.
Arguments:
PATH... One or more directories to scan recursively.
Default:
If no PATH and no --file are supplied:
${DEFAULT_BASE}
Examples:
${PROGRAM_NAME}
${PROGRAM_NAME} "/path/to/tree"
${PROGRAM_NAME} \
"/path/to/first/tree" \
"/path/to/second/tree"
${PROGRAM_NAME} \
--file "/path/to/problematic-file.pdf"
${PROGRAM_NAME} \
--file "/path/to/file1.pdf" \
--file "/path/to/file2.pdf"
${PROGRAM_NAME} \
--max-bytes 180 \
--file "/path/to/problematic-file.pdf"
${PROGRAM_NAME} \
--apply \
--max-bytes 180 \
"/path/to/tree"
Default behavior:
Dry-run mode. Candidate renames are displayed but no files are modified.
EOF
}
version() {
printf '%s %s (%s)\n' \
"$PROGRAM_NAME" \
"$VERSION" \
"$VERSION_DATE"
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
# Return byte length without spawning wc/tr.
#
# Bash normally counts characters according to locale. Temporarily forcing
# LC_ALL=C makes ${#var} count raw bytes instead.
byte_len() {
local value="$1"
local LC_ALL=C
printf '%d' "${#value}"
}
sha8() {
local value="$1"
local digest
digest="$(printf '%s' "$value" | shasum -a 256)"
digest="${digest%% *}"
printf '%s' "${digest:0:HASH_LENGTH}"
}
trim_trailing_space_and_dots() {
local value="$1"
while [[ "$value" == *' ' || "$value" == *'.' || "$value" == *$'\t' ]]; do
value="${value%?}"
done
printf '%s' "$value"
}
utf8_valid() {
local value="$1"
printf '%s' "$value" |
iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1
}
truncate_utf8_to_bytes() {
local value="$1"
local max_bytes="$2"
local LC_ALL=C
local current_bytes
current_bytes=${#value}
while ((current_bytes > max_bytes)); do
value="${value:0:current_bytes-1}"
current_bytes=${#value}
done
# If truncation ended inside a UTF-8 sequence, remove bytes until valid.
while [[ -n "$value" ]] && ! utf8_valid "$value"; do
current_bytes=${#value}
value="${value:0:current_bytes-1}"
done
printf '%s' "$value"
}
process_file() {
local file="$1"
local display_base="${2:-}"
local dir
local name
local name_bytes
local stem
local ext
local hash
local suffix
local suffix_bytes
local target_stem_bytes
local new_stem
local new_name
local new_file
local new_bytes
local display_old
local display_new
[[ -n "${SEEN_FILES["$file"]+x}" ]] && {
((duplicates += 1))
return 0
}
SEEN_FILES["$file"]=1
((scanned += 1))
case "$file" in
*/.terraform/*)
((skipped += 1))
return 0
;;
esac
if [[ ! -f "$file" ]]; then
printf 'ERROR: not a regular file: %s\n\n' "$file" >&2
((errors += 1))
return 0
fi
dir="${file%/*}"
name="${file##*/}"
name_bytes="$(byte_len "$name")"
((name_bytes > MAX_BYTES)) || return 0
((candidates += 1))
stem="$name"
ext=""
# Preserve a conventional extension conservatively.
if [[ "$name" == *.* && "$name" != .* ]]; then
ext=".${name##*.}"
stem="${name%.*}"
fi
hash="$(sha8 "$file")"
suffix="_${hash}${ext}"
suffix_bytes="$(byte_len "$suffix")"
target_stem_bytes=$((MAX_BYTES - suffix_bytes))
if ((target_stem_bytes < 1)); then
printf 'ERROR: suffix leaves no room for filename\n' >&2
printf ' Source: %s\n\n' "$file" >&2
((errors += 1))
return 0
fi
new_stem="$(truncate_utf8_to_bytes "$stem" "$target_stem_bytes")"
new_stem="$(trim_trailing_space_and_dots "$new_stem")"
[[ -n "$new_stem" ]] || new_stem="file"
# Trimming may theoretically change the byte budget. Recheck explicitly.
while :; do
new_name="${new_stem}_${hash}${ext}"
new_bytes="$(byte_len "$new_name")"
((new_bytes <= MAX_BYTES)) && break
new_stem="$(truncate_utf8_to_bytes "$new_stem" "$(( $(byte_len "$new_stem") - 1 ))")"
new_stem="$(trim_trailing_space_and_dots "$new_stem")"
[[ -n "$new_stem" ]] || new_stem="file"
done
new_file="${dir}/${new_name}"
if [[ "$new_file" == "$file" ]]; then
((skipped += 1))
return 0
fi
if [[ -e "$new_file" || -L "$new_file" ]]; then
printf 'ERROR: target already exists\n' >&2
printf ' Source: %s\n' "$file" >&2
printf ' Target: %s\n\n' "$new_file" >&2
((errors += 1))
return 0
fi
display_old="$file"
display_new="$new_file"
if [[ -n "$display_base" ]]; then
case "$file" in
"$display_base"/*)
display_old="${file#"$display_base"/}"
display_new="${new_file#"$display_base"/}"
;;
esac
fi
printf '%d bytes -> %d bytes\n' \
"$name_bytes" \
"$new_bytes"
printf ' OLD: %s\n' "$display_old"
printf ' NEW: %s\n' "$display_new"
if ((APPLY)); then
if mv -- "$file" "$new_file"; then
printf ' OK: renamed\n'
((renamed += 1))
else
printf ' ERROR: rename failed\n' >&2
((errors += 1))
fi
else
printf ' DRY-RUN: unchanged\n'
fi
printf '\n'
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while (($# > 0)); do
case "$1" in
--apply)
APPLY=1
shift
;;
--max-bytes)
(($# >= 2)) ||
die "--max-bytes requires an argument"
MAX_BYTES="$2"
shift 2
;;
--file)
(($# >= 2)) ||
die "--file requires an argument"
EXPLICIT_FILES+=("$2")
shift 2
;;
-V|--version)
version
exit 0
;;
-h|--help)
usage
exit 0
;;
--)
shift
while (($# > 0)); do
BASES+=("$1")
shift
done
;;
-*)
die "Unknown option: $1"
;;
*)
BASES+=("$1")
shift
;;
esac
done
if ((${#BASES[@]} == 0 && ${#EXPLICIT_FILES[@]} == 0)); then
BASES=("$DEFAULT_BASE")
fi
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
[[ "$MAX_BYTES" =~ ^[0-9]+$ ]] ||
die "--max-bytes must be a positive integer"
((MAX_BYTES >= 64)) ||
die "--max-bytes is suspiciously small: $MAX_BYTES"
for base in "${BASES[@]}"; do
[[ -d "$base" ]] ||
die "Directory does not exist: $base"
[[ -r "$base" ]] ||
die "Directory is not readable: $base"
if ((APPLY)); then
[[ -w "$base" ]] ||
die "Directory is not writable: $base"
fi
done
for file in "${EXPLICIT_FILES[@]}"; do
[[ -e "$file" || -L "$file" ]] ||
die "File does not exist: $file"
[[ -r "$file" ]] ||
die "File is not readable: $file"
if ((APPLY)); then
parent="${file%/*}"
[[ -w "$parent" ]] ||
die "Parent directory is not writable: $parent"
fi
done
mode="DRY-RUN"
((APPLY)) && mode="APPLY"
printf '%s %s (%s)\n' \
"$PROGRAM_NAME" \
"$VERSION" \
"$VERSION_DATE"
printf 'Mode : %s\n' "$mode"
printf 'Max bytes : %s\n' "$MAX_BYTES"
printf 'Paths : %d\n' "${#BASES[@]}"
printf 'Files : %d\n' "${#EXPLICIT_FILES[@]}"
for base in "${BASES[@]}"; do
printf ' PATH: %s\n' "$base"
done
for file in "${EXPLICIT_FILES[@]}"; do
printf ' FILE: %s\n' "$file"
done
printf '\n'
# ---------------------------------------------------------------------------
# Processing
# ---------------------------------------------------------------------------
scanned=0
candidates=0
renamed=0
skipped=0
errors=0
duplicates=0
for file in "${EXPLICIT_FILES[@]}"; do
process_file "$file"
done
for base in "${BASES[@]}"; do
base="${base%/}"
printf 'Scanning: %s\n' "$base"
printf '%s\n' '------------------------------------------------------------'
while IFS= read -r -d '' file; do
process_file "$file" "$base"
done < <(
find "$base" \
-type f \
-print0
)
printf '\n'
done
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
printf '%s\n' '============================================================'
printf 'Summary\n'
printf '%s\n' '------------------------------------------------------------'
printf 'Paths : %d\n' "${#BASES[@]}"
printf 'Files : %d\n' "${#EXPLICIT_FILES[@]}"
printf 'Scanned : %d\n' "$scanned"
printf 'Candidates : %d\n' "$candidates"
if ((APPLY)); then
printf 'Renamed : %d\n' "$renamed"
else
printf 'Renamed : 0 (dry-run)\n'
fi
printf 'Skipped : %d\n' "$skipped"
printf 'Duplicates : %d\n' "$duplicates"
printf 'Errors : %d\n' "$errors"
((errors == 0))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment