Last active
December 29, 2025 00:25
-
-
Save KernelGhost/2e1559e93ab89c283c838e01c95a2cc6 to your computer and use it in GitHub Desktop.
This Bash script performs a checksum-based comparison of two directories, identifying missing, extra and modified files.
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 | |
| set -euo pipefail | |
| # Enforce C locale globally to ensure 'sort' and 'join' behavior is identical | |
| export LC_ALL=C | |
| # Define the delimiter | |
| readonly DELIM=$'\x1F' # ASCII Unit Separator | |
| # Create a temporary directory | |
| if [ -z "${TMPDIR:-}" ]; then | |
| TMPDIR="/tmp" | |
| fi | |
| tmpdir="$(mktemp -d "${TMPDIR%/}/fcv.XXXXXXXX")" | |
| # Define temporary file paths | |
| readonly dir1_relative_subdir_paths="${tmpdir}/dir1_relative_subdir_paths.bin" | |
| readonly dir2_relative_subdir_paths="${tmpdir}/dir2_relative_subdir_paths.bin" | |
| readonly dir1_only_relative_subdir_paths="${tmpdir}/dir1_only_relative_subdir_paths.bin" | |
| readonly dir2_only_relative_subdir_paths="${tmpdir}/dir2_only_relative_subdir_paths.bin" | |
| readonly dir1_relative_file_paths="${tmpdir}/dir1_relative_file_paths.bin" | |
| readonly dir2_relative_file_paths="${tmpdir}/dir2_relative_file_paths.bin" | |
| readonly dir1_only_relative_file_paths="${tmpdir}/dir1_only_relative_file_paths.bin" | |
| readonly dir2_only_relative_file_paths="${tmpdir}/dir2_only_relative_file_paths.bin" | |
| readonly dir1_manifest="${tmpdir}/dir1_manifest.bin" | |
| readonly dir2_manifest="${tmpdir}/dir2_manifest.bin" | |
| # Track whether directory structure / file lists differ | |
| structure_mismatch=0 | |
| # Ensure temporary files are deleted on script exit | |
| # shellcheck disable=SC2329 | |
| clean() { [[ -n "${tmpdir:-}" ]] && rm -rf -- "$tmpdir"; } | |
| trap clean EXIT | |
| # Functions: Error, Warning & Success | |
| error() { printf '\033[1;31m[ERROR]\033[0m %s - %s\n' "$1" "$2" >&2; } | |
| warn() { printf '\033[1;33m[WARNING]\033[0m %s - %s\n' "$1" "$2"; } | |
| success() { printf '\033[1;32m[SUCCESS]\033[0m %s - %s\n' "$1" "$2"; } | |
| # Function: Worker Task | |
| # shellcheck disable=SC2329 | |
| worker_task() { | |
| # $1 = File path | |
| # $2 = Delimiter | |
| set -euo pipefail | |
| local file="$1" | |
| local delim="$2" | |
| # Strip leading ./ | |
| local rel="${file#./}" | |
| # Calculate hash | |
| local h | |
| h="$(b3sum --no-names -- "$file")" | |
| # Output: Path<DELIM>Hash<NUL> | |
| printf "%s%s%s\0" "$rel" "$delim" "$h" | |
| } | |
| # Export above function so it is visible to parallel's subshells | |
| export -f worker_task | |
| # Function: Checksum Generation | |
| hash_parallel() { | |
| local base_path="$1" | |
| local output_file="$2" | |
| local directory_label="$3" | |
| local delimiter="$4" | |
| base_path="${base_path%/}" # Remove trailing slash | |
| # Find number of CPU cores (default to 4) | |
| local jobs | |
| jobs="$(nproc 2>/dev/null || echo 4)" | |
| # Print feedback | |
| printf "Hashing %s content using %s cores...\n" "$directory_label" "$jobs" | |
| # Calculate checksums | |
| # Output format per record: relative_path<$DELIM>hash<NUL> | |
| ( | |
| cd "$base_path" || return 1 | |
| # The "_" becomes $0 (script name), "{}" becomes $1 (the file) and "$delimiter" becomes $2 | |
| find . -type f -print0 | \ | |
| parallel -0 --jobs "$jobs" --will-cite --group --bar --halt soon,fail=1 --env worker_task --unsafe worker_task {} "$delimiter" | |
| ) >> "$output_file" | |
| printf "\n" | |
| } | |
| # Greet user | |
| printf '\n################################################################\n' | |
| printf '################# \033[1;31mFOLDER CHECKSUM VERIFICATION\033[0m #################\n' | |
| printf '#### \033[1;34mQuick & Dirty Multiple Thread Folder Comparison Script\033[0m ####\n' | |
| printf '################################################################\n\n' | |
| # Check dependencies | |
| for dep in "find" "sort" "comm" "join" "awk" "parallel" "b3sum" "tr" "wc" "realpath"; do | |
| if ! command -v "$dep" &> /dev/null; then | |
| error "MISSING DEPENDENCY" "$dep is missing! Please install it using your package manager." | |
| exit 1 | |
| fi | |
| done | |
| # Check argument number | |
| if [ "$#" -ne 2 ]; then | |
| error "BAD ARGUMENTS" "Usage: $0 <directory_1> <directory_2>" | |
| exit 2 | |
| fi | |
| # Check arguments are valid directory paths | |
| if [ ! -d "$1" ] || [ ! -d "$2" ]; then | |
| [ ! -d "$1" ] && error "BAD PATH" "$1 is not a directory or does not exist!" | |
| [ ! -d "$2" ] && error "BAD PATH" "$2 is not a directory or does not exist!" | |
| exit 3 | |
| fi | |
| # Store arguments | |
| directory_1=$(realpath -- "$1") | |
| directory_2=$(realpath -- "$2") | |
| # Reject unsupported file names (those containing $DELIM) | |
| if IFS= read -r -d '' _ < <(find "$directory_1" "$directory_2" -name "*$DELIM*" -print0 -quit); then | |
| error "UNSUPPORTED FILENAMES" "Filenames containing the 'ASCII Unit Separator' (0x1F) are not supported!" | |
| exit 4 | |
| fi | |
| # Print feedback | |
| printf 'Scanning subdirectory structure in both directories (this may take a while)...\n' | |
| # Locate missing/extra subdirectories | |
| find "$directory_1" -mindepth 1 -type d -printf '%P\0' | sort -z > "$dir1_relative_subdir_paths" | |
| find "$directory_2" -mindepth 1 -type d -printf '%P\0' | sort -z > "$dir2_relative_subdir_paths" | |
| # -z: NUL-delimited input/output | |
| # -23: Suppress columns 2 and 3 -> Items only in file 1 | |
| # -13: Suppress columns 1 and 3 -> Items only in file 2 | |
| comm -z -23 "$dir1_relative_subdir_paths" "$dir2_relative_subdir_paths" > "$dir1_only_relative_subdir_paths" | |
| comm -z -13 "$dir1_relative_subdir_paths" "$dir2_relative_subdir_paths" > "$dir2_only_relative_subdir_paths" | |
| if [[ -s "$dir1_only_relative_subdir_paths" || -s "$dir2_only_relative_subdir_paths" ]]; then | |
| structure_mismatch=1 | |
| warn "MISSING FOLDERS" "Certain subdirectories do not exist within both directories." | |
| if [[ -s "$dir1_only_relative_subdir_paths" ]]; then | |
| printf "FOLDERS ONLY IN '%s':\n" "$directory_1" | |
| while IFS= read -r -d '' dir; do | |
| printf -- '- "/%s"\n' "$dir" | |
| done < "$dir1_only_relative_subdir_paths" | |
| printf '\n' | |
| fi | |
| if [[ -s "$dir2_only_relative_subdir_paths" ]]; then | |
| printf "FOLDERS ONLY IN '%s':\n" "$directory_2" | |
| while IFS= read -r -d '' dir; do | |
| printf -- '- "/%s"\n' "$dir" | |
| done < "$dir2_only_relative_subdir_paths" | |
| printf '\n' | |
| fi | |
| fi | |
| # Print feedback | |
| printf 'Scanning file lists in both directories (this may take a while)...\n' | |
| # Locate missing/extra files | |
| find "$directory_1" -type f -printf '%P\0' | sort -z > "$dir1_relative_file_paths" | |
| find "$directory_2" -type f -printf '%P\0' | sort -z > "$dir2_relative_file_paths" | |
| # -z: NUL-delimited input/output | |
| # -23: Suppress columns 2 and 3 -> Items only in file 1 | |
| # -13: Suppress columns 1 and 3 -> Items only in file 2 | |
| comm -z -23 "$dir1_relative_file_paths" "$dir2_relative_file_paths" > "$dir1_only_relative_file_paths" | |
| comm -z -13 "$dir1_relative_file_paths" "$dir2_relative_file_paths" > "$dir2_only_relative_file_paths" | |
| if [[ -s "$dir1_only_relative_file_paths" || -s "$dir2_only_relative_file_paths" ]]; then | |
| structure_mismatch=1 | |
| warn "MISSING FILES" "Certain files do not exist within both directories." | |
| if [[ -s "$dir1_only_relative_file_paths" ]]; then | |
| printf "FILES ONLY IN '%s':\n" "$directory_1" | |
| while IFS= read -r -d '' file; do | |
| printf -- '- "/%s"\n' "$file" | |
| done < "$dir1_only_relative_file_paths" | |
| printf '\n' | |
| fi | |
| if [[ -s "$dir2_only_relative_file_paths" ]]; then | |
| printf "FILES ONLY IN '%s':\n" "$directory_2" | |
| while IFS= read -r -d '' file; do | |
| printf -- '- "/%s"\n' "$file" | |
| done < "$dir2_only_relative_file_paths" | |
| printf '\n' | |
| fi | |
| fi | |
| # Generate checksums | |
| hash_parallel "$directory_1" "$dir1_manifest" "Directory 1" "$DELIM" | |
| hash_parallel "$directory_2" "$dir2_manifest" "Directory 2" "$DELIM" | |
| # Sort manifest output by relative paths (first field) (NUL-terminated records, ASCII unit separator-separated fields) | |
| sort -z -t "$DELIM" -k1,1 -o "$dir1_manifest" "$dir1_manifest" | |
| sort -z -t "$DELIM" -k1,1 -o "$dir2_manifest" "$dir2_manifest" | |
| # Compare checksums | |
| # Performing an inner join on relative paths ensures files that do not exist in both parent directories are excluded from comparison. | |
| checksum_mismatches="$(join -z -t "$DELIM" -1 1 -2 1 "$dir1_manifest" "$dir2_manifest" | awk -v RS='\0' -F "$DELIM" ' | |
| BEGIN { | |
| BLUE="\033[34m"; | |
| RED="\033[31m"; | |
| RESET="\033[0m"; | |
| } | |
| # Join default output: key, file1_field2, file2_field2 | |
| # Key = relpath | |
| # $2 = hash in dir1 | |
| # $3 = hash in dir2 | |
| NF >= 3 && $2 != $3 { | |
| printf BLUE "%s" RESET " =/= " RED "%s" RESET " - " "\"/%s\"\n", $2, $3, $1; | |
| } | |
| ' | |
| )" | |
| if [[ "$structure_mismatch" -eq 1 ]]; then | |
| warn "DIRECTORY CONTENTS DIFFER" "Some files or folders exist in only one directory. See earlier output for details." | |
| fi | |
| if [[ -n "$checksum_mismatches" ]]; then | |
| warn "DIFFERING FILE CONTENTS" "Not all files within both folders are identical." | |
| printf 'The following files have differing file contents:\n' | |
| printf '%b\n' "$checksum_mismatches" | |
| else | |
| success "IDENTICAL FILE CONTENTS" "All common files located in both directories are identical." | |
| fi | |
| # Terminate | |
| printf '\n' | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment