#!/usr/bin/env bash # ------------------------------------------------------------------------------ # Copyright (c) 2013-2026 Geoffray Levasseur # Protected by the BSD3 license. Please read bellow for details. # # * Redistribution and use in source and binary forms, # * with or without modification, are permitted provided # * that the following conditions are met: # * # * Redistributions of source code must retain the above # * copyright notice, this list of conditions and the # * following disclaimer. # * # * Redistributions in binary form must reproduce the above # * copyright notice, this list of conditions and the following # * disclaimer in the documentation and/or other materials # * provided with the distribution. # * # * Neither the name of the copyright holder nor the names # * of any other contributors may be used to endorse or # * promote products derived from this software without # * specific prior written permission. # * # * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND # * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, # * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES # * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR # * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR # * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, # * OF SUCH DAMAGE. # ------------------------------------------------------------------------------ # Common helpers (helpers/common.sh) # shellcheck source=../helpers/common.sh declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh" load_conf "filefct" # ------------------------------------------------------------------------------ # Expand wildcards in a file/directory list and quote the results: each match is # wrapped in double quotes (with \, ", $ and ` escaped) and the matches are # joined with the separator. Items without wildcards are quoted as they are. # Usage: expandlist [options] expandlist() { local separator="${EXPANDLIST_DEFAULT_SEPARATOR:- }" local PARSED PARSED=$(getopt -o hs:n --long help,separator:,newline -n 'expandlist' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"expandlist --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "expandlist: expand globs and wrap matched items in double quotes.\n\n" printf "Usage: expandlist [options] \n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-s, --separator SEP\tSet output separator (default: space); \\\\n, \\\\t... are interpreted\n" printf "\t-n, --newline\t\tUse a newline as separator\n" return 0 ;; -s|--separator) separator="$2" shift 2 ;; -n|--newline) separator=$'\n' shift ;; --) shift break ;; *) disp E "Invalid options, use \"expandlist --help\" to display usage." return 1 ;; esac done # Backslash escapes in the separator (\n, \t, ...) are interpreted, so the # configuration file can express a newline as EXPANDLIST_DEFAULT_SEPARATOR=\n printf -v separator '%b' "$separator" local item="" result="" matched=0 # Restore the caller's nullglob setting on exit instead of forcing it off. local _had_nullglob=0 shopt -q nullglob && _had_nullglob=1 shopt -s nullglob # An empty IFS keeps the unquoted expansion below from splitting on spaces # (it would cut "my dir/*.txt" in two) while globbing still happens. local IFS= for item in "$@"; do local expanded=() # True glob expansion when wildcards are present. if [[ "$item" == *'*'* || "$item" == *'?'* || "$item" == *'['* ]]; then # shellcheck disable=SC2206 # Intentional glob expansion, no splitting (IFS is empty) expanded=( $item ) else expanded=( "$item" ) fi if [[ ${#expanded[@]} -eq 0 ]]; then continue fi local content for content in "${expanded[@]}"; do if (( matched )); then result+="$separator" fi # Escape what stays special inside double quotes so that the output # can safely be re-evaluated (eval, command substitution, ...). content=${content//\\/\\\\} content=${content//\"/\\\"} content=${content//\$/\\\$} content=${content//\`/\\\`} result+="\"$content\"" matched=1 done unset content done (( _had_nullglob )) || shopt -u nullglob printf '%s\n' "$result" } export -f expandlist # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Clean a directory tree from temporary or backup files # Usage: clean [options] [directory1] [...[directoryX]] # Options: # -h, --help: display help screen # -r, --recurs: do a recursive cleaning # -f, --force: do not ask for confirmation (use with care) # -s, --shell: do nothing and display what will be executed clean() { local recursive=${CLEAN_DEFAULT_RECURSIVE:-0} force=0 outshell=0 # Define short and long options local PARSED PARSED=$(getopt -o hrsf --long help,recurs,shell,force -n 'clean' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"clean --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -r|--recurs) recursive=1 shift ;; -h|--help) printf "clean: erase backup files in the given directories.\n\n" printf "Usage: clean [option] [directory1] [...[directoryX]]\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay that help screen\n" printf "\t-r, --recurs\t\tDo a recursive cleaning\n" printf "\t-f, --force\t\tDo not ask for confirmation (use with care)\n" printf "\t-s, --shell\t\tDo nothing and display what will be executed\n" printf "\n" return 0 ;; -s|--shell) outshell=1 shift ;; -f|--force) force=1 shift ;; --) shift break ;; *) disp E "Invalid parameter, use \"clean --help\" to display options list" return 1 ;; esac done # Handle remaining arguments as directories local dirlist=("$@") [[ ${#dirlist[@]} -eq 0 ]] && dirlist=(".") local findopt=() rmopt=() (( ! recursive )) && findopt=(-maxdepth 1) (( ! force )) && rmopt=(-i) local dir f for dir in "${dirlist[@]}"; do # "./" keeps a name starting with "-" from being read as a find option. # -H follows a symlink given as argument (a link to a directory). [[ $dir == -* ]] && dir="./$dir" find -H "$dir" "${findopt[@]}" -type f \( -name "*~" -o -name "#*#" -o -name "*.bak" -o -name ".~*#" \) -print0 | while IFS= read -r -d '' f; do if (( outshell )); then # %q prints a re-usable (shell-quoted) form of the name if (( ${#rmopt[@]} )); then printf 'rm %s -- %q\n' "${rmopt[*]}" "$f" else printf 'rm -- %q\n' "$f" fi elif (( force )); then # No confirmation: rm reads no stdin, so leave it attached # to the find pipe (a /dev/tty redirect would fail when there # is no controlling terminal, e.g. cron). rm -- "$f" || disp E "Failed to remove \"$f\"." else # Interactive rm -i: read the answer from the terminal, not # from the find pipe that feeds this loop's stdin. rm -i -- "$f" mcd() { if [[ "$1" == "-h" || "$1" == "--help" ]]; then printf "mcd: Create a directory and enter it.\n\n" printf "Usage: mcd \n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" return 0 fi if (( $# != 1 )); then disp E "Missing parameter. Use \"mcd --help\" to display usage." return 1 fi if ! mkdir -pv -- "$1"; then disp E "Failed to create directory \"$1\"." return 1 fi if ! cd -- "$1"; then disp E "Failed to change to directory \"$1\"." return 1 fi } export -f mcd # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Rename files and directories to replace spaces with another character # Usage: rmspc [options] # Options: # -h, --help: display help screen # -r, --recursive: treat subdirectories of the given directory # -c, --subst-char: change the replacement character (default is underscore) # -v, --verbose: display more details (recursive mode only) # -s, --shell: do nothing and display commands that would be executed rmspc() { local recurs=0 verb=0 shell=0 local substchar="${RMSPC_DEFAULT_CHAR:-_}" substchar_set=0 local mvopt=() local PARSED PARSED=$(getopt -o hrc::vs --long help,recursive,subst-char::,verbose,shell -n 'rmspc' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"rmspc --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "rmspc: remove spaces from all filenames in current directories\n\n" printf "Usage: rmspc [option]\n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay that help screen\n" printf "\t-r, --recursive\t\tTreat subdirectories of the given directory\n" printf "\t-c, --subst-char[=C]\tChange the replacement character (default is underscore)\n" printf "\t-v, --verbose\t\tDisplay more details (recursive mode only)\n" printf "\t-s, --shell\t\tDo nothing and display commands that would be executed\n\n" printf "Note: the character must be attached (-c- or --subst-char=-). If the option\n" printf " is given without a character, spaces are replaced with nothing\n" printf " (concatenation). An existing file is never overwritten.\n" return 0 ;; -r|--recursive) recurs=1 shift ;; -c|--subst-char) substchar_set=1 substchar="$2" shift 2 ;; -v|--verbose) verb=1 shift ;; -s|--shell) shell=1 shift ;; --) shift break ;; *) disp E "Invalid parameter, use \"rmspc --help\" to display options list" return 1 ;; esac done # getopt only attaches an optional argument written as -cX or --subst-char=X; # "-c X" leaves X behind as a positional argument, which rmspc does not take # (it works on the current directory) and would otherwise silently ignore. if [[ $# -gt 0 ]]; then disp E "Unexpected argument '$1'. The replacement character must be attached: -c$1 or --subst-char=$1" return 1 fi [[ "$substchar" == "none" ]] && substchar="" # A slash would turn the new name into a path if [[ "$substchar" == */* ]]; then disp E "The replacement character must not contain a slash." return 1 fi (( verb )) && mvopt=(-v) # Restore the caller's nullglob setting on exit instead of forcing it off. local _had_nullglob=0 shopt -q nullglob && _had_nullglob=1 shopt -s nullglob local f for f in *; do # Symlinks to directories are not followed (no escape from the tree, # no loops). if (( recurs )) && [[ -d "$f" && ! -L "$f" ]]; then ( local lastdir=$f if (( verb )); then disp I "Entering directory $(pwd)/$f ..." fi pushd "$f" >/dev/null || return 1 # Build the recursive argument list by value: the ${var:+-flag} # idiom would expand on the *string* "0" (non-empty) and wrongly # force -v/-s on every recursive call. local rargs=(-r) (( verb )) && rargs+=(-v) (( shell )) && rargs+=(-s) # Attached form, mandatory for an optional argument (see above) (( substchar_set )) && rargs+=(--subst-char="$substchar") rmspc "${rargs[@]}" popd >/dev/null || return 1 # "if" rather than "(( verb )) && ...": the subshell status is # that of its last command and must stay 0 when verb is off. if (( verb )); then disp I "Leaving directory $(pwd)/$lastdir" fi ) fi if [[ "$f" == *" "* ]]; then # Quoted replacement: an unquoted "&" would stand for the match local newf="${f// /"$substchar"}" [[ "$f" == "$newf" ]] && continue # Never overwrite (mv would silently replace an existing file) if [[ -e "$newf" || -L "$newf" ]]; then disp E "Cannot rename \"$f\": \"$newf\" already exists." continue fi if (( shell )); then if (( ${#mvopt[@]} )); then printf 'mv %s -- %q %q\n' "${mvopt[*]}" "$f" "$newf" else printf 'mv -- %q %q\n' "$f" "$newf" fi else mv -n "${mvopt[@]}" -- "$f" "$newf" || { disp E "Failed renaming \"$f\" to \"$newf\"." continue } fi fi done unset f (( _had_nullglob )) || shopt -u nullglob } export -f rmspc # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Display statistics about a file tree # Usage: file_stats [options] [path] # Options: # -H, --human Human readable sizes # -d, --details Display details (min/max/average/median) # -m, --average Display only average size # -M, --median Display only median size # -c, --count Display only count of files # -t, --total Display only total size # -a, --all Display all stats in human readable format (shortcut for -H -d) # -x, --ext [ext] Filter by extension (e.g. -x log for .log files) # -X, --ext-list [list] Filter by multiple extensions (e.g. -X log,txt) # --min [size] Minimum size (e.g., 10M) # --max [size] Maximum size (e.g., 100M) file_stats() { local human=0 details=0 only_avg=0 only_med=0 only_count=0 only_total=0 local path="." show_all=1 ext_filter="" ext_list="" min_size="" max_size="" local PARSED # Short: H, d, m, M, c, t, a, x:, X: # Long: human, details, average, median, count, total, all, ext:, ext-list:, min:, max:, help PARSED=$(getopt -o HdmMctax:X:h --long human,details,average,median,count,total,all,ext:,ext-list:,min:,max:,help -n 'file_stats' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"file_stats --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "Usage: file_stats [options] [path]\n\n" printf "Options:\n" printf "\t-H, --human\t\tHuman readable sizes\n" printf "\t-d, --details\t\tShow detailed histogram\n" printf "\t-m, --average\t\tShow only average size\n" printf "\t-M, --median\t\tShow only median size\n" printf "\t-c, --count\t\tShow only file count\n" printf "\t-t, --total\t\tShow only total size\n" printf "\t-a, --all\t\tShow all (human + details)\n" printf "\t-x, --ext [ext]\t\tFilter by extension\n" printf "\t-X, --ext-list [list]\tFilter by comma-separated list\n" printf "\t--min [size]\t\tMinimum size (e.g., 10M)\n" printf "\t--max [size]\t\tMaximum size (e.g., 100M)\n" return 0 ;; -H|--human) human=1 shift ;; -d|--details) details=1 shift ;; -m|--average) only_avg=1 show_all=0 shift ;; -M|--median) only_med=1 show_all=0 shift ;; -c|--count) only_count=1 show_all=0 shift ;; -t|--total) only_total=1 show_all=0 shift ;; -a|--all) human=1 details=1 shift ;; -x|--ext) ext_filter="${2#.}" shift 2 ;; -X|--ext-list) ext_list="$2" shift 2 ;; --min) min_size="$2" shift 2 ;; --max) max_size="$2" shift 2 ;; --) shift break ;; *) disp E "Invalid option: $1" return 1 ;; esac done [[ -n "$1" ]] && path="$1" if [[ ! -e "$path" ]]; then disp E "file_stats: no such file or directory: ${path}" return 1 fi # "./" keeps a name starting with "-" from being read as a find option [[ $path == -* ]] && path="./$path" # Prepare find filters local find_cmd=(find "$path" -type f) # Single extension filter (case-insensitive). When combined with # --ext-list, a file must satisfy both, as find ANDs the tests. if [[ -n "$ext_filter" ]]; then find_cmd+=(-iname "*.$ext_filter") fi # Extension list filter: ( -iname *.a -o -iname *.b ... ) local exts if [[ -n "$ext_list" ]]; then IFS=',' read -ra exts <<< "$ext_list" find_cmd+=('(') local i for i in "${!exts[@]}"; do [[ $i -ne 0 ]] && find_cmd+=(-o) find_cmd+=(-iname "*.${exts[$i]}") done unset i find_cmd+=(')') fi unset exts # Minimum/maximum size filters (evaluated in bytes) if [[ -n "$min_size" || -n "$max_size" ]]; then if ! command -v numfmt >/dev/null 2>&1; then disp E "file_stats: --min/--max require 'numfmt' (GNU coreutils). Please install it." return 1 fi fi local bytes if [[ -n "$min_size" ]]; then bytes=$(numfmt --from=iec "$min_size" 2>/dev/null) || { disp E "file_stats: invalid --min size: ${min_size}" return 1 } # find's "-size +N" means "strictly more than N bytes"; the minimum # is inclusive, hence N-1 (nothing to filter for a minimum of 0). (( bytes > 0 )) && find_cmd+=(-size +"$(( bytes - 1 ))"c) fi if [[ -n "$max_size" ]]; then bytes=$(numfmt --from=iec "$max_size" 2>/dev/null) || { disp E "file_stats: invalid --max size: ${max_size}" return 1 } # "-size -N" means "less than N bytes": N+1 makes the maximum inclusive. find_cmd+=(-size -"$(( bytes + 1 ))"c) fi # Execution: one size per line, sorted ascending (the median needs it), # then summarised by awk. The path goes through ENVIRON, as awk -v would # interpret backslashes in it. "${find_cmd[@]}" -printf "%s\n" 2>/dev/null | sort -n | \ FILE_STATS_PATH="$path" \ awk -v human="$human" -v details="$details" -v only_avg="$only_avg" \ -v only_med="$only_med" -v only_count="$only_count" \ -v only_total="$only_total" -v show_all="$show_all" ' # Convert function (i and units are local: extra parameters) function human_readable(x, i, units) { split("B KiB MiB GiB TiB", units) i = 1 while (x >= 1024 && i < 5) { x /= 1024 i++ } return sprintf("%.2f %s", x, units[i]) } # Display function function out(label, val, is_size) { if (human == 1 && is_size == 1) val = human_readable(val) printf "%-20s : %s\n", label, val } { sizes[NR] = $1 total += $1 if (min == "" || $1 < min) min = $1 if (max == "" || $1 > max) max = $1 # Histogram bucket b holds sizes in [1024^b, 1024^(b+1)); bucket 0 # also takes empty files. Computed with an integer loop: a # log() ratio can fall just below an exact power of 1024. v = $1; b = 0 while (v >= 1024) { v = int(v / 1024); b++ } bucket[b]++ if (b > maxb) maxb = b } END { path = ENVIRON["FILE_STATS_PATH"] count = NR if (count == 0) { print "No files found." exit } average = total / count # Median calculation: exact using sorted array values if (count % 2 == 1) { median = sizes[(count + 1) / 2] } else { idx = count / 2 median = (sizes[idx] + sizes[idx + 1]) / 2 } if (only_avg) out("Average size", average, 1) else if (only_med) out("Median size", median, 1) else if (only_count) out("Number of files", count, 0) else if (only_total) out("Total size", total, 1) else { if (show_all || human || details) { printf "Statistics for \"%s\"\n", path printf "-------------------------\n" } out("Number of files", count, 0) out("Total size", total, 1) out("Average size", average, 1) out("Median size", median, 1) out("Minimum size", min, 1) out("Maximum size", max, 1) } if (details) { print "\nSize histogram:" # Walk the buckets in ascending order (awk "for (b in array)" gives # no defined order); empty buckets are skipped. for (b = 0; b <= maxb; b++) { if (!(b in bucket)) continue # Pre-calculate label parts # 1024^0 = 1 (B), 1024^1 = 1K, etc. low = (b == 0) ? 0 : (1024^b) high = 1024^(b+1) label = sprintf("%-9s – %-9s", (b == 0) ? "0" : human_readable(low), human_readable(high)) printf "%-25s : %6d files\n", label, bucket[b] } } }' } export -f file_stats # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Find the biggest files in a directory tree # Usage: findbig [options] [directory] # Options: # -h : display help screen # -d : display details (ls -l) for each file # -x : do not cross filesystem boundaries # -l : limit : number of files to return (default is 10) findbig() { local details=0 limit=${FINDBIG_DEFAULT_LIMIT:-10} one_fs=0 local PARSED PARSED=$(getopt -o hdl:x --long help,details,limit:,one-fs -n 'findbig' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"findbig --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "findbig: Find the N biggest files in a directory tree.\n\n" printf "Usage: findbig [options] [directory]\n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-d, --details\t\tShow detailed file info (ls -ld)\n" printf "\t-l, --limit N\t\tNumber of files to return (default: 10)\n" printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n" return 0 ;; -d|--details) details=1 shift ;; -l|--limit) limit="$2" shift 2 ;; -x|--one-fs) one_fs=1 shift ;; --) shift break ;; *) disp E "Invalid option: $1" return 1 ;; esac done # Checked here so that the configured default (FINDBIG_DEFAULT_LIMIT) is # validated as well as -l. if [[ ! "$limit" =~ ^[1-9][0-9]*$ ]]; then disp E "Invalid limit '${limit}': must be a positive integer." return 1 fi local dir="${1:-.}" # "./" keeps a name starting with "-" from being read as a find option [[ $dir == -* ]] && dir="./$dir" # Prepare find arguments in an array for cleaner handling local find_args=(-L "$dir") (( one_fs )) && find_args+=(-xdev) find_args+=(-type f) # Logic: find files, print size and path, sort numeric reverse, take N local _fd _fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null) if [[ -n "$_fd" ]]; then local _fd_args=(--follow --no-ignore --hidden --absolute-path -t f) (( one_fs )) && _fd_args+=(--one-file-system) _fd_args+=(. "$dir") if (( details )); then local line "$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \ | sort -rn | head -n "$limit" \ | while IFS= read -r line; do local path="${line#* }" ls -ld -- "$path" done unset line else "$_fd" "${_fd_args[@]}" --exec stat --printf="%s %n\n" 2>/dev/null \ | sort -rn | head -n "$limit" fi elif (( details )); then local line find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" | while IFS= read -r line; do local path="${line#* }" ls -ld -- "$path" done unset line else find "${find_args[@]}" -printf "%s %p\n" 2>/dev/null | sort -rn | head -n "$limit" fi } export -f findbig # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Find empty files in a directory tree # Usage: findzero [options] [directory] # Options: # -h : display help screen # -d : display details (ls -l) for each file # -x : do not cross filesystem boundaries # --delete : delete empty files and display their paths findzero() { local delete=0 details=0 one_fs=0 local PARSED # o: options, long: long equivalents PARSED=$(getopt -o hdx --long help,details,one-fs,delete -n 'findzero' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"findzero --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "findzero: Find or delete empty files in a directory tree.\n\n" printf "Usage: findzero [options] [directory]\n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-d, --details\t\tShow detailed file info (ls -ls)\n" printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n" printf "\t--delete\t\tActually remove the empty files\n" return 0 ;; -d|--details) details=1 shift ;; -x|--one-fs) one_fs=1 shift ;; --delete) delete=1 shift ;; --) shift break ;; *) disp E "Invalid option: $1" return 1 ;; esac done local dir="${1:-.}" # "./" keeps a name starting with "-" from being read as a find option [[ $dir == -* ]] && dir="./$dir" # Symlinks are followed to report/count empty targets, but never when # deleting: with -L, a link to an empty file matches and "delete" would # remove the link itself. local follow=1 (( delete )) && follow=0 local find_args=() (( follow )) && find_args+=("-L") find_args+=("$dir" "-type" "f" "-empty") (( one_fs )) && find_args+=("-xdev") # Execution logic local _fd _fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null) if [[ -n "$_fd" ]]; then local _fd_args=(--no-ignore --hidden --absolute-path -t f --size -1b) (( follow )) && _fd_args+=(--follow) (( one_fs )) && _fd_args+=(--one-file-system) _fd_args+=(. "$dir") # fd's "--size -1b" means "at most 1 byte", so it also lists files of # exactly one byte. Every result is therefore re-checked with [[ -s ]] # (true when the size is not zero) and skipped if not empty: deleting a # one-byte file would be data loss. if (( delete )); then disp W "Deleting empty files in $dir..." local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do [[ -s "$f" ]] && continue printf "%s\n" "$f" rm -f -- "$f" done unset f elif (( details )); then local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do [[ -s "$f" ]] && continue ls -ls -- "$f" done unset f else local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do if [[ ! -s "$f" ]]; then printf "%s\n" "$f"; fi done unset f fi elif (( delete )); then disp W "Deleting empty files in $dir..." find "${find_args[@]}" -delete -print elif (( details )); then find "${find_args[@]}" -ls else find "${find_args[@]}" fi } export -f findzero # ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------ # Find dead symbolic links in a directory tree # Usage: finddead [options] [directory] # Options: # -h : display help screen # -d : display details (ls -l) for each link # -x : do not cross filesystem boundaries # --delete : delete dead links and display their paths finddead() { local delete=0 details=0 one_fs=0 local PARSED PARSED=$(getopt -o hdx --long help,details,one-fs,delete -n 'finddead' -- "$@") # shellcheck disable=SC2181 # getopt return code is checked immediately after if [[ $? -ne 0 ]]; then disp E "Invalid options, use \"finddead --help\" to display usage." return 1 fi # Restore the getopt-normalised (shell-quoted) argument list local -a _pargs=() _getopt_split _pargs "$PARSED" set -- "${_pargs[@]}" while true; do case "$1" in -h|--help) printf "finddead: Find or delete dead/broken symbolic links.\n\n" printf "Usage: finddead [options] [directory]\n\n" printf "Options:\n" printf "\t-h, --help\t\tDisplay this help screen\n" printf "\t-d, --details\t\tShow detailed symlink info (ls -ls)\n" printf "\t-x, --one-fs\t\tDo not cross filesystem boundaries\n" printf "\t--delete\t\tActually remove the dead links\n" return 0 ;; -d|--details) details=1 shift ;; -x|--one-fs) one_fs=1 shift ;; --delete) delete=1 shift ;; --) shift break ;; *) disp E "Invalid option: $1" return 1 ;; esac done local dir="${1:-.}" # "./" keeps a name starting with "-" from being read as a find option [[ $dir == -* ]] && dir="./$dir" # -xtype l searches for links that do not point to an existing file local find_args=("$dir" "-xtype" "l") (( one_fs )) && find_args+=("-xdev") # Execution logic local _fd _fd=$(command -v fd 2>/dev/null || command -v fdfind 2>/dev/null) if [[ -n "$_fd" ]]; then # fd -t l lists all symlinks; post-filter broken ones (target unreachable via -e) local _fd_args=(--no-ignore --hidden --absolute-path -t l) (( one_fs )) && _fd_args+=(--one-file-system) _fd_args+=(. "$dir") if (( delete )); then disp W "Deleting dead symlinks in $dir..." local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; rm -f -- "$f"; fi done unset f elif (( details )); then local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do if [[ ! -e "$f" ]]; then ls -ls -- "$f"; fi done unset f else local f "$_fd" "${_fd_args[@]}" 2>/dev/null | while IFS= read -r f; do if [[ ! -e "$f" ]]; then printf "%s\n" "$f"; fi done unset f fi elif (( delete )); then disp W "Deleting dead symlinks in $dir..." find "${find_args[@]}" -delete -print elif (( details )); then find "${find_args[@]}" -ls else find "${find_args[@]}" fi } export -f finddead # ------------------------------------------------------------------------------ # EOF