All checks were successful
check / check (push) Successful in 57s
fatalf called os.Exit(1), which does not run deferred functions, so every defer db.Close() was dead on the fatal path: the SQLite WAL was left uncheckpointed and the -wal/-shm sidecars were left for the next process to recover. It also made those paths impossible to exercise in-process. fatalf is gone. runScan, runReport, runTrees, loadRecords and resolveRoots return their errors, so the deferred close always runs, and the only exit point is run() in main.go. Mapping errors to exit codes needs care: cobra prints the error and the command's usage text for anything RunE returns, and main mapped every Execute() error to exit 2. A runtime failure is not a usage problem, so the runE adapter silences both for the subcommands and marks their errors fatalError; run() reports a fatalError as "sfdupes: ..." on stderr and exits 1, and leaves everything else -- cobra's own argument, flag and unknown-command errors, which cobra has already reported with its usage text -- on exit 2. The bare "sfdupes" invocation still prints usage and exits 2. Exit codes and message text are unchanged: 0 on success even with per-file warnings, 1 fatal, 2 usage, per README section "Error handling and exit codes". Everything on stdout is still data only. main_test.go drives the CLI in-process and covers all three: a fatal error raised after the database is open (a database with no files table) closes it and leaves no -wal or -shm behind for scan, report and trees; a nonexistent PATH operand is fatal, not usage, and prints no usage text; the usage errors still exit 2; and a scan that skipped an unreadable file still exits 0.
170 lines
3.9 KiB
Go
170 lines
3.9 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. The error is returned rather than
|
|
// exiting, so that the deferred close — which checkpoints the SQLite
|
|
// WAL — always runs; the database is closed before the caller formats
|
|
// its output, so it stays closed even if that output fails.
|
|
func loadRecords() ([]scanRec, error) {
|
|
dbPath := databasePath()
|
|
|
|
db, err := openReportDatabase(dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
recs, err := loadFileRows(db)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("database %s: %w", dbPath, err)
|
|
}
|
|
|
|
return recs, nil
|
|
}
|
|
|
|
// 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() error {
|
|
recs, err := loadRecords()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dupes := collectDupeGroups(recs)
|
|
|
|
out := bufio.NewWriterSize(os.Stdout, ioBufSize)
|
|
|
|
_, err = fmt.Fprintln(out, "first\tdupe\tsize")
|
|
if err != nil {
|
|
return fmt.Errorf("write stdout: %w", 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 {
|
|
return fmt.Errorf("write stdout: %w", err)
|
|
}
|
|
|
|
dupeFiles++
|
|
reclaimable += g.size
|
|
}
|
|
}
|
|
|
|
err = out.Flush()
|
|
if err != nil {
|
|
return fmt.Errorf("write stdout: %w", 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))
|
|
|
|
return nil
|
|
}
|
|
|
|
// 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])
|
|
}
|