feat: add find-dupes
This commit is contained in:
1 file changed
+195
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CHUNK = 1024
|
||||||
|
|
||||||
|
G = "\033[32m"
|
||||||
|
Y = "\033[33m"
|
||||||
|
C = "\033[36m"
|
||||||
|
R = "\033[31m"
|
||||||
|
B = "\033[1m"
|
||||||
|
Z = "\033[0m"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size(s: str) -> int:
|
||||||
|
s = s.strip().lower()
|
||||||
|
mul = 1
|
||||||
|
for suf, m in (
|
||||||
|
("kb", 1024), ("k", 1024),
|
||||||
|
("mb", 1024**2), ("m", 1024**2),
|
||||||
|
("gb", 1024**3), ("g", 1024**3),
|
||||||
|
("b", 1),
|
||||||
|
):
|
||||||
|
if s.endswith(suf):
|
||||||
|
s = s[:-len(suf)].strip()
|
||||||
|
mul = m
|
||||||
|
break
|
||||||
|
return int(float(s) * mul)
|
||||||
|
|
||||||
|
|
||||||
|
def human(n: int) -> str:
|
||||||
|
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||||
|
if n < 1024 or unit == "TiB":
|
||||||
|
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
||||||
|
n /= 1024
|
||||||
|
|
||||||
|
|
||||||
|
def prog(stage: str, cur: int, total: int) -> None:
|
||||||
|
msg = f"{stage:<9} | {cur}/{total} files"
|
||||||
|
if not hasattr(prog, "_last"):
|
||||||
|
prog._last = 0
|
||||||
|
pad = max(0, prog._last - len(msg))
|
||||||
|
sys.stderr.write("\r" + C + msg + Z + (" " * pad))
|
||||||
|
sys.stderr.flush()
|
||||||
|
prog._last = len(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("root", nargs="?", default=".")
|
||||||
|
ap.add_argument("--min-size", default="1")
|
||||||
|
ap.add_argument("--max-size", default=None)
|
||||||
|
ap.add_argument("--ignore", action="append", default=[])
|
||||||
|
return ap.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def is_ignored(p: Path, ignore: list[Path]) -> bool:
|
||||||
|
try:
|
||||||
|
rp = p.resolve()
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return any(rp == q or q in rp.parents for q in ignore)
|
||||||
|
|
||||||
|
|
||||||
|
def file_head_tail(p: Path, n: int = CHUNK):
|
||||||
|
try:
|
||||||
|
size = p.stat().st_size
|
||||||
|
with p.open("rb") as f:
|
||||||
|
head = f.read(n)
|
||||||
|
f.seek(max(0, size - n))
|
||||||
|
tail = f.read(n)
|
||||||
|
return head, tail
|
||||||
|
except OSError:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def sha256sum(p: Path) -> str | None:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
try:
|
||||||
|
with p.open("rb") as f:
|
||||||
|
while True:
|
||||||
|
b = f.read(1024 * 1024)
|
||||||
|
if not b:
|
||||||
|
break
|
||||||
|
h.update(b)
|
||||||
|
return h.hexdigest()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def iter_files(root: Path, ignore: list[Path], min_size: int, max_size: int | None):
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dp = Path(dirpath)
|
||||||
|
dirnames[:] = [d for d in dirnames if not (dp / d).is_symlink() and not is_ignored(dp / d, ignore)]
|
||||||
|
|
||||||
|
for name in filenames:
|
||||||
|
p = dp / name
|
||||||
|
if p.is_symlink() or is_ignored(p, ignore):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
size = p.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if size < min_size:
|
||||||
|
continue
|
||||||
|
if max_size is not None and size > max_size:
|
||||||
|
continue
|
||||||
|
yield p, size
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
root = Path(args.root).expanduser().resolve()
|
||||||
|
min_size = parse_size(args.min_size)
|
||||||
|
max_size = parse_size(args.max_size) if args.max_size else None
|
||||||
|
ignore = [Path(p).expanduser().resolve() for p in args.ignore]
|
||||||
|
|
||||||
|
by_size = defaultdict(list)
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
for p, size in iter_files(root, ignore, min_size, max_size):
|
||||||
|
total += 1
|
||||||
|
if total % 100 == 0:
|
||||||
|
prog("Scanning", total, total)
|
||||||
|
by_size[size].append(p)
|
||||||
|
|
||||||
|
if not total:
|
||||||
|
print("No files found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
prog("Scanning", total, total)
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
|
||||||
|
candidates = {s: ps for s, ps in by_size.items() if len(ps) > 1}
|
||||||
|
if not candidates:
|
||||||
|
print("No duplicates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
by_ht = defaultdict(list)
|
||||||
|
ht_total = sum(len(ps) for ps in candidates.values())
|
||||||
|
done = 0
|
||||||
|
for size, ps in candidates.items():
|
||||||
|
for p in ps:
|
||||||
|
done += 1
|
||||||
|
prog("Head/tail", done, ht_total)
|
||||||
|
h, t = file_head_tail(p)
|
||||||
|
if h is not None:
|
||||||
|
by_ht[(size, h, t)].append(p)
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
|
||||||
|
ht_groups = {k: ps for k, ps in by_ht.items() if len(ps) > 1}
|
||||||
|
if not ht_groups:
|
||||||
|
print("No duplicates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
by_hash = defaultdict(list)
|
||||||
|
hash_total = sum(len(ps) for ps in ht_groups.values())
|
||||||
|
done = 0
|
||||||
|
for ps in ht_groups.values():
|
||||||
|
for p in ps:
|
||||||
|
done += 1
|
||||||
|
prog("Hashing", done, hash_total)
|
||||||
|
h = sha256sum(p)
|
||||||
|
if h:
|
||||||
|
by_hash[h].append(p)
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
|
||||||
|
groups = []
|
||||||
|
for h, ps in by_hash.items():
|
||||||
|
if len(ps) > 1:
|
||||||
|
try:
|
||||||
|
size = ps[0].stat().st_size
|
||||||
|
except OSError:
|
||||||
|
size = 0
|
||||||
|
groups.append((len(ps), size, h, ps))
|
||||||
|
|
||||||
|
if not groups:
|
||||||
|
print("No duplicates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
groups.sort(key=lambda x: (-x[0], -x[1]))
|
||||||
|
|
||||||
|
for idx, (count, size, h, ps) in enumerate(groups, 1):
|
||||||
|
print(f"{B}{Y}Group {idx}{Z} ({count} files, {human(size)}) {C}{h[:12]}{Z}")
|
||||||
|
for p in sorted(ps):
|
||||||
|
print(f" {G}{p.resolve()}{Z}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
Reference in new issue
Block a user