Skip to content

Instantly share code, notes, and snippets.

@anvanvan
Last active January 21, 2026 04:12
Show Gist options
  • Select an option

  • Save anvanvan/8a351832e8c0170ec0276a60a20c8aa2 to your computer and use it in GitHub Desktop.

Select an option

Save anvanvan/8a351832e8c0170ec0276a60a20c8aa2 to your computer and use it in GitHub Desktop.
diff_dirs - Quick directory comparison using rsync (bash/zsh/fish)
diff_dirs() {
local group=0
while [[ "$1" == -* ]]; do
case "$1" in
-g|--group) group=1; shift ;;
-h|--help)
echo "Usage: diff_dirs [-g] <folderA> <folderB>"
echo ""
echo "Quick directory comparison using rsync (compares by file size only)."
echo ""
echo "Options:"
echo " -g, --group Group output by collapsing exclusive directories"
echo ""
echo "Output:"
echo " [ONLY in A] - file exists only in folderA"
echo " [ONLY in B] - file exists only in folderB"
echo " [CHANGED] - file exists in both but differs"
return 1
;;
*) echo "Unknown option: $1"; return 1 ;;
esac
done
if [[ $# -lt 2 ]]; then
echo "Usage: diff_dirs [-g] <folderA> <folderB>"
return 1
fi
local a="${1%/}" b="${2%/}"
local nameA=$(basename "$a")
local nameB=$(basename "$b")
# If basenames are the same, use parent/basename
if [[ "$nameA" == "$nameB" ]]; then
nameA="$(basename "$(dirname "$a")")/$nameA"
nameB="$(basename "$(dirname "$b")")/$nameB"
fi
if [[ $group -eq 1 ]]; then
# Grouped output: collapse exclusive directories
rsync -rin8 --size-only --delete "$a/" "$b/" | awk \
-v labelA="$nameA" -v labelB="$nameB" '
{ lines[NR] = $0 }
END {
for (i = 1; i <= NR; i++) {
line = lines[i]
if (line ~ /^\*deleting +[^\/]+\/$/) {
path = line; sub(/^\*deleting +/, "", path)
excl_b[path] = 1
}
if (line ~ /^cd\+* +[^\/]+\/$/) {
path = line; sub(/^[^ ]+ +/, "", path)
excl_a[path] = 1
}
}
for (i = 1; i <= NR; i++) {
line = lines[i]
if (line ~ /^\*deleting/) {
path = line; sub(/^\*deleting +/, "", path)
skip = 0
for (d in excl_b) { if (path != d && index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelB, path
continue
}
if (line ~ /^cd\+/) {
path = line; sub(/^[^ ]+ +/, "", path)
skip = 0
for (d in excl_a) { if (path != d && index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelA, path
continue
}
if (line ~ /^>f\+\+\+\+\+/) {
path = line; sub(/^[^ ]+ +/, "", path)
skip = 0
for (d in excl_a) { if (index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelA, path
continue
}
if (line ~ /^>f.s/) {
path = line; sub(/^[^ ]+ +/, "", path)
printf "[CHANGED%17s] %s\n", "", path
}
}
}
'
else
# Verbose output (default)
rsync -rin8 --size-only --delete "$a/" "$b/" | awk \
-v labelA="$nameA" -v labelB="$nameB" '
/^\*deleting/ {sub(/^\*deleting +/, ""); printf "[ONLY %-20s] %s\n", labelB, $0; next}
/^>f\+\+\+\+\+/ {sub(/^[^ ]+ +/, ""); printf "[ONLY %-20s] %s\n", labelA, $0; next}
/^>f.s/ {sub(/^[^ ]+ +/, ""); printf "[CHANGED%17s] %s\n", "", $0}
'
fi
}
function diff_dirs -d "Quick directory comparison using rsync"
argparse 'g/group' 'h/help' -- $argv
or return 1
if set -q _flag_help; or test (count $argv) -lt 2
echo "Usage: diff_dirs [-g] <folderA> <folderB>"
echo ""
echo "Quick directory comparison using rsync (compares by file size only)."
echo ""
echo "Options:"
echo " -g, --group Group output by collapsing exclusive directories"
echo ""
echo "Output:"
echo " [ONLY in A] - file exists only in folderA"
echo " [ONLY in B] - file exists only in folderB"
echo " [CHANGED] - file exists in both but differs"
return 1
end
set -l a (string trim -r -c "/" $argv[1])
set -l b (string trim -r -c "/" $argv[2])
set -l nameA (basename $a)
set -l nameB (basename $b)
# If basenames are the same, use parent/basename
if test "$nameA" = "$nameB"
set nameA (basename (dirname $a))/$nameA
set nameB (basename (dirname $b))/$nameB
end
if set -q _flag_group
# Grouped output: collapse exclusive directories
rsync -rin8 --size-only --delete "$a/" "$b/" | awk \
-v labelA="$nameA" -v labelB="$nameB" '
{ lines[NR] = $0 }
END {
# Find top-level exclusive directories
for (i = 1; i <= NR; i++) {
line = lines[i]
if (line ~ /^\*deleting +[^\/]+\/$/) {
path = line; sub(/^\*deleting +/, "", path)
excl_b[path] = 1
}
if (line ~ /^cd\+* +[^\/]+\/$/) {
path = line; sub(/^[^ ]+ +/, "", path)
excl_a[path] = 1
}
}
# Output with collapsing
for (i = 1; i <= NR; i++) {
line = lines[i]
if (line ~ /^\*deleting/) {
path = line; sub(/^\*deleting +/, "", path)
skip = 0
for (d in excl_b) { if (path != d && index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelB, path
continue
}
if (line ~ /^cd\+/) {
path = line; sub(/^[^ ]+ +/, "", path)
skip = 0
for (d in excl_a) { if (path != d && index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelA, path
continue
}
if (line ~ /^>f\+\+\+\+\+/) {
path = line; sub(/^[^ ]+ +/, "", path)
skip = 0
for (d in excl_a) { if (index(path, d) == 1) skip = 1 }
if (!skip) printf "[ONLY %-20s] %s\n", labelA, path
continue
}
if (line ~ /^>f.s/) {
path = line; sub(/^[^ ]+ +/, "", path)
printf "[CHANGED%17s] %s\n", "", path
}
}
}
'
else
# Verbose output (default)
rsync -rin8 --size-only --delete "$a/" "$b/" | awk \
-v labelA="$nameA" -v labelB="$nameB" '
/^\*deleting/ {sub(/^\*deleting +/, ""); printf "[ONLY %-20s] %s\n", labelB, $0; next}
/^>f\+\+\+\+\+/ {sub(/^[^ ]+ +/, ""); printf "[ONLY %-20s] %s\n", labelA, $0; next}
/^>f.s/ {sub(/^[^ ]+ +/, ""); printf "[CHANGED%17s] %s\n", "", $0}
'
end
end

diff_dirs

Quick directory comparison using rsync. Compares by file size only (fast).

Installation

Bash/Zsh: Add diff_dirs.bash to ~/.bashrc or ~/.zshrc

Fish: Save diff_dirs.fish to ~/.config/fish/functions/

Usage

diff_dirs [-g] <folderA> <folderB>

Options:
  -g, --group  Collapse exclusive directories into single line

Output:
  [ONLY in A]  - file exists only in folderA
  [ONLY in B]  - file exists only in folderB
  [CHANGED]    - file exists in both but differs

Example

$ diff_dirs ~/project-v1 ~/project-v2
[ONLY project-v1        ] old-file.txt
[ONLY project-v1        ] legacy/config.json
[ONLY project-v1        ] legacy/utils.js
[ONLY project-v2        ] new-feature.ts
[CHANGED                ] README.md
[CHANGED                ] src/index.js

$ diff_dirs -g ~/project-v1 ~/project-v2
[ONLY project-v1        ] old-file.txt
[ONLY project-v1        ] legacy/
[ONLY project-v2        ] new-feature.ts
[CHANGED                ] README.md
[CHANGED                ] src/index.js

How it works

Uses rsync -rin8 --size-only --delete in dry-run mode:

  • -r recursive
  • -i itemized output
  • -n dry-run (no changes)
  • -8 preserve unicode filenames
  • --size-only compare by size, not checksum (fast)
  • --delete shows files only in destination

License

MIT

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment