Author SHA1 Message Date
sneak eebf579f11 Merge pull request 'next into main' (#110) from next into main
check / check (push) Waiting to run
Reviewed-on: #110
2026-10-22 18:44:20 +02:00
sneak 8a4616039b Merge pull request 'next → main: trial-run fixes (escaping, read-only reports, scan lock, interrupts, streaming, README)' (#69) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #69
2026-10-15 09:50:27 +02:00
sneak c2804d088b Merge pull request 'next' (#50) from next into main
check / check (push) Successful in 1m1s
Reviewed-on: #50
2026-09-26 15:27:29 +02:00
18 changed files with 2790 additions and 2247 deletions
+960 -1017
View File
File diff suppressed because it is too large Load Diff
-32
View File
@@ -29,38 +29,6 @@
# Completed Steps
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
it. It leads with what the program is and how to use it. A reference section
covers every setting and every subcommand. Development and testing come last.
Every statement was checked against the code on `next`. Five comments in
`main_test.go` cite "Settings", where their rules now live (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/135)
- a file or directory removed while the scan runs is not an error, in the walk
or in the hash phase. The scan prints no line for it and does not count it as
skipped. When the first of several hard links to a file is gone, the hash
phase reads the next one instead. On a terminal every progress display starts
with the spinner, the hash phase's included. A phase with an exact total ends
its line with its percent and a 10-column bar at the terminal's right edge,
which no longer moves as the line's length changes. A line too wide for the
terminal now loses its rate before its bar. The time remaining is labelled
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133)
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
raw from the hash phase through the database to `report` and `trees`. Reading
a record whose hash is not 32 bytes is an error (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/134)
- the hash phase gives every file the walk records three hashes: `head` and
`tail`, the SHA-256 of its first and last 64 KiB, and `content`, the SHA-256
of the whole file under 100 MiB and of 1 MiB at each gigabyte from 100 MiB up.
The 10 MiB and 50 MiB thresholds, the size census, the records stored without
hashes and the content phase are gone, so a scan has four phases and a rescan
reads only new and changed files. `info` no longer counts the records with
hashes, since every record has them (2026-10-25,
https://git.eeqj.de/sneak/sfdupes/issues/132)
- re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from
`sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder`
stages of the `Dockerfile` the module download, and in `builder` the
+14 -19
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
@@ -169,8 +168,8 @@ func assertRecordsIntact(t *testing.T, db *sql.DB, before []string) {
// TestSyncScanCancelledMidWalkKeepsRecords is the regression net under
// the post-walk guard. The scan is cancelled part-way through the
// walk, so it reaches the guard having walked a genuinely partial tree
// and holding a still-populated index of records the walk never got to.
// walk, so it reaches the guard holding a genuinely partial size
// census and a still-populated index of records the walk never got to.
// Every one of those records would look vanished to the update phase.
// The guard is what stops the scan there, and this test is what
// notices if it stops doing so: deleting the guard, or making it
@@ -209,10 +208,10 @@ func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
}
// assertWalkGuardAborted checks that the scan stopped at the post-walk
// guard: with a walk that reached some files (it really ran) but not
// all of them (it really was cut short), and with no record counted for
// removal. A removal count means the partial walk was carried past
// the guard into the update phase, which is the failure this test
// guard: with a census that is neither empty (the walk really ran)
// nor complete (it really was cut short), and with no record counted
// for removal. A removal count means the partial census was carried
// past the guard into the update phase, which is the failure this test
// exists to catch.
func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
t.Helper()
@@ -223,30 +222,30 @@ func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
}
if st.unchanged == 0 {
t.Fatalf("stats = %+v: the walk reached no file, so it never "+
t.Fatalf("stats = %+v: the census is empty, so the walk never "+
"ran and the guard was reached for the wrong reason", st)
}
if st.unchanged >= walkCancelFiles {
t.Fatalf("stats = %+v: the walk reached the whole tree, so it "+
"was not cut short", st)
t.Fatalf("stats = %+v: the census covers the whole tree, so the "+
"walk was not cut short", st)
}
// The workers drop every directory still queued once the scan is
// cancelled, so only the files in the directories already in flight
// can still be reached after the fact. A walk beyond that bound
// can add to the census after the fact. A census beyond that bound
// would mean the cancellation was not observed where it should have
// been.
limit := walkCancelAtDone + walkCancelInFlightFiles
if st.unchanged > limit {
t.Errorf("the walk reached %d files, want at most %d: it kept "+
t.Errorf("census covers %d files, want at most %d: the walk kept "+
"taking directories off the queue after cancellation",
st.unchanged, limit)
}
if st.removed != 0 {
t.Errorf("stats = %+v: the scan counted records for removal from "+
"a partial walk", st)
"a partial census", st)
}
}
@@ -763,9 +762,7 @@ func TestHashWorkerDropsQueuedRuns(t *testing.T) {
var hashed atomic.Bool
hash := func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
hash := func(path string, size int64) (string, string, string, error) {
hashed.Store(true)
return hashSignature(path, size)
@@ -805,9 +802,7 @@ func TestHashWorkerAbandonsBlockedSend(t *testing.T) {
// The scan is cancelled while the worker hashes, so the worker has
// already passed the check that drops queued runs.
hash := func(path string, size int64) (
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
) {
hash := func(path string, size int64) (string, string, string, error) {
cancel()
return hashSignature(path, size)
+111 -58
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
@@ -49,17 +48,16 @@ const lockFilePerm = 0o600
// createTableSQL is the schema applied to a fresh database. Paths are
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
// holds whole Unix seconds and mtime_nsec the nanoseconds within that
// second. head, tail and content each hold the raw 32 bytes of a
// SHA-256.
// second.
const createTableSQL = `
CREATE TABLE files (
path BLOB PRIMARY KEY,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
mtime_nsec INTEGER NOT NULL,
head BLOB NOT NULL,
tail BLOB NOT NULL,
content BLOB NOT NULL
head TEXT NOT NULL,
tail TEXT NOT NULL,
content TEXT NOT NULL
) WITHOUT ROWID
`
@@ -94,10 +92,6 @@ var errSchemaVersion = errors.New("unsupported database schema version")
// database.
var errScanRunning = errors.New("another scan is running")
// errHashSize reports a record whose head, tail or content is not the
// 32 bytes of a SHA-256.
var errHashSize = errors.New("hash is not 32 bytes")
// databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the
// path, with the working directory, its symlinks resolved, put in front
@@ -498,24 +492,19 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
for rows.Next() {
var (
path, head, tail, content []byte
sec, nsec int64
r scanRec
path []byte
sec, nsec int64
r scanRec
)
err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
&r.content)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
r.path = string(path)
r.mtime = time.Unix(sec, nsec)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
fn(r)
}
@@ -572,22 +561,17 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
for rows.Next() {
var (
path, head, tail, content []byte
r scanRec
path []byte
r scanRec
)
err = rows.Scan(&r.size, &head, &tail, &content, &path)
err = rows.Scan(&r.size, &r.head, &r.tail, &r.content, &path)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
r.path = string(path)
err = r.setHashes(head, tail, content)
if err != nil {
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
}
err = fn(r)
if err != nil {
return err
@@ -602,22 +586,6 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
return nil
}
// setHashes sets r's head, tail and content to the values read from
// those columns. Each must be the raw 32 bytes of a SHA-256.
func (r *scanRec) setHashes(head, tail, content []byte) error {
for _, b := range [][]byte{head, tail, content} {
if len(b) != sha256.Size {
return fmt.Errorf("%w: found %d bytes", errHashSize, len(b))
}
}
r.head = [sha256.Size]byte(head)
r.tail = [sha256.Size]byte(tail)
r.content = [sha256.Size]byte(content)
return nil
}
// countRecords returns the number of records in the database.
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
var n int64
@@ -630,11 +598,15 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
return n, nil
}
// databaseCountsSQL counts the records and adds up their sizes, in one
// databaseCountsSQL counts the records, those with head and tail
// hashes and those with a content hash, and adds up their sizes, in one
// pass over the table. A SUM over no rows is NULL, so on an empty table
// the SUM falls back to 0.
// each SUM falls back to 0.
const databaseCountsSQL = `
SELECT COUNT(*), COALESCE(SUM(size), 0)
SELECT COUNT(*),
COALESCE(SUM(head <> '' AND tail <> ''), 0),
COALESCE(SUM(content <> ''), 0),
COALESCE(SUM(size), 0)
FROM files
`
@@ -643,6 +615,8 @@ FROM files
type databaseCounts struct {
schemaVersion int
records int64 // every record
headTail int64 // records with head and tail hashes
content int64 // records with a content hash
size int64 // the sizes of all records added up
}
@@ -670,7 +644,7 @@ func readDatabaseCounts(ctx context.Context,
}
err = tx.QueryRowContext(ctx, databaseCountsSQL).
Scan(&c.records, &c.size)
Scan(&c.records, &c.headTail, &c.content, &c.size)
if err != nil {
return c, fmt.Errorf("count records: %w", err)
}
@@ -678,15 +652,15 @@ func readDatabaseCounts(ctx context.Context,
return c, nil
}
// loadFileMeta streams every record's path, size and mtime to fn. Scan
// change detection needs no hash values, and skipping the hash columns
// keeps the scan's in-memory index small on multi-million-file
// databases.
// loadFileMeta streams every record's path, size, mtime, and whether
// it carries hashes to fn. Scan change detection needs no hash
// values, and skipping the hash columns keeps the scan's in-memory
// index small on multi-million-file databases.
func loadFileMeta(ctx context.Context, db *sql.DB,
fn func(path string, size int64, mtime time.Time),
fn func(path string, size int64, mtime time.Time, hashed bool),
) error {
rows, err := db.QueryContext(ctx,
"SELECT path, size, mtime, mtime_nsec FROM files")
"SELECT path, size, mtime, mtime_nsec, head <> '' FROM files")
if err != nil {
return fmt.Errorf("read records: %w", err)
}
@@ -697,14 +671,94 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
var (
path []byte
size, sec, nsec int64
hashed int64
)
err = rows.Scan(&path, &size, &sec, &nsec)
err = rows.Scan(&path, &size, &sec, &nsec, &hashed)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
fn(string(path), size, time.Unix(sec, nsec))
fn(string(path), size, time.Unix(sec, nsec), hashed != 0)
}
err = rows.Err()
if err != nil {
return fmt.Errorf("read records: %w", err)
}
return nil
}
// contentGroupsSQL selects each group (the records sharing a size,
// head, and tail) of records of at least headTailMin bytes that has more
// than one record and at least one record without a content hash, with
// the number of its records.
const contentGroupsSQL = `
SELECT size, head, tail, COUNT(*) AS records
FROM files
WHERE size >= ? AND head <> ''
GROUP BY size, head, tail
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
`
// contentCandidatesSQL selects every record of the groups
// contentGroupsSQL selects, with whether each record has a content
// hash. SQLite does the grouping, so no other record's hashes are
// loaded into memory; the rows come ordered by size, head, and tail, so
// each group's rows arrive together.
const contentCandidatesSQL = `
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
FROM files AS f
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
ORDER BY size, head, tail
`
// countContentCandidates returns the number of rows of
// contentCandidatesSQL, adding up the records of the groups
// contentGroupsSQL selects, so it neither joins nor sorts.
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
var n int64
err := db.QueryRowContext(ctx,
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
headTailMin).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count content candidates: %w", err)
}
return n, nil
}
// loadContentCandidates streams the rows of contentCandidatesSQL to fn:
// each record, without its content hash, and whether it has one.
func loadContentCandidates(ctx context.Context, db *sql.DB,
fn func(r scanRec, hashed bool),
) error {
rows, err := db.QueryContext(ctx, contentCandidatesSQL, headTailMin)
if err != nil {
return fmt.Errorf("read records: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
path []byte
sec, nsec int64
r scanRec
hashed int64
)
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
&hashed)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
r.path = string(path)
r.mtime = time.Unix(sec, nsec)
fn(r, hashed != 0)
}
err = rows.Err()
@@ -788,8 +842,7 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
for _, r := range upserts {
_, err = st.ExecContext(ctx, []byte(r.path), r.size,
r.mtime.Unix(), r.mtime.Nanosecond(),
r.head[:], r.tail[:], r.content[:])
r.mtime.Unix(), r.mtime.Nanosecond(), r.head, r.tail, r.content)
if err != nil {
return fmt.Errorf("upsert %s: %w", r.path, err)
}
+67 -71
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
@@ -474,67 +473,6 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
}
}
// TestLoadRowsRefuseWrongSizeHash stores a record whose head, tail or
// content is not the 32 bytes of a SHA-256: either one byte, or the 64
// bytes of a SHA-256 written as hex text. loadFileRows and
// loadSignatureRows must each fail on it with an error naming its path.
func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
t.Parallel()
hexText := strings.Repeat("ab", sha256.Size)
cases := []struct {
name string
update string
value any
}{
{"one-byte head", "UPDATE files SET head = ?", []byte{0}},
{"hex-text head", "UPDATE files SET head = ?", hexText},
{"one-byte tail", "UPDATE files SET tail = ?", []byte{0}},
{"hex-text tail", "UPDATE files SET tail = ?", hexText},
{"one-byte content", "UPDATE files SET content = ?", []byte{0}},
{"hex-text content", "UPDATE files SET content = ?", hexText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db := openTestDB(t)
err := applyChanges(t.Context(), db,
[]scanRec{{path: "/a/f"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(), c.update, c.value)
if err != nil {
t.Fatal(err)
}
loads := map[string]func() error{
"loadFileRows": func() error {
return loadFileRows(t.Context(), db, func(int64) {},
func(scanRec) {})
},
"loadSignatureRows": func() error {
return loadSignatureRows(t.Context(), db, func(int64) {},
func(scanRec) error { return nil })
},
}
for name, load := range loads {
err := load()
if !errors.Is(err, errHashSize) ||
!strings.Contains(err.Error(), "/a/f") {
t.Errorf("%s: err = %v, want %v naming /a/f",
name, err, errHashSize)
}
}
})
}
}
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// from the files_signature index alone, with nothing to sort: the
// first record comes at once, so report's read phase counts each
@@ -589,13 +527,12 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// written.
recs := []scanRec{
{
size: 2, mtime: time.Unix(20, 999_999_999), head: testHash("h2"),
tail: testHash("t2"), content: testHash("c2"),
path: "/a/tab\tnew\nline",
size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2",
content: "c2", path: "/a/tab\tnew\nline",
},
{
size: 1, mtime: time.Unix(10, 0), head: testHash("h1"),
tail: testHash("t1"), content: testHash("c1"), path: "/a/x",
size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1",
content: "c1", path: "/a/x",
},
}
@@ -616,8 +553,8 @@ func TestApplyChangesRoundTrip(t *testing.T) {
// An upsert for an existing path updates in place; a delete
// removes exactly its path.
upd := scanRec{
size: 3, mtime: time.Unix(30, 0), head: testHash("h3"),
tail: testHash("t3"), content: testHash("c3"), path: "/a/x",
size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3",
path: "/a/x",
}
err = applyChanges(t.Context(), db, []scanRec{upd},
@@ -644,8 +581,8 @@ func TestApplyChangesBatching(t *testing.T) {
recs := make([]scanRec, 0, n)
for i := range n {
recs = append(recs, scanRec{
size: int64(i), mtime: time.Unix(1, 0), head: testHash("h"),
tail: testHash("t"), path: fmt.Sprintf("/batch/%07d", i),
size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t",
path: fmt.Sprintf("/batch/%07d", i),
})
}
@@ -675,3 +612,62 @@ func TestApplyChangesBatching(t *testing.T) {
t.Fatalf("records = %d, want 0", len(got))
}
}
// TestCountContentCandidates checks that countContentCandidates counts
// the records the content phase's search returns: those of each group
// of two or more records of headTailMin or more with a head, where at
// least one has no content hash.
func TestCountContentCandidates(t *testing.T) {
t.Parallel()
db := openTestDB(t)
// Each group's records: size, head, and content, one per record.
groups := []struct {
size int64
head string
contents []string
}{
{headTailMin, "h", []string{"", "c", "c"}}, // returned
{headTailMin + 1, "h", []string{"", ""}}, // returned
{headTailMin + 2, "h", []string{"c", "c"}}, // all hashed
{headTailMin + 3, "h", []string{""}}, // one record
{headTailMin - 1, "h", []string{"", ""}}, // too small
{headTailMin + 4, "", []string{"", ""}}, // no head
}
var recs []scanRec
for _, g := range groups {
for _, content := range g.contents {
recs = append(recs, scanRec{
size: g.size, mtime: time.Unix(1, 0), head: g.head,
tail: "t", content: content,
path: fmt.Sprintf("/r/%d", len(recs)),
})
}
}
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
got, err := countContentCandidates(t.Context(), db)
if err != nil {
t.Fatalf("countContentCandidates: %v", err)
}
var returned int64
err = loadContentCandidates(t.Context(), db,
func(scanRec, bool) { returned++ })
if err != nil {
t.Fatalf("loadContentCandidates: %v", err)
}
if got != 5 || returned != 5 {
t.Errorf("count = %d, records returned = %d, want 5 and 5",
got, returned)
}
}
+74 -4
View File
@@ -164,8 +164,9 @@ func TestDefaultExcludesHelp(t *testing.T) {
// TestScanDoesNotEnterDefaultExclude checks that the walk does not
// enter a default exclude below an operand. It says so on a line of its
// own and does not count it as skipped. The records below it count as
// outside the operands: the record of a file gone from it is kept,
// while a file added to it is not recorded.
// outside the operands: the record of a file gone from it is kept, and
// its size joins the size census, while a file added to it is not
// recorded.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
@@ -185,6 +186,10 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
unseen := writeFile(t, excluded, "new", pattern(2, 10))
// The same size as gone, so it is hashed only if the census holds the
// size of gone's record.
added := writeFile(t, dir, "added", pattern(3, 100))
st, err := syncScan(t.Context(), db, []string{dir}, 1, false,
walkRules{excludes: []string{excluded}})
if err != nil {
@@ -198,6 +203,11 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
recs := dbRecords(t, db)
recordByPath(t, recs, gone)
if recordByPath(t, recs, added).head == "" {
t.Errorf("%s not hashed: the census lacks the size of %s",
added, gone)
}
if slices.Contains(recordPaths(recs), unseen) {
t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
}
@@ -381,8 +391,9 @@ func TestRunScanSkipsNonPersistentOperand(t *testing.T) {
// TestScanKeepsRecordsOnNonPersistent scans a directory on a filesystem
// that is not persistent beside one on the tests' temporary directory.
// The records an earlier scan left in it count as outside the operands:
// the record of a file gone from it is kept, while a file added to it
// is not recorded.
// the record of a file gone from it is kept, and its size joins the size
// census, while a file added to it is not recorded, and its size does
// not join the census.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
@@ -402,6 +413,11 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
unseen := writeFile(t, shm, "new", pattern(2, 10))
// added has the size of gone, and lonely that of unseen: each is
// hashed only if the census holds that size.
added := writeFile(t, dir, "added", pattern(3, 100))
lonely := writeFile(t, dir, "lonely", pattern(4, 10))
st, err := syncScan(t.Context(), db, []string{shm, dir}, 1, false, rules)
if err != nil {
t.Fatal(err)
@@ -414,6 +430,15 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
recs := dbRecords(t, db)
recordByPath(t, recs, gone)
if recordByPath(t, recs, added).head == "" {
t.Errorf("%s not hashed: the census lacks the size of %s",
added, gone)
}
if recordByPath(t, recs, lonely).head != "" {
t.Errorf("%s hashed: the census holds the size of %s", lonely, unseen)
}
if slices.Contains(recordPaths(recs), unseen) {
t.Errorf("%s recorded", unseen)
}
@@ -425,6 +450,51 @@ func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
}
}
// TestScanContentSkipsNonPersistent checks that the content phase does
// not read a file on a filesystem that is not persistent, though an
// earlier scan left a record for it: it is treated as a changed file,
// so its record is left as it is and its identical copy in another
// tree, having no other match, is not read either.
func TestScanContentSkipsNonPersistent(t *testing.T) {
t.Parallel()
shm, _, rules := shmTempDir(t)
db := openTestDB(t)
// A scan that, without the list of filesystems, records a with a
// head and tail but no content hash.
a := sparseFileWithoutMatch(t, shm, "a", headTailMin)
syncTree(t, db, shm)
before := recordByPath(t, dbRecords(t, db), a)
if before.head == "" || before.content != "" {
t.Fatalf("after scanning %s: %+v, want head and tail only", shm, before)
}
b := sparseFile(t, t.TempDir(), "b", headTailMin)
st, err := syncScan(t.Context(), db, []string{filepath.Dir(b)}, 1, false,
rules)
if err != nil {
t.Fatal(err)
}
if st.skipped != 0 {
t.Errorf("skipped = %d, want 0", st.skipped)
}
recs := dbRecords(t, db)
if r := recordByPath(t, recs, a); r != before {
t.Errorf("record = %+v, want it left as %+v", r, before)
}
if r := recordByPath(t, recs, b); r.content != "" {
t.Errorf("%s: content = %q, want none: its only match is on %s",
b, r.content, shm)
}
}
// TestWalkLeavesOutNonPersistent starts the walk itself in a directory
// on a filesystem that is not persistent, which a scan never does: it
// drops such an operand first. Below a directory on a persistent
+3
View File
@@ -113,6 +113,9 @@ func writeInfo(stdout io.Writer, dbPath, source string,
dbFile.ModTime().UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "schema version\t%d\n", counts.schemaVersion)
fmt.Fprintf(&b, "records\t%d\n", counts.records)
fmt.Fprintf(&b, "records with head and tail hashes\t%d\n",
counts.headTail)
fmt.Fprintf(&b, "records with content hash\t%d\n", counts.content)
fmt.Fprintf(&b, "size of recorded files\t%d (%s)\n",
counts.size, humanBytes(counts.size))
+13 -11
View File
@@ -12,25 +12,23 @@ import (
// infoOpenLine is the plain line of info's open phase when it opens the
// database at once; it prints it as the phase starts and as it ends.
const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
"remain unknown"
"eta unknown"
// infoCountLine is the plain line of info's count phase when the count
// takes under a second; it prints it as the phase starts and as it
// ends.
const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
"remain unknown"
"eta unknown"
// infoRecs are four records: a duplicate pair, a file of 20 MiB and
// another small file.
// infoRecs are four records: a duplicate pair with every hash, a file
// of 20 MiB with head and tail hashes but no content hash yet, and a
// file never hashed.
func infoRecs() []scanRec {
h, tl, c, u := testHash("h"), testHash("t"), testHash("c"), testHash("u")
bigH, bigT, bigC := testHash("bh"), testHash("bt"), testHash("bc")
return []scanRec{
{size: 5, head: h, tail: tl, content: c, path: "/d/a"},
{size: 5, head: h, tail: tl, content: c, path: "/d/b"},
{size: 20 << 20, head: bigH, tail: bigT, content: bigC, path: "/d/big"},
{size: 7, head: u, tail: u, content: u, path: "/d/unique"},
{size: 5, head: "h", tail: "t", content: "c", path: "/d/a"},
{size: 5, head: "h", tail: "t", content: "c", path: "/d/b"},
{size: 20 << 20, head: "bh", tail: "bt", path: "/d/big"},
{size: 7, path: "/d/unique"},
}
}
@@ -47,12 +45,16 @@ func TestRunInfo(t *testing.T) {
"empty": {
want: "schema version\t1\n" +
"records\t0\n" +
"records with head and tail hashes\t0\n" +
"records with content hash\t0\n" +
"size of recorded files\t0 (0 B)\n",
},
"filled": {
recs: infoRecs(),
want: "schema version\t1\n" +
"records\t4\n" +
"records with head and tail hashes\t3\n" +
"records with content hash\t2\n" +
"size of recorded files\t20971537 (20.0 MiB)\n",
},
}
+9 -7
View File
@@ -1,12 +1,14 @@
// Command sfdupes quickly identifies candidate duplicate files across
// very large filesystems without reading every byte of every file.
// Files are considered duplicates when their sizes are equal and they
// agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
// KiB, and of the content. The content hash covers the whole file under
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// file is never read in full. scan maintains a persistent SQLite
// database of file signatures (SFDUPES_DATABASE, default
// /var/lib/sfdupes/db.sqlite) that the other subcommands read.
// agree on a short ladder of SHA-256 hashes. A file under 10 MiB is
// hashed in full. A larger file is compared on the hashes of its first
// and last 64 KiB, and only when those match another file's is its
// content hash computed and compared: of the whole file when it is
// under 50 MiB, or of gigabyte-spaced 1 MiB samples when it is 50 MiB
// or larger. scan maintains a persistent SQLite database of file
// signatures (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite)
// that the other subcommands read.
//
// Usage:
//
@@ -236,7 +238,7 @@ func newScanCommand() *cobra.Command {
}),
}
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
"concurrent workers for the walk and hash phases")
"concurrent workers for the walk, hash, and content phases")
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
+7 -7
View File
@@ -393,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
}
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
// README §Settings: the path names the file exactly. With link a
// README §Database: the path names the file exactly. With link a
// symlink to the directory real/sub, link/../db.sqlite names
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
// use that file, relative or absolute, and its first line must name
@@ -648,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
}
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
// README §Settings: --workers below 1 is a usage error reported in
// README §scan mode: --workers below 1 is a usage error reported in
// one line on stderr, before the scan opens the database.
for _, workers := range []string{"0", "-1"} {
t.Run(workers, func(t *testing.T) {
@@ -686,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
func TestRunHelp(t *testing.T) {
t.Parallel()
// README §Settings: help goes to stderr, exits 0, and leaves stdout
// empty.
// README §Subcommands: help goes to stderr, exits 0, and leaves
// stdout empty.
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
for _, args := range cases {
@@ -713,8 +713,8 @@ func TestRunHelp(t *testing.T) {
func TestRunVersion(t *testing.T) {
t.Parallel()
// README §Settings: the version is one line on stdout, with nothing
// on stderr, and exits 0.
// README §Subcommands: the version is one line on stdout, with
// nothing on stderr, and exits 0.
info, _ := debug.ReadBuildInfo()
for _, arg := range []string{"--version", "-v"} {
@@ -1122,7 +1122,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
}
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
// README §Settings: the path names the database file exactly. In
// README §Database: the path names the database file exactly. In
// SQLite's connection string an unescaped ? or # would end the file
// name and % would start an escape, and a path starting with //
// could be read as a host name. %25 is a valid escape, so unescaped
+91 -103
View File
@@ -9,7 +9,6 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/schollz/progressbar/v3"
"golang.org/x/term"
@@ -35,17 +34,10 @@ const stallTime = 10 * time.Second
// spinnerType selects the progressbar library's spinner style.
const spinnerType = 14
// barWidth is the width of a phase's bar between its two ends, as in
// the example line in README "Progress".
// barWidth is the width of the progressbar library's bar between its
// two ends, as in the example line in README "Progress".
const barWidth = 10
// barCell is one column of the filled part of a bar.
const barCell = "█"
// barColumns is the width of what percentBar returns: the percent in
// four columns and its sign, a space, and the bar with its two ends.
const barColumns = barWidth + 8
// percentScale converts a fraction to a percentage.
const percentScale = 100
@@ -53,12 +45,17 @@ const percentScale = 100
// read, as the progressbar library takes it.
const fallbackWidth = 80
// spinnerMargin is the columns of a line besides its text: the spinner
// and the three spaces the library draws around the text, and the
// terminal's last column, which every line stays clear of so that no
// terminal wraps it.
// spinnerMargin is the columns of a spinner's line besides its text:
// the spinner and the three spaces the library draws around the text,
// and the terminal's last column, which every line stays clear of so
// that no terminal wraps it.
const spinnerMargin = 5
// barMargin is the columns of a bar's line besides its text: the
// percent and the bar the library draws after the text, the spaces
// around them, and the terminal's last column.
const barMargin = barWidth + 11
// countingRecords is what the display of a phase that reads the records
// says while it counts them, before it has its total.
const countingRecords = "counting the records"
@@ -88,7 +85,7 @@ func terminalWidth() int {
return width
}
// phaseLabel names phase with its place among phases, as "[2/4] walk".
// phaseLabel names phase with its place among phases, as "[2/5] walk".
func phaseLabel(phases []string, phase string) string {
return fmt.Sprintf("[%d/%d] %s",
slices.Index(phases, phase)+1, len(phases), phase)
@@ -112,13 +109,13 @@ type drawnCount struct {
}
// progress renders one phase's progress on stderr. On a TTY the
// progressbar library draws its spinner and then line, fitted to the
// terminal's width. A phase with an exact total ends the line with its
// percent and bar, at the terminal's right edge, when the terminal has
// room for them. It is drawn again every redrawInterval, so its elapsed
// time keeps moving while the phase waits for its next item. When
// stderr is not a TTY it emits no ANSI redraws: it prints line as the
// phase starts, every plainInterval while it runs, and as it ends.
// progressbar library draws line, fitted to the terminal's width: after
// the library's spinner, or, for a phase with an exact total, before the
// library's bar when the terminal has room for the bar beside it. It is
// drawn again every redrawInterval, so its elapsed time keeps moving
// while the phase waits for its next item. When stderr is not a TTY it
// emits no ANSI redraws: it prints line as the phase starts, every
// plainInterval while it runs, and as it ends.
//
// The draws come from a goroutine of their own, so mu guards the fields
// below it. Every method must be called from the main goroutine. A nil
@@ -134,6 +131,7 @@ type progress struct {
mu sync.Mutex
bar *progressbar.ProgressBar // what the library draws; nil until then
withBar bool // bar is the library's bar, not its spinner
widest int // the widest line bar has drawn, in columns
label string
unit string
@@ -143,7 +141,7 @@ type progress struct {
stalledOn string
count int64
start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the time remaining
rateStart time.Time // when the count started, for the ETA
lastItem time.Time // when the last item was completed, or the count started
// drawn holds the count at each draw over the last rateWindow, oldest
// first; the rate counts from the first. It keeps the draw before the
@@ -217,34 +215,55 @@ func (p *progress) draw() {
}
if p.width == nil {
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, false))
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, true))
return
}
width := p.width()
text := p.line(now, width-spinnerMargin, true)
// Before each draw the library erases its line with as many spaces as
// the widest line it has drawn. A display gets a new one when the
// terminal has been made too narrow for that erase, which would wrap
// and leave a line behind at every draw.
if p.bar == nil || p.widest >= width {
p.newBar(width)
// A phase with an exact total gets the library's bar when its whole
// line, without the percent the bar shows, fits beside the bar: the
// bar gives way before any part of the line. A total of 0 leaves the
// bar nothing to fill.
text, withBar := "", false
if p.total > 0 && !p.estimate {
text = p.line(now, math.MaxInt, false)
withBar = len(text) <= width-barMargin
}
margin := barMargin
if !withBar {
margin = spinnerMargin
text = p.line(now, width-margin, true)
}
// The library makes a bar or a spinner, and before each draw erases
// its line with as many spaces as the widest line it has drawn. A
// display gets a new one when it turns from one to the other, as its
// line grows or the terminal is resized, and when the terminal has
// been made too narrow for that erase, which would wrap and leave a
// line behind at every draw.
if p.bar == nil || p.withBar != withBar || p.widest >= width {
p.newBar(withBar, width)
}
if withBar {
_ = p.bar.Set64(p.count)
}
p.bar.Describe(text)
// The library drew the spinner, the text and the spaces around it,
// less the terminal's last column, which spinnerMargin keeps clear. A
// bar's cells take one column each but more than one byte.
p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1)
// The library drew the text and its margin, less the terminal's last
// column, which the margin keeps clear.
p.widest = max(p.widest, len(text)+margin-1)
}
// newBar has the library make what draw draws: its spinner, followed by
// the text draw gives it and nothing of the library's own. p.mu must be
// held.
func (p *progress) newBar(width int) {
// newBar has the library make what draw draws: a bar for the phase's
// total when withBar is set, and a spinner when not. Either shows the
// text draw gives it, and nothing of its own besides the spinner, or
// the bar and its percent. p.mu must be held.
func (p *progress) newBar(withBar bool, width int) {
if p.bar != nil {
// Erase what was drawn before, short of the last column of a
// terminal that may have been made narrower since: the library's
@@ -253,36 +272,29 @@ func (p *progress) newBar(width int) {
fmt.Fprint(os.Stderr, "\r"+blank+"\r")
}
// A total of -1 gives the library's spinner.
p.bar = progressbar.NewOptions64(-1,
total := int64(-1) // the library's spinner
if withBar {
total = p.total
}
p.bar = progressbar.NewOptions64(total,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetWidth(barWidth),
progressbar.OptionSpinnerType(spinnerType),
// The spinner turns at each draw, not on a timer of the
// library's own.
progressbar.OptionSetSpinnerChangeInterval(0),
// The text carries the elapsed time and the time remaining.
// The text carries the elapsed time and the ETA.
progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false),
)
p.widest = 0
}
// percentBar returns the percent and the bar that end the text of a
// phase with an exact total, as " 25% |██ |": the bar's
// barWidth columns fill in proportion to the items done. A count past
// the total, as when a scan adds records while trees reads them, shows
// as 100%. p.mu must be held.
func (p *progress) percentBar() string {
done := min(float64(p.count)/float64(p.total), 1)
filled := strings.Repeat(barCell, int(done*barWidth))
return fmt.Sprintf("%4.0f%% |%-*s|", done*percentScale, barWidth, filled)
p.withBar, p.widest = withBar, 0
}
// setTotal gives a phase that started without a total its total, once
// it is known. The rate, the time remaining and the time with no item
// completed count from then on: the time spent waiting for the total
// says nothing about how fast items go.
// it is known. The rate, the ETA and the time with no item completed
// count from then on: the time spent waiting for the total says nothing
// about how fast items go.
func (p *progress) setTotal(total int64) {
if p == nil {
return
@@ -426,26 +438,22 @@ func (p *progress) finish() {
// line formats the phase's progress as of now in at most room columns,
// as
//
// [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
//
// where "files" is the phase's unit. A phase with no total shows the
// items done instead, or what it waits for. The rate is that since the
// first draw in drawn, and the time remaining comes from the rate since
// the count started. Once no item has been completed for stallTime, the
// rate, which would read 0, gives way to the word "stalled" and where
// the phase waits, as "stalled disk/db".
// where "files" is the phase's unit, without the percent when percent
// is false, as before the library's bar, which shows its own. A phase
// with no total shows the items done instead, or what it waits for. The
// rate is that since the first draw in drawn, and the ETA comes from
// the rate since the count started. Once no item has been completed for
// stallTime, the rate, which would read 0, gives way to the word
// "stalled" and where the phase waits, as "stalled disk/db".
//
// When bar is true, as on a terminal, a phase with an exact total ends
// the line with its percent and bar, in place of the percent after its
// count. A total of 0 leaves the bar nothing to fill.
//
// The phase and its place, the progress, the elapsed time and the time
// remaining are kept whole. A line wider than room first leaves out the
// rate, the words of a stalled phase, or what the phase waits for. Next
// it leaves out the bar. Only if it is still wider is it cut at room.
// Every part but the bar is ASCII, so its length is its width in
// columns. p.mu must be held.
func (p *progress) line(now time.Time, room int, bar bool) string {
// The phase and its place, the progress, the elapsed time and the ETA
// are kept whole. A line wider than room leaves out the rate, the words
// of a stalled phase, or what the phase waits for; only if it is still
// wider is it cut at room. Every part is ASCII, so its length is its
// width in columns. p.mu must be held.
func (p *progress) line(now time.Time, room int, percent bool) string {
elapsed := now.Sub(p.start).Round(time.Second)
// head and tail hold the parts kept whole; extra, between them, is
@@ -454,41 +462,21 @@ func (p *progress) line(now time.Time, room int, bar bool) string {
if p.total < 0 && p.waitingFor != "" {
head, extra = p.label+": ", p.waitingFor+", "
tail = fmt.Sprintf("elapsed %s, remain unknown", elapsed)
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
} else {
since := p.drawn[0]
rate := perSecond(p.count-since.count, now.Sub(since.at))
average := perSecond(p.count, now.Sub(p.rateStart))
head = p.label + ": " + p.itemsDone(true) + ", "
head = p.label + ": " + p.itemsDone(percent) + ", "
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
tail = fmt.Sprintf("elapsed %s, remain %s", elapsed, p.remain(average))
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(average))
if now.Sub(p.lastItem) >= stallTime {
extra = "stalled " + p.stalledOn + ", "
}
}
if bar && p.total > 0 && !p.estimate {
// Beside the bar, which shows the percent, the count shows none.
// The line is padded to the room the bar leaves it, so the bar
// stays at room's right edge however long the line is.
beside := room - barColumns
barHead := p.label + ": " + p.itemsDone(false) + ", "
switch {
case len(barHead+extra+tail) <= beside:
return fmt.Sprintf("%-*s%s", beside, barHead+extra+tail,
p.percentBar())
case len(barHead+tail) <= beside:
return fmt.Sprintf("%-*s%s", beside, barHead+tail, p.percentBar())
}
// The bar gives way. The rate, or the words of a stalled phase,
// gave way before it and stay out.
extra = ""
}
full, kept := head+extra+tail, head+tail
switch {
@@ -538,10 +526,10 @@ func (p *progress) itemsDone(percent bool) string {
return fmt.Sprintf("%s %.0f%%", done, pct)
}
// remain returns the time the phase has left at rate items a second, or
// eta returns the time the phase has left at rate items a second, or
// "unknown": with no total, before the first item, and once the count
// passes an estimate. p.mu must be held.
func (p *progress) remain(rate float64) string {
func (p *progress) eta(rate float64) string {
switch {
case p.total < 0, p.estimate && p.count > p.total:
return "unknown"
@@ -551,8 +539,8 @@ func (p *progress) remain(rate float64) string {
return "unknown"
}
left := time.Duration(
remaining := time.Duration(
float64(p.total-p.count) / rate * float64(time.Second))
return left.Round(time.Second).String()
return remaining.Round(time.Second).String()
}
+120 -271
View File
@@ -23,7 +23,7 @@ const testRedraw = time.Millisecond
const testDeadline = 5 * time.Second
// hashStalledOn is where the hash phase says it waits once it has
// stalled, as hashPhase gives it.
// stalled, as readRuns gives it.
const hashStalledOn = "disk/db"
// testClock is a clock a test moves by hand, for a display that reads
@@ -117,38 +117,38 @@ func TestProgressLine(t *testing.T) {
label: scanPhase("load"), total: -1,
waitingFor: countingRecords,
},
want: "[1/4] load: counting the records, elapsed 10s, remain unknown",
want: "[1/5] load: counting the records, elapsed 10s, eta unknown",
}, {
p: &progress{
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
},
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, {
p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100},
want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, remain unknown",
want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown",
}, {
p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0},
want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, remain 0s",
want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s",
}, {
p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: 100,
estimate: true, count: 20,
},
want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, remain 40s",
want: "[2/5] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, {
// Past the estimate the walk cannot tell how long it has left.
p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: 100,
estimate: true, count: 120,
},
want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
"remain unknown",
want: "[2/5] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
"eta unknown",
}, {
// A first scan's walk has no estimate.
p: &progress{
label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20,
},
want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, remain unknown",
want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta unknown",
}, {
// The rate is in what the phase counts.
p: &progress{
@@ -156,14 +156,14 @@ func TestProgressLine(t *testing.T) {
count: 20,
},
want: "[3/4] group: [20/100] 20%, 2 directories/s, elapsed 10s, " +
"remain 40s",
"eta 40s",
}}
for _, tc := range cases {
tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now
tc.p.drawn = []drawnCount{{at: ago}}
if got := tc.p.line(now, math.MaxInt, false); got != tc.want {
if got := tc.p.line(now, math.MaxInt, true); got != tc.want {
t.Errorf("line = %q, want %q", got, tc.want)
}
}
@@ -184,9 +184,9 @@ func TestLineFitsRoom(t *testing.T) {
start: ago, rateStart: ago, lastItem: now,
drawn: []drawnCount{{at: ago}},
}
load := &progress{
label: scanPhase("load"), total: -1,
waitingFor: countingRecords, start: ago, rateStart: ago,
content := &progress{
label: scanPhase("content"), total: -1,
waitingFor: "searching the database", start: ago, rateStart: ago,
}
started := now.Add(-50 * time.Second)
@@ -198,49 +198,48 @@ func TestLineFitsRoom(t *testing.T) {
}
cases := []struct {
p *progress
room int
bar bool
want string
p *progress
room int
percent bool
want string
}{{
p: hash, room: 60,
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
p: hash, room: 57, percent: true,
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
}, {
p: hash, room: 59,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
p: hash, room: 56, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s",
}, {
p: hash, room: 48,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
p: hash, room: 45, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40",
}, {
// The bar shows the percent.
p: hash, room: 74, bar: true,
want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" +
" 20% |██ |",
// Before the library's bar, which shows the percent.
p: hash, room: 57, percent: false,
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
}, {
p: load, room: 43,
want: "[1/4] load: elapsed 10s, remain unknown",
p: content, room: 40, percent: true,
want: "[5/5] content: elapsed 10s, eta unknown",
}, {
p: stalled, room: 68,
want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"remain 3m20s",
p: stalled, room: 65, percent: true,
want: "[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"eta 3m20s",
}, {
p: stalled, room: 67,
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
p: stalled, room: 64, percent: true,
want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s",
}}
for _, tc := range cases {
if got := tc.p.line(now, tc.room, tc.bar); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got,
tc.want)
if got := tc.p.line(now, tc.room, tc.percent); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q",
tc.room, tc.percent, got, tc.want)
}
}
}
// TestSetTotalStartsRate checks that setTotal starts a phase's rate,
// its time remaining and its time with no record read from when the
// total becomes known, not from when the phase started waiting for it:
// after 20 seconds of waiting for the total, the phase has not stalled 5
// seconds after it, and has 10 seconds after it with no record read.
// its ETA and its time with no record read from when the total becomes
// known, not from when the phase started waiting for it: after 20
// seconds of waiting for the total, the phase has not stalled 5 seconds
// after it, and has 10 seconds after it with no record read.
func TestSetTotalStartsRate(t *testing.T) {
t.Parallel()
@@ -256,15 +255,15 @@ func TestSetTotalStartsRate(t *testing.T) {
clock.Add(5 * time.Second)
want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown"
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
want := "[1/5] load: [0/100] 0%, 0 files/s, elapsed 25s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want)
}
clock.Add(5 * time.Second)
want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown"
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
want = "[1/5] load: [0/100] 0%, stalled db, elapsed 30s, eta unknown"
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want)
}
@@ -273,8 +272,8 @@ func TestSetTotalStartsRate(t *testing.T) {
p.increment()
}
want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s"
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
want = "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want)
}
}
@@ -317,9 +316,9 @@ func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) {
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown"
want := "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown"
if len(lines) != 2 || lines[0] != want ||
!strings.HasPrefix(lines[1], "[3/4] hash: [1000/1000] 100%, ") {
!strings.HasPrefix(lines[1], "[3/5] hash: [1000/1000] 100%, ") {
t.Errorf("stderr lines = %q, want %q, then the line at 1000",
lines, want)
}
@@ -337,7 +336,7 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
}, nil, testRedraw, time.Now)
awaitOutput(t, stderr,
"[1/4] load: counting the records, elapsed 0s, remain unknown\n", 3)
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
p.finish()
}
@@ -367,23 +366,23 @@ func TestPlainProgressStalledProducer(t *testing.T) {
clock.Add(5 * time.Second)
awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, remain 20s\n", 3)
"[3/5] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3)
clock.Add(time.Second)
awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, remain 24s\n", 3)
"[3/5] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3)
clock.Add(4 * time.Second)
awaitOutput(t, stderr,
"[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, remain 40s\n",
"[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 10s, eta 40s\n",
3)
}
// TestPlainProgressRateAfterStall draws a phase's plain line by hand, a
// little over plainInterval after the line before, as a late tick
// does, through a stall and a burst of files after it: the rate covers
// the files read since the line before, while the time remaining comes
// from the phase's average.
// the files read since the line before, while the ETA comes from the
// phase's average.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainProgressRateAfterStall(t *testing.T) {
@@ -411,10 +410,10 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
p.finish()
want := []string{
"[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown",
"[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, remain unknown",
"[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, remain unknown",
"[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, remain 2m15s",
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown",
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown",
"[3/5] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, eta unknown",
"[3/5] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s",
}
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
@@ -427,8 +426,8 @@ func TestPlainProgressRateAfterStall(t *testing.T) {
// redrawInterval, as a terminal does, through a stall of stallTime and
// a burst of files after it: the rate covers the files read over the
// last rateWindow, and falls to 0 once the burst is older than that,
// while the time remaining comes from the phase's average. Plain lines
// show each draw.
// while the ETA comes from the phase's average. Plain lines show each
// draw.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressRateAfterStallRedrawn(t *testing.T) {
@@ -458,8 +457,8 @@ func TestProgressRateAfterStallRedrawn(t *testing.T) {
redraw(rateWindow + redrawInterval)
p.finish()
want := "[3/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, remain 4m45s\n" +
"[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, remain 4m47s\n"
want := "[3/5] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" +
"[3/5] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 4m47s\n"
if !strings.Contains(stderr(), want) {
t.Errorf("stderr = %q, want the lines %q", stderr(), want)
}
@@ -481,10 +480,10 @@ func lastFrame(out string) string {
}
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
// phase, with a bar or without, keeps being drawn while the phase waits
// for its next item, with the elapsed time as of now, and shows every
// item completed before the wait. The terminal is 120 columns wide, so
// the hash phase's display has room for its bar.
// phase, spinner or bar, keeps being drawn while the phase waits for its
// next item, with the elapsed time as of now, and shows every item
// completed before the wait. stderr is not a terminal here, so the
// displays take it to be fallbackWidth wide.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRedrawsWhileWaiting(t *testing.T) {
@@ -507,8 +506,7 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
for _, tc := range cases {
before := len(stderr())
since := func() string { return stderr()[before:] }
p := startProgress(tc.params, func() int { return 120 }, testRedraw,
time.Now)
p := startProgress(tc.params, terminalWidth, testRedraw, time.Now)
for range 50 {
p.increment()
@@ -554,13 +552,13 @@ func drawLate(stderr func() string, params progressParams, width int,
return stderr()[before:]
}
// TestTerminalLinesKeepTimeRemaining checks the line each display shows
// on an 80-column terminal at a large scan's counts and times, and the
// line of trees' read phase at the same counts: the rate is left out,
// so that the elapsed time and the time remaining stay whole.
// TestTerminalLinesKeepETA checks the line each display shows on an
// 80-column terminal at a large scan's counts and times, and the line
// of trees' read phase at the same counts: the rate is left out, so
// that the elapsed time and the ETA stay whole.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
func TestTerminalLinesKeepETA(t *testing.T) {
stderr := captureStderr(t)
cases := []struct {
@@ -572,20 +570,26 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
label: scanPhase("walk"), unit: scanUnit, total: 1200000,
estimate: true,
},
want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
"remain unknown",
want: "[2/5] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
"eta unknown",
}, {
params: progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
},
want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"remain 17h30m0s",
want: "[3/5] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s",
}, {
params: progressParams{
label: scanPhase("content"), unit: scanUnit, total: 9876543,
},
want: "[5/5] content: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"eta 17h30m0s",
}, {
params: progressParams{
label: treesPhase("read"), unit: "records", total: 9876543,
},
want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " +
"remain 17h30m0s",
"eta 17h30m0s",
}}
for _, tc := range cases {
@@ -596,32 +600,30 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
}
}
// TestTerminalStalledLines draws every phase's line once the phase has
// stalled, its last item done 42 seconds before. At an ordinary scan's
// counts and times, 12345 of 45678 items done four and a half minutes
// in, on a terminal 100 columns wide, the line shows where the phase
// waits whole, beside the phase, progress, elapsed time and time
// remaining. At a large scan's, 1234567 of 9876543 two and a half hours
// in, on an 80-column terminal, those four still show whole, as the
// words give way first where they do not fit.
// TestTerminalStalledLines draws every phase's line on an 80-column
// terminal once the phase has stalled, its last item done 42 seconds
// before. At an ordinary scan's counts and times, 12345 of 45678 items
// done four and a half minutes in, the line shows where the phase waits
// whole, beside the phase, progress, elapsed time and ETA. At a large
// scan's, 1234567 of 9876543 two and a half hours in, those four still
// show whole, as the words give way first where they do not fit.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalStalledLines(t *testing.T) {
stderr := captureStderr(t)
// drawStalled draws the display params describes on a terminal width
// columns wide as it starts and once more as it ends, with count of
// total items done elapsed into the phase, and returns what the
// terminal shows.
drawStalled := func(params progressParams, width int, total,
count int64, elapsed time.Duration,
// drawStalled draws the display params describes as it starts and
// once more as it ends, with count of total items done elapsed into
// the phase, and returns what the terminal shows.
drawStalled := func(params progressParams, total, count int64,
elapsed time.Duration,
) string {
before := len(stderr())
clock := &testClock{now: time.Now()}
params.total = total
// No timed draws come between the two.
p := startProgress(params, func() int { return width }, time.Hour,
p := startProgress(params, func() int { return 80 }, time.Hour,
clock.Now)
clock.Add(elapsed - 42*time.Second)
@@ -641,6 +643,9 @@ func TestTerminalStalledLines(t *testing.T) {
{label: scanPhase("walk"), estimate: true, stalledOn: "disk"},
{label: scanPhase("hash"), stalledOn: hashStalledOn},
{label: scanPhase("update"), stalledOn: "db"},
// The content phase's checks and reads, then its last write.
{label: scanPhase("content"), stalledOn: "disk/db"},
{label: scanPhase("content"), stalledOn: "db"},
{label: treesPhase("read"), stalledOn: "db"},
{label: treesPhase("group"), stalledOn: "cpu"},
{label: treesPhase("write"), stalledOn: "out"},
@@ -653,22 +658,19 @@ func TestTerminalStalledLines(t *testing.T) {
approx = "~"
}
// The percent is in the line, or beside the bar.
shown := drawStalled(params, 100, 45678, 12345,
shown := drawStalled(params, 45678, 12345,
4*time.Minute+30*time.Second)
for _, want := range []string{
params.label + ": [12345/" + approx + "45678]",
", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s",
} {
if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want)
}
want := params.label + ": [12345/" + approx + "45678] 27%, stalled " +
params.stalledOn + ", elapsed 4m30s, eta 12m9s"
if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want)
}
shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute)
shown = drawStalled(params, 9876543, 1234567, 150*time.Minute)
for _, want := range []string{
params.label + ": [1234567/" + approx + "9876543] 12%, ",
", elapsed 2h30m0s, remain 17h30m0s",
", elapsed 2h30m0s, eta 17h30m0s",
} {
if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want)
@@ -691,7 +693,7 @@ func TestTerminalLinesFitWidth(t *testing.T) {
for _, width := range []int{120, 100, 80, 72, 60, 50, 40} {
for _, total := range []int64{9876543, 20000} {
params := progressParams{
label: scanPhase("update"), unit: scanUnit, total: total,
label: scanPhase("content"), unit: scanUnit, total: total,
}
out := drawLate(stderr, params, width, total/8)
@@ -713,164 +715,13 @@ func TestTerminalLinesFitWidth(t *testing.T) {
}
}
// TestTerminalLinesStartWithSpinner draws a display with no total and
// one with a bar, two and a half hours into the phase, on a terminal 120
// columns wide. Each line must start with the library's spinner in the
// first column, then a space and the phase's label.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesStartWithSpinner(t *testing.T) {
stderr := captureStderr(t)
for _, params := range []progressParams{
{label: scanPhase("walk"), unit: scanUnit, total: -1},
{label: scanPhase("hash"), unit: scanUnit, total: 9876543},
} {
shown := lastFrame(drawLate(stderr, params, 120, 1234567))
spinner, rest, _ := strings.Cut(shown, " ")
if utf8.RuneCountInString(spinner) != 1 ||
!strings.HasPrefix(rest, params.label+": ") {
t.Errorf("terminal shows %q, want the spinner, a space and %q",
shown, params.label)
}
if params.total > 0 && !strings.Contains(shown, "% |") {
t.Errorf("terminal shows %q, want a bar", shown)
}
}
}
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two
// and a half hours in, at two counts whose lines differ in length, on
// terminals 110 and 120 columns wide. The bar is partly filled at the
// first count and full at the second. It must start the same number of
// columns from the terminal's right edge each time, and end the same
// number of columns from it: a line that grows or shrinks, as its time
// remaining does, never moves the bar, and nor do the bar's filled
// cells.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalBarStaysAtRightEdge(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
widths := []int{110, 120}
counts := []int64{1234567, 9876543}
startFromEdge := make([]int, 0, len(widths)*len(counts))
endFromEdge := make([]int, 0, len(widths)*len(counts))
for _, width := range widths {
for _, count := range counts {
shown := lastFrame(drawLate(stderr, params, width, count))
beforeBar, _, found := strings.Cut(shown, "% |")
if !found {
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
}
// The bar ends with the last "|".
throughBar := shown[:strings.LastIndex(shown, "|")+1]
startFromEdge = append(startFromEdge,
width-utf8.RuneCountInString(beforeBar))
endFromEdge = append(endFromEdge,
width-utf8.RuneCountInString(throughBar))
}
}
if slices.Min(startFromEdge) != slices.Max(startFromEdge) {
t.Errorf("bar starts %v columns from the right edge, want the same "+
"every time", startFromEdge)
}
if slices.Min(endFromEdge) != slices.Max(endFromEdge) {
t.Errorf("bar ends %v columns from the right edge, want the same "+
"every time", endFromEdge)
}
}
// TestTerminalRateGivesWayBeforeBar draws the hash phase's display two
// and a half hours in. On a terminal 90 columns wide the line fits
// beside the bar without its rate, but not with it: the bar is drawn
// and the rate is not. On a terminal 85 columns wide the line does not
// fit beside the bar even without its rate: neither is drawn, though
// the rate would fit where the bar was.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRateGivesWayBeforeBar(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
shown := lastFrame(drawLate(stderr, params, 90, 1234567))
if !strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
t.Errorf("terminal 90 wide shows %q, want the bar and no rate", shown)
}
shown = lastFrame(drawLate(stderr, params, 85, 1234567))
if strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
t.Errorf("terminal 85 wide shows %q, want neither bar nor rate", shown)
}
}
// TestTerminalSpinnerTurnsBesideBar draws the hash phase's display, its
// bar partly filled, several times on a terminal 120 columns wide. The
// spinner must turn at every draw. A filled cell of the bar takes one
// column but three bytes. A line measured in bytes would seem too wide
// for the terminal, and every draw would start the display over, its
// spinner included.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalSpinnerTurnsBesideBar(t *testing.T) {
stderr := captureStderr(t)
params := progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}
// No timed draws: the test draws.
p := startProgress(params, func() int { return 120 }, time.Hour,
time.Now)
p.mu.Lock()
p.count = 1234567
p.mu.Unlock()
const draws = 4
spinners := make([]string, 0, draws)
for range draws {
before := len(stderr())
p.draw()
spinner, _, _ := strings.Cut(lastFrame(stderr()[before:]), " ")
spinners = append(spinners, spinner)
}
p.finish()
for i := 1; i < len(spinners); i++ {
if spinners[i] == spinners[i-1] {
t.Errorf("spinners at successive draws are %q, want each to "+
"differ from the one before", spinners)
break
}
}
}
// TestTerminalNarrowedRedrawsInPlace draws a large scan's display, two
// and a half hours into the phase, on a terminal 120 columns wide, then
// makes the terminal narrower than that line and draws it again a few
// times. Nothing written after that, the erase before each redraw
// included, may reach the narrower terminal's last column: it would
// wrap, and every redraw would leave a line behind. At the narrower
// width the hash phase's bar gives way.
// wrap, and every redraw would leave a line behind. At these widths the
// spinner stays a spinner and the bar gives way to the spinner.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
@@ -888,7 +739,7 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
},
80,
90,
},
}
@@ -928,10 +779,10 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
}
}
// TestProgressWarningsOnOwnLines drives the terminal displays, with a
// bar and without, through a run of warnings with no items between
// them, as when the walk meets many unreadable paths, for many redraws:
// every warning must land on a line of its own, never inside a redraw.
// TestProgressWarningsOnOwnLines drives the terminal displays, spinner
// and bar, through a run of warnings with no items between them, as when
// the walk meets many unreadable paths, for many redraws: every warning
// must land on a line of its own, never inside a redraw.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWarningsOnOwnLines(t *testing.T) {
@@ -977,12 +828,11 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
}
}
// TestProgressWriterRowsOnOwnLines drives the terminal displays, with a
// bar and without, through a run of rows written through the display's
// TestProgressWriterRowsOnOwnLines drives the terminal displays,
// spinner and bar, through a run of rows written through the display's
// writer to the same terminal, as stdout is in a plain "sfdupes trees",
// each written out at once, for many redraws: the terminal must show
// every row whole, in order, on a line of its own. The terminal is 120
// columns wide, so the write phase's display has room for its bar.
// every row whole, in order, on a line of its own.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWriterRowsOnOwnLines(t *testing.T) {
@@ -999,8 +849,7 @@ func TestProgressWriterRowsOnOwnLines(t *testing.T) {
var want []string
for _, params := range cases {
p := startProgress(params, func() int { return 120 }, testRedraw,
time.Now)
p := startProgress(params, terminalWidth, testRedraw, time.Now)
out := p.writer(os.Stderr)
// No pause between rows: one is garbled only if a redraw lands
+9 -4
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"crypto/sha256"
"database/sql"
"fmt"
"io"
@@ -25,9 +24,9 @@ const minGroupSize = 2
type scanRec struct {
size int64
mtime time.Time
head [sha256.Size]byte
tail [sha256.Size]byte
content [sha256.Size]byte
head string
tail string
content string
path string
}
@@ -170,6 +169,12 @@ type reportWriter struct {
func (w *reportWriter) add(r scanRec) error {
w.records++
// A record without a content hash has unknown content (README
// "Database"), so it is never a duplicate.
if r.content == "" {
return nil
}
if r.size == w.sig.size && r.head == w.sig.head &&
r.tail == w.sig.tail && r.content == w.sig.content {
w.paths = append(w.paths, r.path)
+47 -66
View File
@@ -2,7 +2,6 @@ package main
import (
"bytes"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
@@ -19,27 +18,14 @@ import (
// awkwardDir is a directory name holding every byte the reports escape.
const awkwardDir = "/d/\tone\ntwo\rthree\\four"
// testHash returns a stand-in hash for a test record: the bytes of s,
// then zeros. Stand-ins sort as their strings do, so a test can choose
// the order in which report reads its records.
func testHash(s string) [sha256.Size]byte {
var h [sha256.Size]byte
copy(h[:], s)
return h
}
// awkwardPairRecs is a duplicate pair in sibling directories /d/A and
// awkwardDir. A raw tab sorts before "A" but its escaped form `\t`
// sorts after it, so awkwardDir coming first shows that sorting uses
// the raw path.
func awkwardPairRecs() []scanRec {
h, tl, c := testHash("h"), testHash("t"), testHash("c")
return []scanRec{
{size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
{size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
{size: 5, head: "h", tail: "t", content: "c", path: "/d/A/f"},
{size: 5, head: "h", tail: "t", content: "c", path: awkwardDir + "/f"},
}
}
@@ -157,8 +143,8 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
recs := make([]scanRec, 2*ioBufSize/len(dir))
for i := range recs {
recs[i] = scanRec{
size: int64(i/2) + 1, head: testHash("h"), tail: testHash("t"),
content: testHash("c"), path: fmt.Sprintf("%s/%d", dir, i),
size: int64(i/2) + 1, head: "h", tail: "t", content: "c",
path: fmt.Sprintf("%s/%d", dir, i),
}
}
@@ -187,7 +173,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// reportOpenLine is the plain line of report's open phase when it opens
// the database at once; it prints it as the phase starts and as it ends.
const reportOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
"remain unknown"
"eta unknown"
// smokeReport is report's stdout for the smoke-test records.
const smokeReport = "first\tdupe\tsize\n" +
@@ -225,7 +211,7 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
"report: database " + path + " (from " + databaseEnv + ")",
reportOpenLine,
reportOpenLine,
"[2/2] read: counting the records, elapsed 0s, remain unknown",
"[2/2] read: counting the records, elapsed 0s, eta unknown",
"[2/2] read: [6/6] 100%, ",
"report: 6 records read, 2 duplicate groups, 4 dupe files, " +
"6.1 KiB reclaimable",
@@ -291,14 +277,13 @@ func TestReadReportOnTerminal(t *testing.T) {
func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
// README §Constraints: identical database contents give identical
// output, whatever order the records were inserted in.
a, b := testHash("a"), testHash("b")
recs := append(smokeTreeRecs(), awkwardPairRecs()...)
recs = append(recs,
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/2"},
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/1"},
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/2"},
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"},
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/2"},
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/1"},
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/2"},
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/1"},
scanRec{size: 50, path: "/x/unhashed"},
)
reversed := slices.Clone(recs)
@@ -401,19 +386,16 @@ func TestWarnfEscapes(t *testing.T) {
func TestDupeGroups(t *testing.T) {
t.Parallel()
h, tl, c := testHash("h"), testHash("t"), testHash("c")
bigH, bigT, bigC := testHash("H"), testHash("T"), testHash("C")
u := testHash("u")
recs := []scanRec{
{size: 100, head: h, tail: tl, content: c, path: "/z/b"},
{size: 100, head: h, tail: tl, content: c, path: "/z/a"},
{size: 100, head: h, tail: tl, content: c, path: "/z/c"},
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/2"},
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/1"},
{size: 100, head: "h", tail: "t", content: "c", path: "/z/b"},
{size: 100, head: "h", tail: "t", content: "c", path: "/z/a"},
{size: 100, head: "h", tail: "t", content: "c", path: "/z/c"},
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/2"},
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/1"},
// Same size as the /z group but a different head hash.
{size: 100, head: testHash("other"), tail: tl, content: c, path: "/z/d"},
{size: 100, head: "other", tail: "t", content: "c", path: "/z/d"},
// A singleton signature must not form a group.
{size: 7, head: u, tail: u, content: u, path: "/lonely"},
{size: 7, head: "u", tail: "u", content: "u", path: "/lonely"},
}
groups := dupeGroupsOf(t, recs)
@@ -437,13 +419,16 @@ func TestDupeGroups(t *testing.T) {
func TestDupeGroupsContentSeparates(t *testing.T) {
t.Parallel()
// Same size, head, and tail: /b differs from the others only in its
// content hash, so it joins no group, while /a and /c group.
h, tl := testHash("h"), testHash("t")
// Same size, head, and tail, but different content hashes: the final
// rung keeps them apart, so no group forms. Matching content groups.
// Records without a content hash never group, not even with each
// other.
recs := []scanRec{
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/a"},
{size: 100, head: h, tail: tl, content: testHash("c2"), path: "/b"},
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/c"},
{size: 100, head: "h", tail: "t", content: "c1", path: "/a"},
{size: 100, head: "h", tail: "t", content: "c2", path: "/b"},
{size: 100, head: "h", tail: "t", content: "c1", path: "/c"},
{size: 100, head: "h", tail: "t", path: "/d"},
{size: 100, head: "h", tail: "t", path: "/e"},
}
groups := dupeGroupsOf(t, recs)
@@ -462,15 +447,14 @@ func TestDupeGroupsMtimeExcluded(t *testing.T) {
// mtime is informational only; records differing only in mtime
// still group together.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{
{
size: 9, mtime: time.Unix(100, 0), head: h, tail: tl,
content: c, path: "/m/1",
size: 9, mtime: time.Unix(100, 0), head: "h", tail: "t",
content: "c", path: "/m/1",
},
{
size: 9, mtime: time.Unix(200, 0), head: h, tail: tl,
content: c, path: "/m/2",
size: 9, mtime: time.Unix(200, 0), head: "h", tail: "t",
content: "c", path: "/m/2",
},
}
@@ -485,12 +469,11 @@ func TestDupeGroupsTieBreak(t *testing.T) {
// The hashes sort opposite to the first paths, so ordering the
// groups by hash instead of by first path fails this test.
a, b := testHash("a"), testHash("b")
recs := []scanRec{
{size: 50, head: a, tail: a, content: a, path: "/beta/2"},
{size: 50, head: a, tail: a, content: a, path: "/beta/1"},
{size: 50, head: b, tail: b, content: b, path: "/alpha/2"},
{size: 50, head: b, tail: b, content: b, path: "/alpha/1"},
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/2"},
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/2"},
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/1"},
}
groups := dupeGroupsOf(t, recs)
@@ -512,16 +495,15 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
// /z, /k, which is neither the order of their first paths nor its
// reverse, and must write them ordered by first path. The group of
// size 7 comes after them all, though its first path sorts first.
a, b, c, d := testHash("a"), testHash("b"), testHash("c"), testHash("d")
recs := []scanRec{
{size: 50, head: a, tail: a, content: a, path: "/k/2"},
{size: 50, head: a, tail: a, content: a, path: "/k/1"},
{size: 50, head: b, tail: b, content: b, path: "/z/1"},
{size: 50, head: b, tail: b, content: b, path: "/z/2"},
{size: 50, head: c, tail: c, content: c, path: "/a/2"},
{size: 50, head: c, tail: c, content: c, path: "/a/1"},
{size: 7, head: d, tail: d, content: d, path: "/0/2"},
{size: 7, head: d, tail: d, content: d, path: "/0/1"},
{size: 50, head: "a", tail: "a", content: "a", path: "/k/2"},
{size: 50, head: "a", tail: "a", content: "a", path: "/k/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/z/1"},
{size: 50, head: "b", tail: "b", content: "b", path: "/z/2"},
{size: 50, head: "c", tail: "c", content: "c", path: "/a/2"},
{size: 50, head: "c", tail: "c", content: "c", path: "/a/1"},
{size: 7, head: "d", tail: "d", content: "d", path: "/0/2"},
{size: 7, head: "d", tail: "d", content: "d", path: "/0/1"},
}
groups := dupeGroupsOf(t, recs)
@@ -540,12 +522,11 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
func TestDupeGroupsDeterministic(t *testing.T) {
t.Parallel()
a, b := testHash("a"), testHash("b")
recs := []scanRec{
{size: 1, head: a, tail: a, content: a, path: "/p/1"},
{size: 1, head: a, tail: a, content: a, path: "/p/2"},
{size: 2, head: b, tail: b, content: b, path: "/q/1"},
{size: 2, head: b, tail: b, content: b, path: "/q/2"},
{size: 1, head: "a", tail: "a", content: "a", path: "/p/1"},
{size: 1, head: "a", tail: "a", content: "a", path: "/p/2"},
{size: 2, head: "b", tail: "b", content: "b", path: "/q/1"},
{size: 2, head: "b", tail: "b", content: "b", path: "/q/2"},
}
forward := dupeGroupsOf(t, recs)
+508 -204
View File
File diff suppressed because it is too large Load Diff
+719 -348
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -286,9 +286,18 @@ func (b *treeBuilder) finish() (*treeNode, []*treeNode) {
// name and its signature (size, head, tail, content); mtime is
// excluded.
func fileEntry(name string, r scanRec) string {
content := r.content
// A record without a content hash has unknown content (README
// "Database"): give it a signature no other file can share, so
// trees containing it never compare equal. Real hashes are hex, so
// the NUL-prefixed form cannot collide.
if content == "" {
content = "unhashed\x00" + r.path
}
return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) +
"\x00" + string(r.head[:]) + "\x00" + string(r.tail[:]) +
"\x00" + string(r.content[:])
"\x00" + r.head + "\x00" + r.tail + "\x00" + content
}
// collectTreeGroups groups directories by digest, counting each one on
@@ -338,8 +347,7 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
// digest is the SHA-256 of its child entries — files serialized with
// name and signature, subdirectories with name and recursive digest —
// sorted byte-lexicographically. Filenames cannot contain NUL or "/",
// and every hash and digest is 32 bytes, so NUL delimiters are
// unambiguous.
// so NUL delimiters are unambiguous.
func (n *treeNode) computeDigest() {
slices.Sort(n.entries)
+26 -21
View File
@@ -10,17 +10,24 @@ import (
"time"
)
// Signature hashes shared by the smoke-test records.
const (
f1Head = "f1h"
f1Tail = "f1t"
f1Content = "f1c"
f2Head = "f2h"
f2Tail = "f2t"
f2Content = "f2c"
)
// treesOpenLine is the plain line of trees' open phase when it opens
// the database at once; it prints it as the phase starts and as it ends.
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
"remain unknown"
"eta unknown"
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
// /d/t2 are identical, /d/t3 differs from them only by one filename.
func smokeTreeRecs() []scanRec {
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
return []scanRec{
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
@@ -146,14 +153,13 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
for _, top := range []string{"/a", "/c"} {
for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} {
content := testHash("c")
content := "c"
if p == "/b-x/f" {
content = testHash("other")
content = "other"
}
recs = append(recs, scanRec{
size: 1, head: testHash("h"), tail: testHash("t"),
content: content, path: top + p,
size: 1, head: "h", tail: "t", content: content, path: top + p,
})
}
}
@@ -236,11 +242,11 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
"trees: database " + path + " (from " + databaseEnv + ")",
treesOpenLine,
treesOpenLine,
"[2/4] read: counting the records, elapsed 0s, remain unknown",
"[2/4] read: counting the records, elapsed 0s, eta unknown",
"[2/4] read: [6/6] 100%, ",
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown",
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, eta unknown",
"[3/4] group: [8/8] 100%, ",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, eta unknown",
"[4/4] write: [1/1] 100%, ",
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
}
@@ -326,10 +332,11 @@ func TestTreeDigests(t *testing.T) {
func TestTreeDigestContentSensitivity(t *testing.T) {
t.Parallel()
same, c := testHash("same"), testHash("c")
const sharedTail = "same"
recs := []scanRec{
{size: 10, head: same, tail: same, content: c, path: "/r/a/f"},
{size: 10, head: testHash("DIFF"), tail: same, content: c, path: "/r/b/f"},
{size: 10, head: sharedTail, tail: sharedTail, content: "c", path: "/r/a/f"},
{size: 10, head: "DIFF", tail: sharedTail, content: "c", path: "/r/b/f"},
}
_, dirs := treeOf(t, recs)
@@ -390,10 +397,9 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
// Identical sibling dirs share a parent, so their group cannot be
// implied by a parent group and must be reported.
h, tl, c := testHash("h"), testHash("t"), testHash("c")
recs := []scanRec{
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x1/f"},
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x2/f"},
}
super, dirs := treeOf(t, recs)
@@ -412,11 +418,10 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
// /p/a and /q/b contain identical x subtrees, but /p/a has an
// extra file, so the parents' digests differ and the x group must
// be reported.
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"), testHash("e")
recs := []scanRec{
{size: 10, head: h, tail: tl, content: c, path: "/p/a/x/f"},
{size: 99, head: e, tail: e, content: e, path: "/p/a/extra"},
{size: 10, head: h, tail: tl, content: c, path: "/q/b/x/f"},
{size: 10, head: "h", tail: "t", content: "c", path: "/p/a/x/f"},
{size: 99, head: "e", tail: "e", content: "e", path: "/p/a/extra"},
{size: 10, head: "h", tail: "t", content: "c", path: "/q/b/x/f"},
}
super, dirs := treeOf(t, recs)