#! /usr/bin/perl # This script parses a whole directory tree for duplicate files. It uses an # efficient method of checking filesizes first, and hashing only when # filesizes match. It returns files that are duplicates in groups delineated # by blank lines to stdout. Man, that sounds nerdy. # # Optional first argument is the directory tree to scan. Defaults to cwd. # Optional remaining arguments are filename regexes to exclude. Defaults to # null. # # Force me to write this properly. use warnings; use strict; # Get the find and current working directory modules. use File::Find; use Cwd; # Initialise global directory name scalar. my $dir; # Initialise a couple of important global-scope hashes. my ( %sizes, %matches ); # Open output as stdout. You could change this, but it's easier just to pipe it. open OUTPUT, '>>', '/dev/stdout'; # If no arguments, work with the current working directory. if ( ! $ARGV[0] ) { $dir = cwd; } # If arguments, check for existence, then confirm as directory to scan. elsif ( -d $ARGV[0] ) { $dir = shift @ARGV; } # If it doesn't exist, halt. else { print 'Directory '.$ARGV[0].' not found.'."\n"; exit 1; } # Any further arguments are regex exclusions. This could very well be null. That's fine. my @exclusions = @ARGV; # Declare a sub that returns filesizes. sub filesize { return ( stat( shift ) )[7]; } # Declare a sub that returns inodes. sub inode { return ( stat( shift ) )[1]; } # Declare a sub that returns checksums. sub checksum { my $file = shift; return substr( `md5sum "$file"`, 0, 32 ); } # Declare the wanted sub ... sub wanted { # If it's a file ... if ( -f $File::Find::name ) { # Just to make this easier ... my $file = $File::Find::name; # Check it doesn't match any exclusions. my $exclude = 0; foreach my $exclusion ( @exclusions ) { $exclude = 1 if ( $file =~ m/$exclusion/ ); } # No? Good! Start processing it. if ( ! $exclude ) { # Get filesize and add it to hash. my $size = filesize( $file ); $sizes{ $file } = $size; # Start a matches array reference. $matches{ $file } = [ ]; } } } # ... and start the find process on the directory! find \&wanted, $dir; # Get a sorted list of the sizes keys by filesize. This groups them nicely. my @files_sorted = sort {$sizes{$a} <=> $sizes{$b}} keys %sizes; # Start with an impossible filesize and a null filename. my $any_matches = 0; my $current_size = -1; my $current_matching; # For each of the files in that sorted array, foreach my $file ( @files_sorted ) { # ... check that: if ( # The filesizes according to the %sizes hash match; $sizes{$file} == $current_size and # ... they aren't the same inode, in which case it's probably an intentional hard link; inode( $current_matching ) != inode( $file ) and # ... the MD5 hashes match. checksum( $current_matching ) eq checksum( $file ) ) { # If so, conclude it's a duplicate, and add it to the matches for this file. $any_matches = 1; push @{$matches{$current_matching}}, $file; } # Otherwise, start a new group and keep iterating. else { $current_matching = $file; $current_size = $sizes{$file} } } # The flag for any matches is still false? Success! if ( ! $any_matches ) { print 'No matches.', "\n"; exit 0; } # We don't need these now. undef $any_matches; undef $current_matching; undef $current_size; undef $dir; undef @exclusions; undef @files_sorted; undef %sizes; # Start printing some output. print OUTPUT "\n", 'Summary of matches:', "\n\n"; # Print each group of duplicate files! foreach my $match ( keys %matches ) { # Glean the matches for this file by dereferencing that array reference from the matches hash. my @this_matches = @{$matches{$match}}; # If there are matches, print them. if ( $#this_matches > -1 ) { print OUTPUT $match, "\n"; print OUTPUT $_, "\n" foreach ( @this_matches ); print OUTPUT "\n"; } } # Close the output, whatever it is, and we're done. close OUTPUT; exit 0;