Restructure scan into three phases: walk+stat, hash, update. The stat pass is folded into the walk workers: each regular file is lstatted as its directory is read, while the metadata is hot. The walk builds a scan-wide size census (walked files plus records outside the scan roots), and unchanged already-hashed files resolve during the walk without further work. Only files whose size at least one other file shares are ever read: a size-unique file cannot be a duplicate, so it is recorded without hashes (head and tail empty). When a later scan makes its size shared, the file is hashed then, even if otherwise unchanged. report excludes unhashed records; trees gives them a never-matching signature so a tree containing one never compares equal to another. Hashed records are committed in batched transactions while the hash phase runs, so an interrupted scan keeps everything hashed so far and the next run resumes cheaply. The hash phase total is exact, giving a meaningful ETA. Memory drops accordingly: the existing-record index holds only path, size, mtime, and a hashed flag (no hash values); the walk carries one small record per candidate file; overlapping operands are pruned up front instead of deduplicating every walked path in a scan-wide set. Files no bigger than one chunk are hashed with a single read.
161 lines
3.6 KiB
Go
161 lines
3.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
)
|
|
|
|
// ioBufSize is the buffer size for the buffered stdout writers.
|
|
const ioBufSize = 1 << 20
|
|
|
|
// minGroupSize is the smallest number of members that makes a
|
|
// duplicate group.
|
|
const minGroupSize = 2
|
|
|
|
// scanRec is one file record from the database. The signature (size,
|
|
// head, tail) is the duplicate key; mtime is informational only and
|
|
// used by scan for change detection.
|
|
type scanRec struct {
|
|
size int64
|
|
mtime int64
|
|
head string
|
|
tail string
|
|
path string
|
|
}
|
|
|
|
// loadRecords opens the database and reads every file record for the
|
|
// report and trees subcommands. Any database problem — including a
|
|
// missing database — is fatal.
|
|
func loadRecords() []scanRec {
|
|
dbPath := databasePath()
|
|
|
|
db, err := openReportDatabase(dbPath)
|
|
if err != nil {
|
|
fatalf("%v", err)
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
recs, err := loadFileRows(db)
|
|
if err != nil {
|
|
fatalf("database %s: %v", dbPath, err)
|
|
}
|
|
|
|
return recs
|
|
}
|
|
|
|
// dupeGroup is one set of candidate-duplicate files: identical size,
|
|
// head hash, and tail hash. paths is sorted lexicographically; the
|
|
// first entry is the group's "first", the rest are dupes.
|
|
type dupeGroup struct {
|
|
size int64
|
|
paths []string
|
|
}
|
|
|
|
// runReport implements the report subcommand: it reads every record
|
|
// from the database and prints the file-level duplicates report as TSV
|
|
// on stdout. It never touches the scanned filesystem; its only I/O is
|
|
// the database, stdout, and stderr.
|
|
func runReport() {
|
|
recs := loadRecords()
|
|
dupes := collectDupeGroups(recs)
|
|
|
|
out := bufio.NewWriterSize(os.Stdout, ioBufSize)
|
|
|
|
_, err := fmt.Fprintln(out, "first\tdupe\tsize")
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
dupeFiles := 0
|
|
|
|
var reclaimable int64
|
|
|
|
for _, g := range dupes {
|
|
for _, p := range g.paths[1:] {
|
|
_, err = fmt.Fprintf(out, "%s\t%s\t%d\n",
|
|
g.paths[0], p, g.size)
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
dupeFiles++
|
|
reclaimable += g.size
|
|
}
|
|
}
|
|
|
|
err = out.Flush()
|
|
if err != nil {
|
|
fatalf("write stdout: %v", err)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr,
|
|
"report: %d records read, %d duplicate groups, %d dupe files, "+
|
|
"%s reclaimable\n",
|
|
len(recs), len(dupes), dupeFiles, humanBytes(reclaimable))
|
|
}
|
|
|
|
// collectDupeGroups groups records by signature and returns every group
|
|
// with two or more paths, each group's paths sorted lexicographically,
|
|
// groups ordered by size descending then by first path ascending.
|
|
func collectDupeGroups(recs []scanRec) []dupeGroup {
|
|
groups := make(map[fileSig][]string)
|
|
|
|
for _, r := range recs {
|
|
// A record without hashes (its size was unique when last
|
|
// scanned) has unknown content and is never reported as a
|
|
// duplicate.
|
|
if r.head == "" {
|
|
continue
|
|
}
|
|
|
|
k := fileSig{size: r.size, head: r.head, tail: r.tail}
|
|
groups[k] = append(groups[k], r.path)
|
|
}
|
|
|
|
var dupes []dupeGroup
|
|
|
|
for k, paths := range groups {
|
|
if len(paths) < minGroupSize {
|
|
continue
|
|
}
|
|
|
|
slices.Sort(paths)
|
|
dupes = append(dupes, dupeGroup{size: k.size, paths: paths})
|
|
}
|
|
|
|
// Biggest reclaimable space first; ties broken by first path.
|
|
slices.SortFunc(dupes, func(a, b dupeGroup) int {
|
|
if a.size != b.size {
|
|
if a.size > b.size {
|
|
return -1
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
return strings.Compare(a.paths[0], b.paths[0])
|
|
})
|
|
|
|
return dupes
|
|
}
|
|
|
|
// humanBytes formats a byte count in human units (binary prefixes).
|
|
func humanBytes(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d B", n)
|
|
}
|
|
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
|
|
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
|
}
|