Door Anoniem: #!/bin/sh
find $@ -type f -size +0 -print0 | xargs -0 md5sum | sort | uniq --all-repeated=prepend -w32 | sed -e 's/^[0-9a-f]* //'
Mooie one-liner.
Omdat ik zelf in de loop van de tijd ook af en toe behoefte aan zo'n tool had, zowel de vraagsteller als dit en andere antwoorden een praktische richting aangeven, en omdat ik het leuk vind om in Python te knutselen heb ik me daarstraks even kwaad gemaakt met het volgende resultaat:
#!/usr/bin/env python3
"""
Find duplicate files.
Usage:
{script} [path...] [-- cmd [arg...]]
{script} --help
The path arguments can be either directories or regular files. If any
directories are given they serve as roots for the search: only files within
those directories and their subdirectories are compared. If no roots are given
the current directory is used as a root.
If regular files are given only those files and their duplicates are reported.
Paths of duplicate files are printed to stdout, one path per line, with empty
lines between groups of duplicates, unless '--' is given, then the command
specified is executed for each group, with the arguments specified, followed by
the pathnames for the group.
Empty files and symbolic links are ignored.
If --help is given this help text is printed.
"""
import sys
import pathlib
import hashlib
import itertools
import operator
import subprocess
def helptext():
return __doc__.format(script=pathlib.Path(sys.argv[0]).name)
def by_size(path):
return path.stat().st_size
def by_hash(path):
try:
return hashlib.sha1(path.read_bytes()).digest()
except IOError as ex:
print('Error reading {}: {}'.format(path, ex), file=sys.stderr)
return None
def find_dupes(paths, keyfunc):
items = ((keyfunc(path), path) for path in paths)
items = sorted((key, path) for key, path in items if key is not None)
groups = [[i[1] for i in g]
for k, g in itertools.groupby(items, key=operator.itemgetter(0))]
yield from (group for group in groups if len(group) > 1)
def parse_args(args):
args = sys.argv[1:]
if '--help' in args:
print(helptext())
sys.exit()
if '--' in args:
pos = args.index('--')
paths = args[:pos]
cmd = args[pos + 1:]
else:
paths = args
cmd = None
paths = [pathlib.Path(p) for p in paths]
roots = [p for p in paths if p.is_dir()]
match = [p for p in paths if p not in roots]
for p in match:
if not p.is_file():
sys.exit('ERROR, not a file: {}\n{}'.format(p, helptext()))
if not roots:
roots = ['.']
return roots, match, cmd
def main():
roots, match, cmd = parse_args(sys.argv[1:])
paths = (path
for root in roots
for path in pathlib.Path(root).glob('**/*')
if path.is_file()
and not path.is_symlink()
and path.stat().st_size != 0)
same_size = find_dupes(paths, by_size)
if match:
same_size = (group
for group in same_size
if any(m in group for m in match))
same_hash = (group
for sgroup in same_size
for group in find_dupes(sgroup, by_hash))
if match:
same_hash = (group
for group in same_hash
if any(m in group for m in match))
if cmd:
for group in same_hash:
subprocess.run(cmd + group)
else:
for group in same_hash:
for path in sorted(group):
print(path)
print()
if __name__ == '__main__':
main()
Het is op de commandline op de volgende manieren gebruiken:
• Zonder argumenten zoekt het in de current directory en alle subdirectories naar identieke bestanden, en laat ze zien als groepje identieke bestanden met één bestand per regel en lege regels tussen de groepjes.
• Als je een of meer directory's als commandline-argumenten meegeeft dan worden die doorzocht in plaats van de current directory.
• Als je een of meer bestanden als commandline-argumenten meegeeft dan dienen die als filter: alleen de groepjes overeenkomende bestanden waarin minstens een van die bestandsnamen voorkomt worden gerapporteerd.
• De bestands- en directorynamen uit de vorige twee punten mogen door elkaar worden opgegeven.
• Achteraan de argumenten kan '--' worden toegevoegd met daarachter een commando en argumenten die voor elke gevonden groep bestanden worden uitgevoerd. De bestandsnamen uit die groep worden aan de argumenten toegevoegd.
Ik heb dit op Linux uitgewerkt, gebruikmakend van Python 3.7. Ik heb volgens mij niets Linux-specifieks gebruikt, dus zou dit op een Windows-systeem met een voldoende recente versie van Python3 erop moeten werken. Of je er in Windows een uitvoerbaar script-commando van kan maken zoals dat op Linux/Unix mogelijk is moet iemand die daarin thuis is maar vertellen, want ik weet dat niet.
Er wordt eerst naar de file size gekeken en pas als die overeenkomt worden hashes (sha1) berekend om de inhoud mee te vergelijken. Lege bestanden en symbolic links worden overgeslagen.
Voor wie de code probeert te doorgronden zonder thuis te zijn in Python: ik gebruik het feit dat je in Python referenties aan functies als argumenten kan meegeven aan andere functies. De functie die "yield" in plaats van "return" bevat voor het resultaat is een zogenaamde "generator", die niet één resultaat teruggeeft maar een stroom resultaten. Verder leunt de verwerking zwaar op zogenaamde "list comprehensions" en "generator expressions", dat zijn de expressies die eruit zien als combinaties van for-loops en if-statements die tussen respectievelijk blokhaken en ronde haken staan. Als je naar die termen zoekt is er online volop uitleg erover te vinden.
De voor deze toepassing nuttige magie van generator expressions is dat ze het mogelijk maken om acties die complete loops vormen als statements te noteren die lezen alsof ze een voor een worden uitgevoerd, met zelfs optionele bewerkingen ertussen, terwijl ze op runtime een pijplijn vormen waarin de verwerkte items stuk voor stuk door opeenvolgende bewerkingen worden getrokken. Voor dit programma betekent dat dat de resultaten niet pas getoond worden als alle hashes zijn berekend maar al vrij snel beginnen te verschijnen (wel moeten alle file sizes al opgezocht zijn), terwijl het geheugengebruik ook bij grote verzamelingen bestanden binnen de perken blijft omdat niet alle tussenresultaten in geheugen bewaard hoeven te blijven. Voor kenners van functionele programmeertalen als Haskell: zo ziet lazy evaluation eruit in Python.