Skip to content

Instantly share code, notes, and snippets.

@KernelGhost
Last active December 29, 2025 00:25
Show Gist options
  • Select an option

  • Save KernelGhost/2e1559e93ab89c283c838e01c95a2cc6 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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