The same ISO in three download folders, a photo library copied “just in case,” build artifacts checked in twice — duplicates quietly eat disk. Here’s a pure-Ruby finder that reports exactly how much space you’d reclaim, reading as few bytes as possible.
Step through the build below — the problem, the full script, the three-stage design, and the real test output:
Naively finding duplicates means hashing every file — brutal on a directory full of large media. The trick is to read as little as possible: bucket by size first (unique sizes can’t be duplicates), then a cheap 64 KiB partial hash, then a full hash only for the survivors. Large unique files are never fully read.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# duplicate_file_finder.rb -- find duplicate files and reclaimable space.
#
# Duplicate files quietly eat disk: the same ISO in three download folders,
# a photo library copied "just in case", build artifacts checked in twice.
# This finds byte-for-byte duplicates efficiently and reports how much space
# you'd get back by keeping one copy of each.
#
# ruby duplicate_file_finder.rb ~/Downloads ~/Documents
# ruby duplicate_file_finder.rb /data --min-size 1048576 # ignore < 1 MiB
# ruby duplicate_file_finder.rb /data --json
# ruby duplicate_file_finder.rb /data --script > dedup.sh # emit rm commands
#
# Efficiency: files are first grouped by SIZE (a cheap stat). Only groups with
# 2+ same-size files are hashed, and hashing is done in two stages -- a fast
# partial hash of the first 64 KiB, then a full SHA-256 only for partial-hash
# collisions -- so huge unique files are never fully read.
#
# Stdlib only: find, digest, json, optparse. No gems. Cross-platform.
require 'find'
require 'digest'
require 'json'
require 'optparse'
options = { min_size: 1, json: false, script: false }
OptionParser.new do |o|
o.banner = 'Usage: ruby duplicate_file_finder.rb DIR [DIR...] [options]'
o.on('--min-size BYTES', Integer, 'ignore files smaller than BYTES (default 1)') { |v| options[:min_size] = v }
o.on('--json', 'JSON output') { options[:json] = true }
o.on('--script', 'emit shell rm commands (keeps the first of each group)') { options[:script] = true }
end.parse!
roots = ARGV
abort('error: give me at least one directory to scan') if roots.empty?
def human(bytes)
units = %w[B KiB MiB GiB TiB]
size = bytes.to_f; i = 0
while size >= 1024 && i < units.size - 1
size /= 1024; i += 1
end
format(i.zero? ? '%d %s' : '%.1f %s', size, units[i])
end
PARTIAL = 64 * 1024 # bytes read for the fast pre-hash
def partial_hash(path)
File.open(path, 'rb') { |f| Digest::SHA256.hexdigest(f.read(PARTIAL) || '') }
end
def full_hash(path)
d = Digest::SHA256.new
File.open(path, 'rb') { |f| d.update(f.read(1 << 20)) until f.eof? }
d.hexdigest
end
# --- stage 1: group candidate files by size --------------------------------
by_size = Hash.new { |h, k| h[k] = [] }
scanned = 0
roots.each do |root|
Find.find(File.expand_path(root)) do |path|
stat = File.lstat(path)
next unless stat.file? && stat.size >= options[:min_size]
scanned += 1
by_size[stat.size] << path
rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
next
end
end
# --- stage 2 + 3: partial hash, then full hash only for collisions ---------
dupes = [] # [ [path, path, ...], size ]
by_size.each do |size, paths|
next if paths.size < 2
paths.group_by { |p| partial_hash(p) rescue nil }.each_value do |same_partial|
next if same_partial.size < 2
same_partial.group_by { |p| full_hash(p) rescue nil }.each_value do |same_full|
dupes << [same_full, size] if same_full.compact.size > 1
end
end
end
dupes.sort_by! { |group, size| -(size * (group.size - 1)) } # biggest wins first
reclaimable = dupes.sum { |group, size| size * (group.size - 1) }
if options[:script]
puts '#!/bin/sh'
puts "# review before running -- keeps the FIRST file of each duplicate group"
dupes.each do |group, _|
group.drop(1).each { |p| puts "rm -- #{p.inspect}" }
end
elsif options[:json]
puts JSON.pretty_generate('scanned' => scanned,
'duplicate_groups' => dupes.size,
'reclaimable_bytes' => reclaimable,
'groups' => dupes.map { |g, s| { 'size' => s, 'copies' => g.size, 'files' => g } })
else
puts "duplicate file finder -- scanned #{scanned} files in #{roots.join(', ')}"
puts
dupes.each do |group, size|
puts "#{group.size} copies x #{human(size)} (reclaim #{human(size * (group.size - 1))})"
group.each { |p| puts " #{p}" }
end
puts
puts "#{dupes.size} duplicate groups, #{human(reclaimable)} reclaimable"
end
exit(dupes.empty? ? 0 : 1) # exit 1 when duplicates exist -> easy cron gating
Stage 1 groups by lstat size. Stage 2 SHA-256s the first 64 KiB of each same-size file. Stage 3 fully hashes only files that also share a partial hash, confirming byte-for-byte duplicates. Groups are ranked by reclaimable space, and --script emits reviewable rm commands that keep the first copy of each group.
duplicate file finder -- scanned 6 files in /tmp/duptest
2 copies x 195.3 KiB (reclaim 195.3 KiB)
/tmp/duptest/x/big.bin
/tmp/duptest/y/big_dup.bin
3 copies x 30 B (reclaim 60 B)
/tmp/duptest/x/a.txt
/tmp/duptest/y/a_copy.txt
/tmp/duptest/y/a_copy2.txt
2 duplicate groups, 195.4 KiB reclaimable
exit=1
Full script + README on GitHub: ruby-devops-toolkit/duplicate-file-finder
What you need
- Ruby 2.7+ (tested on 3.0.2) — stdlib only:
find,digest,json,optparse. No gems. - Linux, macOS, or Windows.
duplicate_file_finder.rb
The complete script. The walkthrough explains the three-stage hashing that keeps it fast.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# duplicate_file_finder.rb -- find duplicate files and reclaimable space.
#
# Duplicate files quietly eat disk: the same ISO in three download folders,
# a photo library copied "just in case", build artifacts checked in twice.
# This finds byte-for-byte duplicates efficiently and reports how much space
# you'd get back by keeping one copy of each.
#
# ruby duplicate_file_finder.rb ~/Downloads ~/Documents
# ruby duplicate_file_finder.rb /data --min-size 1048576 # ignore < 1 MiB
# ruby duplicate_file_finder.rb /data --json
# ruby duplicate_file_finder.rb /data --script > dedup.sh # emit rm commands
#
# Efficiency: files are first grouped by SIZE (a cheap stat). Only groups with
# 2+ same-size files are hashed, and hashing is done in two stages -- a fast
# partial hash of the first 64 KiB, then a full SHA-256 only for partial-hash
# collisions -- so huge unique files are never fully read.
#
# Stdlib only: find, digest, json, optparse. No gems. Cross-platform.
require 'find'
require 'digest'
require 'json'
require 'optparse'
options = { min_size: 1, json: false, script: false }
OptionParser.new do |o|
o.banner = 'Usage: ruby duplicate_file_finder.rb DIR [DIR...] [options]'
o.on('--min-size BYTES', Integer, 'ignore files smaller than BYTES (default 1)') { |v| options[:min_size] = v }
o.on('--json', 'JSON output') { options[:json] = true }
o.on('--script', 'emit shell rm commands (keeps the first of each group)') { options[:script] = true }
end.parse!
roots = ARGV
abort('error: give me at least one directory to scan') if roots.empty?
def human(bytes)
units = %w[B KiB MiB GiB TiB]
size = bytes.to_f; i = 0
while size >= 1024 && i < units.size - 1
size /= 1024; i += 1
end
format(i.zero? ? '%d %s' : '%.1f %s', size, units[i])
end
PARTIAL = 64 * 1024 # bytes read for the fast pre-hash
def partial_hash(path)
File.open(path, 'rb') { |f| Digest::SHA256.hexdigest(f.read(PARTIAL) || '') }
end
def full_hash(path)
d = Digest::SHA256.new
File.open(path, 'rb') { |f| d.update(f.read(1 << 20)) until f.eof? }
d.hexdigest
end
# --- stage 1: group candidate files by size --------------------------------
by_size = Hash.new { |h, k| h[k] = [] }
scanned = 0
roots.each do |root|
Find.find(File.expand_path(root)) do |path|
stat = File.lstat(path)
next unless stat.file? && stat.size >= options[:min_size]
scanned += 1
by_size[stat.size] << path
rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
next
end
end
# --- stage 2 + 3: partial hash, then full hash only for collisions ---------
dupes = [] # [ [path, path, ...], size ]
by_size.each do |size, paths|
next if paths.size < 2
paths.group_by { |p| partial_hash(p) rescue nil }.each_value do |same_partial|
next if same_partial.size < 2
same_partial.group_by { |p| full_hash(p) rescue nil }.each_value do |same_full|
dupes << [same_full, size] if same_full.compact.size > 1
end
end
end
dupes.sort_by! { |group, size| -(size * (group.size - 1)) } # biggest wins first
reclaimable = dupes.sum { |group, size| size * (group.size - 1) }
if options[:script]
puts '#!/bin/sh'
puts "# review before running -- keeps the FIRST file of each duplicate group"
dupes.each do |group, _|
group.drop(1).each { |p| puts "rm -- #{p.inspect}" }
end
elsif options[:json]
puts JSON.pretty_generate('scanned' => scanned,
'duplicate_groups' => dupes.size,
'reclaimable_bytes' => reclaimable,
'groups' => dupes.map { |g, s| { 'size' => s, 'copies' => g.size, 'files' => g } })
else
puts "duplicate file finder -- scanned #{scanned} files in #{roots.join(', ')}"
puts
dupes.each do |group, size|
puts "#{group.size} copies x #{human(size)} (reclaim #{human(size * (group.size - 1))})"
group.each { |p| puts " #{p}" }
end
puts
puts "#{dupes.size} duplicate groups, #{human(reclaimable)} reclaimable"
end
exit(dupes.empty? ? 0 : 1) # exit 1 when duplicates exist -> easy cron gating
How it works — three stages, minimal reads
Group by size first
Files are bucketed by lstat size. A file with a unique size cannot have a duplicate, so it’s eliminated before any hashing — which means large one-off files (an ISO, a video) are never read at all.
Partial hash, then full hash
For each size bucket with two or more files, only the first 64 KiB of each file is SHA-256’d. That cheaply splits most coincidental same-size files. Only files that also share that partial hash are then read in full and hashed to confirm a true byte-for-byte duplicate.
Reclaimable space and a safe delete script
Groups are sorted so the biggest reclaimable wins show first. --script emits rm commands that keep the first file of each group — it never deletes anything itself, so you review before running.
Reading the results
- Two files look identical but aren’t grouped. They differ by a byte (a trailing newline, embedded metadata); this tool is exact by design.
- Symlinks are skipped (
lstat, notstat), so a symlink is never reported as a duplicate of its target. - Review before deleting.
--scriptprints commands; always eyeball which copy it keeps.
Where to take it next
Add --hardlink to replace duplicates with hard links instead of deleting; swap SHA-256 for BLAKE2 for faster full hashes; or add --exclude globs to skip .git and node_modules.