All checks were successful
check / check (push) Successful in 3s
scan now synchronizes a database that survives between runs (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite) instead of emitting a stream: operands are resolved to absolute paths, unchanged files (same size, mtime not newer than recorded) are never re-read, new and changed files are hashed, and records under the scanned operands that were not verified this run are deleted; records outside the operands are untouched. All changes commit in a single transaction, and WAL journaling with a busy timeout keeps a report run during a cron scan safe. report and trees read the database (no positional arguments); the NUL-terminated stream format, its parser, and the malformed-record handling are gone. The driver is modernc.org/sqlite (pure Go), so builds keep cgo disabled.
154 lines
3.4 KiB
Go
154 lines
3.4 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 {
|
|
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])
|
|
}
|