Author SHA1 Message Date
sneak 16df355ef2 Rewrite README.md sentence by sentence for a newcomer
check / check (push) Waiting to run
Rewrites `README.md` from scratch for
#135. Every statement was checked
against the code on `next` and against what the built binary prints on
small trees under `/var/tmp`. The headings that the code comments cite
are kept. Five `main_test.go` comments cite "Settings", two cite what
"Error handling" says about a failed write to stdout, and one comment
each in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what
the README now says.

Where the old text and the repository disagreed, the new text states
what the code does: `@latest` installs the newest tagged release, ten
million files in both "Rationale" and "Design", no Windows excludes, and
an operand reached through a symlinked directory below another operand
is dropped (#144).

Model: fable-5-1
2026-10-26 18:05:31 +00:00
clawbot ee09cda355 Never scan an operand inside a .zfs directory (closes #142)
check / check (push) Waiting to run
An operand inside a directory named .zfs was walked, because the
operand check looked only at the operand's own name. The check now
also looks at every directory above the operand, both as given and
with its symlinks resolved. Such an operand gets a warning, counts as
skipped and is not walked, whether given alone or under another
operand. The check runs before overlapping operands are pruned, so an
operand under another one still gets its warning.

The test for an operand named .zfs now writes its record beneath .zfs
straight into the database, since no scan stores one there any more.

Judgement call: a path component named .zfs counts whatever it is, a
symlink included.

Model: opus-5-5
2026-10-26 15:15:37 +01:00
clawbot aa99b37b99 Skip removed paths silently and fix the progress line (closes #133)
check / check (push) Canceled after 0s
A path removed while the scan runs gets no warning and no skipped
count, in the walk or the hash phase. If the first of several hard
links is gone, the next is read. Other errors are still warned about.

On a terminal every display is the library's spinner, then the line. A
phase with an exact total ends the padded line with its percent and a
bar at a fixed column, drawn by sfdupes, as the library shows no
spinner beside its own bar. A too-wide line loses its rate first.

The time remaining is labelled remain, not eta.

Judgement call: an operand removed mid-scan is skipped silently too.
Deviation: README's stalled-line example keeps its words on 100
columns, not 80.

Model: opus-5-5
2026-10-26 06:32:38 +01:00
clawbot 68881f04f2 Store each hash as its raw 32 bytes in a BLOB column (closes #134)
check / check (push) Canceled after 0s
The head, tail and content columns of the files table are BLOB NOT
NULL and hold the raw 32-byte SHA-256, half the size of the hex text,
in the table and in the files_signature index. The hash phase, the
records, report and trees carry each hash as a [sha256.Size]byte and
compare it with ==; nothing is hex-encoded. Reading a record whose
hash column is not 32 bytes is an error naming its path.

In the tests, testHash is a stand-in hash: a string's bytes padded
with zeros, so fixtures keep the sort order their strings give.

Judgement call: a hash of the wrong length is refused on reading,
not copied silently.

Model: opus-5-5
2026-10-26 03:15:38 +01:00
clawbot f7a746f912 Hash every file's head, tail and content in the hash phase (closes #132)
check / check (push) Canceled after 0s
Every file the walk records now gets three hashes: head and tail, of
its first and last 64 KiB, and content. The content hash covers the
whole file under 100 MiB, and 1 MiB at each gigabyte from 100 MiB up,
so a large file is never read in full. One 100 MiB constant replaces
the 10 MiB and 50 MiB thresholds. The size census, the records stored
without hashes and the content phase are gone, so a rescan reads only
new and changed files. report and trees no longer special-case a
missing content hash. README "Duplicate detection" and "Rationale" are
rewritten.

Judgement call: info drops its two hash-count lines, which now always
equal the record count.

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