next into main: the duplicate check, hash storage, display and .zfs fixes from the trial #137

Open
clawbot wants to merge 10 commits from next into main
18 changed files with 3749 additions and 3352 deletions
+1117 -964
View File
File diff suppressed because it is too large Load Diff
+631 -407
View File
File diff suppressed because it is too large Load Diff
+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)
+187 -140
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -46,18 +47,20 @@ const dbFilePerm = 0o644
const lockFilePerm = 0o600 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.
// holds whole Unix seconds and mtime_nsec the nanoseconds within that // disk_size holds the bytes the file takes on disk. mtime holds whole
// second. // Unix seconds and mtime_nsec the nanoseconds within that 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,
disk_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
` `
@@ -67,14 +70,59 @@ const createIndexSQL = `
CREATE INDEX files_signature ON files (size, head, tail, content) CREATE INDEX files_signature ON files (size, head, tail, content)
` `
// createTotalsSQL creates the totals table, which holds one row: the
// number of records and the sizes of all records added up. Counting
// them over files would read every record. The three triggers below
// keep the row current, in the transaction of each change to files.
const createTotalsSQL = `
CREATE TABLE totals (
records INTEGER NOT NULL,
size INTEGER NOT NULL
)
`
// insertTotalsSQL adds the one row of totals, for an empty files table.
const insertTotalsSQL = `
INSERT INTO totals (records, size) VALUES (0, 0)
`
// totalsAfterInsertSQL counts each inserted record into totals.
const totalsAfterInsertSQL = `
CREATE TRIGGER totals_after_insert AFTER INSERT ON files BEGIN
UPDATE totals SET records = records + 1, size = size + NEW.size;
END
`
// totalsAfterDeleteSQL takes each deleted record out of totals.
const totalsAfterDeleteSQL = `
CREATE TRIGGER totals_after_delete AFTER DELETE ON files BEGIN
UPDATE totals SET records = records - 1, size = size - OLD.size;
END
`
// totalsAfterUpdateSQL replaces a record's old size in totals with its
// new one. scan's upsert of an existing record is such an update.
const totalsAfterUpdateSQL = `
CREATE TRIGGER totals_after_update AFTER UPDATE OF size ON files BEGIN
UPDATE totals SET size = size + NEW.size - OLD.size;
END
`
// recordCountSQL reads the number of records from totals.
const recordCountSQL = `
SELECT records FROM totals
`
// upsertSQL inserts one file record, replacing any existing record for // upsertSQL inserts one file record, replacing any existing record for
// the same path. // the same path.
const upsertSQL = ` const upsertSQL = `
INSERT INTO files (path, size, mtime, mtime_nsec, head, tail, content) INSERT INTO files (
VALUES (?, ?, ?, ?, ?, ?, ?) path, size, disk_size, mtime, mtime_nsec, head, tail, content
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (path) DO UPDATE SET ON CONFLICT (path) DO UPDATE SET
size = excluded.size, mtime = excluded.mtime, size = excluded.size, disk_size = excluded.disk_size,
mtime_nsec = excluded.mtime_nsec, mtime = excluded.mtime, mtime_nsec = excluded.mtime_nsec,
head = excluded.head, tail = excluded.tail, head = excluded.head, tail = excluded.tail,
content = excluded.content content = excluded.content
` `
@@ -92,6 +140,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
@@ -144,9 +196,14 @@ func createDatabaseDirectory(path string) error {
// scanParams are the connection parameters for scan: read-write, with // scanParams are the connection parameters for scan: read-write, with
// WAL journaling and a busy timeout, so a report can run while a cron // WAL journaling and a busy timeout, so a report can run while a cron
// scan is in progress. closeScanDatabase leaves WAL mode again. // scan is in progress. closeScanDatabase leaves WAL mode again.
//
// Each write to files fires a trigger, so SQLite keeps a statement
// journal for it. temp_store(MEMORY) keeps that journal in memory,
// which costs far less than a temporary file.
const scanParams = "_pragma=busy_timeout(10000)" + const scanParams = "_pragma=busy_timeout(10000)" +
"&_pragma=journal_mode(WAL)" + "&_pragma=journal_mode(WAL)" +
"&_pragma=synchronous(NORMAL)" "&_pragma=synchronous(NORMAL)" +
"&_pragma=temp_store(MEMORY)"
// reportParams are the connection parameters for report, trees and // reportParams are the connection parameters for report, trees and
// info: read-only, with the same busy timeout. They set no journal // info: read-only, with the same busy timeout. They set no journal
@@ -420,14 +477,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
defer func() { _ = tx.Rollback() }() defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(ctx, createTableSQL) for _, stmt := range []string{
createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
} {
_, err = tx.ExecContext(ctx, stmt)
if err != nil { if err != nil {
return fmt.Errorf("create schema: %w", err) return fmt.Errorf("create schema: %w", err)
} }
_, err = tx.ExecContext(ctx, createIndexSQL)
if err != nil {
return fmt.Errorf("create schema: %w", err)
} }
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -474,7 +531,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
var n int64 var n int64
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -482,8 +539,8 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
total(n) total(n)
rows, err := tx.QueryContext(ctx, rows, err := tx.QueryContext(ctx,
"SELECT path, size, mtime, mtime_nsec, head, tail, content "+ "SELECT path, size, disk_size, mtime, mtime_nsec, head, tail, "+
"FROM files ORDER BY path") "content FROM files ORDER BY path")
if err != nil { if err != nil {
return fmt.Errorf("read records: %w", err) return fmt.Errorf("read records: %w", err)
} }
@@ -492,19 +549,25 @@ 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, &r.diskSize, &sec, &nsec,
&r.content) &head, &tail, &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)
} }
@@ -527,10 +590,11 @@ ORDER BY size DESC, head DESC, tail DESC, content DESC
` `
// loadSignatureRows gives total the number of records, then streams // loadSignatureRows gives total the number of records, then streams
// every record's signature and path to fn, without its mtime, in the // every record's signature and path to fn, without its mtime or its
// order of signatureRowsSQL. The count and the records are read in one // disk_size, in the order of signatureRowsSQL. The count and the
// transaction, so they agree while a scan is committing. An error from // records are read in one transaction, so they agree while a scan is
// fn stops the reading and is returned as it is. // committing. An error from fn stops the reading and is returned as it
// is.
func loadSignatureRows(ctx context.Context, db *sql.DB, func loadSignatureRows(ctx context.Context, db *sql.DB,
total func(n int64), fn func(r scanRec) error, total func(n int64), fn func(r scanRec) error,
) error { ) error {
@@ -545,7 +609,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
var n int64 var n int64
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return fmt.Errorf("count records: %w", err) return fmt.Errorf("count records: %w", err)
} }
@@ -561,17 +625,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,11 +655,27 @@ 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
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n) err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n)
if err != nil { if err != nil {
return 0, fmt.Errorf("count records: %w", err) return 0, fmt.Errorf("count records: %w", err)
} }
@@ -598,16 +683,10 @@ 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 reads the number of records and the sizes of all
// hashes and those with a content hash, and adds up their sizes, in one // records added up from totals.
// pass over the table. A SUM over no rows is NULL, so on an empty table
// each SUM falls back to 0.
const databaseCountsSQL = ` const databaseCountsSQL = `
SELECT COUNT(*), SELECT records, size FROM totals
COALESCE(SUM(head <> '' AND tail <> ''), 0),
COALESCE(SUM(content <> ''), 0),
COALESCE(SUM(size), 0)
FROM files
` `
// databaseCounts is what info reads from the database: its schema // databaseCounts is what info reads from the database: its schema
@@ -615,8 +694,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 +721,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,90 +729,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, disk_size and mtime
// it carries hashes to fn. Scan change detection needs no hash // to fn. Scan change detection needs no hash values, and skipping the
// values, and skipping the hash columns keeps the scan's in-memory // hash columns keeps the scan's in-memory index small on
// index small on multi-million-file databases. // multi-million-file 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, m fileMeta),
) error { ) error {
rows, err := db.QueryContext(ctx, rows, err := db.QueryContext(ctx,
"SELECT path, size, mtime, mtime_nsec, head <> '' FROM files") "SELECT path, size, disk_size, mtime, mtime_nsec FROM files")
if err != nil {
return fmt.Errorf("read records: %w", err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
path []byte
size, sec, nsec int64
hashed int64
)
err = rows.Scan(&path, &size, &sec, &nsec, &hashed)
if err != nil {
return fmt.Errorf("read record: %w", err)
}
fn(string(path), size, time.Unix(sec, nsec), hashed != 0)
}
err = rows.Err()
if err != nil {
return fmt.Errorf("read records: %w", err)
}
return nil
}
// contentGroupsSQL selects each group (the records sharing a size,
// head, and tail) of records of at least headTailMin bytes that has more
// than one record and at least one record without a content hash, with
// the number of its records.
const contentGroupsSQL = `
SELECT size, head, tail, COUNT(*) AS records
FROM files
WHERE size >= ? AND head <> ''
GROUP BY size, head, tail
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
`
// contentCandidatesSQL selects every record of the groups
// contentGroupsSQL selects, with whether each record has a content
// hash. SQLite does the grouping, so no other record's hashes are
// loaded into memory; the rows come ordered by size, head, and tail, so
// each group's rows arrive together.
const contentCandidatesSQL = `
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
FROM files AS f
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
ORDER BY size, head, tail
`
// countContentCandidates returns the number of rows of
// contentCandidatesSQL, adding up the records of the groups
// contentGroupsSQL selects, so it neither joins nor sorts.
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
var n int64
err := db.QueryRowContext(ctx,
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
headTailMin).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count content candidates: %w", err)
}
return n, nil
}
// loadContentCandidates streams the rows of contentCandidatesSQL to fn:
// each record, without its content hash, and whether it has one.
func loadContentCandidates(ctx context.Context, db *sql.DB,
fn func(r scanRec, hashed bool),
) error {
rows, err := db.QueryContext(ctx, contentCandidatesSQL, headTailMin)
if err != nil { if err != nil {
return fmt.Errorf("read records: %w", err) return fmt.Errorf("read records: %w", err)
} }
@@ -745,20 +747,18 @@ func loadContentCandidates(ctx context.Context, db *sql.DB,
for rows.Next() { for rows.Next() {
var ( var (
path []byte path []byte
m fileMeta
sec, nsec int64 sec, nsec int64
r scanRec
hashed int64
) )
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail, err = rows.Scan(&path, &m.size, &m.diskSize, &sec, &nsec)
&hashed)
if err != nil { if err != nil {
return fmt.Errorf("read record: %w", err) return fmt.Errorf("read record: %w", err)
} }
r.path = string(path) m.mtime = time.Unix(sec, nsec)
r.mtime = time.Unix(sec, nsec)
fn(r, hashed != 0) fn(string(path), m)
} }
err = rows.Err() err = rows.Err()
@@ -776,21 +776,21 @@ func loadContentCandidates(ctx context.Context, db *sql.DB,
// WAL small and let concurrent reports observe progress. // WAL small and let concurrent reports observe progress.
const updateBatchSize = 10000 const updateBatchSize = 10000
// applyChanges writes one scan's database changes — upserts for new and // applyChanges writes upserts for new and changed files and deletes for
// changed files, deletes for vanished ones — in batched transactions. // vanished ones, in batched transactions. Progress is rendered on prog
// Progress is rendered on prog (one increment per change). // (one increment per change).
func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec, func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
deletes []string, prog *progress, deletes []string, prog *progress,
) error { ) error {
for batch := range slices.Chunk(upserts, updateBatchSize) { for batch := range slices.Chunk(upserts, updateBatchSize) {
err := applyBatch(ctx, db, batch, nil, prog) err := applyBatch(ctx, db, batch, nil, nil, prog)
if err != nil { if err != nil {
return err return err
} }
} }
for batch := range slices.Chunk(deletes, updateBatchSize) { for batch := range slices.Chunk(deletes, updateBatchSize) {
err := applyBatch(ctx, db, nil, batch, prog) err := applyBatch(ctx, db, nil, nil, batch, prog)
if err != nil { if err != nil {
return err return err
} }
@@ -799,10 +799,26 @@ func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
return nil return nil
} }
// applyBatch commits one batch of upserts and deletes in a single // applyDiskSizes writes the disk_size of each of recs into its record,
// transaction. // in batched transactions, and leaves the rest of the record alone.
// Progress is rendered on prog (one increment per record).
func applyDiskSizes(ctx context.Context, db *sql.DB, recs []fileRec,
prog *progress,
) error {
for batch := range slices.Chunk(recs, updateBatchSize) {
err := applyBatch(ctx, db, nil, batch, nil, prog)
if err != nil {
return err
}
}
return nil
}
// applyBatch commits one batch of upserts, disk_size updates and
// deletes in a single transaction.
func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec, func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
deletes []string, prog *progress, diskSizes []fileRec, deletes []string, prog *progress,
) error { ) error {
tx, err := db.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
@@ -816,6 +832,11 @@ func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
return err return err
} }
err = execDiskSizes(ctx, tx, diskSizes, prog)
if err != nil {
return err
}
err = execDeletes(ctx, tx, deletes, prog) err = execDeletes(ctx, tx, deletes, prog)
if err != nil { if err != nil {
return err return err
@@ -841,8 +862,9 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
defer func() { _ = st.Close() }() defer func() { _ = st.Close() }()
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.diskSize,
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)
} }
@@ -853,6 +875,31 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
return nil return nil
} }
// execDiskSizes sets the disk_size of the record of each of recs. It
// does not touch size, so the totals triggers do not fire.
func execDiskSizes(ctx context.Context, tx *sql.Tx, recs []fileRec,
prog *progress,
) error {
st, err := tx.PrepareContext(ctx,
"UPDATE files SET disk_size = ? WHERE path = ?")
if err != nil {
return fmt.Errorf("prepare disk_size update: %w", err)
}
defer func() { _ = st.Close() }()
for _, r := range recs {
_, err = st.ExecContext(ctx, r.diskSize, []byte(r.path))
if err != nil {
return fmt.Errorf("update disk_size of %s: %w", r.path, err)
}
prog.increment()
}
return nil
}
// execDeletes removes the records for paths no longer present. // execDeletes removes the records for paths no longer present.
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string, func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
prog *progress, prog *progress,
+225 -74
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
@@ -473,17 +474,73 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
} }
} }
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL // TestLoadRowsRefuseWrongSizeHash stores a record whose head, tail or
// from the files_signature index alone, with nothing to sort: the // content is not the 32 bytes of a SHA-256: either one byte, or the 64
// first record comes at once, so report's read phase counts each // bytes of a SHA-256 written as hex text. loadFileRows and
// record as it comes rather than waiting on SQLite for all of them. // loadSignatureRows must each fail on it with an error naming its path.
func TestSignatureRowsNeedNoSort(t *testing.T) { 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() t.Parallel()
db := openTestDB(t) db := openTestDB(t)
rows, err := db.QueryContext(t.Context(), err := applyChanges(t.Context(), db,
"EXPLAIN QUERY PLAN "+signatureRowsSQL) []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)
}
}
})
}
}
// queryPlan returns the detail of each line of SQLite's EXPLAIN QUERY
// PLAN for query.
func queryPlan(t *testing.T, db *sql.DB, query string) []string {
t.Helper()
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -511,12 +568,164 @@ func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
return plan
}
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
// from the files_signature index alone, with nothing to sort: the
// first record comes at once, so report's read phase counts each
// record as it comes rather than waiting on SQLite for all of them.
func TestSignatureRowsNeedNoSort(t *testing.T) {
t.Parallel()
db := openTestDB(t)
plan := queryPlan(t, db, signatureRowsSQL)
want := []string{"SCAN files USING COVERING INDEX files_signature"} want := []string{"SCAN files USING COVERING INDEX files_signature"}
if !slices.Equal(plan, want) { if !slices.Equal(plan, want) {
t.Errorf("query plan = %q, want %q", plan, want) t.Errorf("query plan = %q, want %q", plan, want)
} }
} }
// TestCountsReadOnlyTotals checks that SQLite answers the queries for
// the number of records and their total size from the one row of
// totals. A plan that reads files or its index reads every record.
func TestCountsReadOnlyTotals(t *testing.T) {
t.Parallel()
db := openTestDB(t)
for _, query := range []string{recordCountSQL, databaseCountsSQL} {
plan := queryPlan(t, db, query)
want := []string{"SCAN totals"}
if !slices.Equal(plan, want) {
t.Errorf("query plan of %q = %q, want %q", query, plan, want)
}
}
}
// TestCountsComeFromTotals sets the row of totals to numbers that files
// does not hold, then checks that scan's load phase, the read phases of
// trees and report, and info each report the numbers from totals.
func TestCountsComeFromTotals(t *testing.T) {
t.Parallel()
// files is empty, so a count over files gives 0 records of 0 bytes.
const records, size = 7, 9000
db := openTestDB(t)
_, err := db.ExecContext(t.Context(),
"UPDATE totals SET records = ?, size = ?", records, size)
if err != nil {
t.Fatal(err)
}
scanTotal, err := countRecords(t.Context(), db)
if err != nil {
t.Fatal(err)
}
var treesTotal, reportTotal int64
err = loadFileRows(t.Context(), db,
func(n int64) { treesTotal = n }, func(scanRec) {})
if err != nil {
t.Fatal(err)
}
err = loadSignatureRows(t.Context(), db,
func(n int64) { reportTotal = n },
func(scanRec) error { return nil })
if err != nil {
t.Fatal(err)
}
info, err := readDatabaseCounts(t.Context(), db)
if err != nil {
t.Fatal(err)
}
for name, got := range map[string]int64{
"scan's load": scanTotal,
"trees' read": treesTotal,
"report's read": reportTotal,
"info": info.records,
} {
if got != records {
t.Errorf("%s: %d records, want %d from totals",
name, got, records)
}
}
if info.size != size {
t.Errorf("info: size %d, want %d from totals", info.size, size)
}
}
// assertTotalsMatchFiles fails unless the row of totals holds the
// number of records and the sizes of all records added up, as counted
// over files.
func assertTotalsMatchFiles(t *testing.T, db *sql.DB) {
t.Helper()
got, err := readDatabaseCounts(t.Context(), db)
if err != nil {
t.Fatal(err)
}
var records, size int64
err = db.QueryRowContext(t.Context(),
"SELECT COUNT(*), COALESCE(SUM(size), 0) FROM files").
Scan(&records, &size)
if err != nil {
t.Fatal(err)
}
if got.records != records || got.size != size {
t.Errorf("totals = %d records of %d bytes, files holds %d of %d",
got.records, got.size, records, size)
}
}
// TestTotalsMatchFiles scans the smoke tree, rescans it after two files
// change size, and rescans it after two files are removed. After each
// scan the row of totals must agree with files.
func TestTotalsMatchFiles(t *testing.T) {
t.Parallel()
dir := buildSmokeTree(t)
db := openTestDB(t)
syncTree(t, db, dir)
assertTotalsMatchFiles(t, db)
writeFile(t, dir, "a/one.bin", pattern(10, 2500))
writeFile(t, dir, "t1/f1", pattern(30, 1000))
if st := syncTree(t, db, dir); st.updated != 2 {
t.Fatalf("rescan stats = %+v, want 2 updated", st)
}
assertTotalsMatchFiles(t, db)
for _, name := range []string{"a/unique.bin", "t2/sub/f2"} {
err := os.Remove(filepath.Join(dir, name))
if err != nil {
t.Fatal(err)
}
}
if st := syncTree(t, db, dir); st.removed != 2 {
t.Fatalf("rescan stats = %+v, want 2 removed", st)
}
assertTotalsMatchFiles(t, db)
}
func TestApplyChangesRoundTrip(t *testing.T) { func TestApplyChangesRoundTrip(t *testing.T) {
t.Parallel() t.Parallel()
@@ -527,12 +736,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 +763,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 +791,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 +822,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
+6 -9
View File
@@ -26,11 +26,11 @@ func infoPhase(name string) string {
// it. Any database problem, including a missing database, is fatal. // it. Any database problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and count (count the records, // own on stderr: open (open the database) and count (read the number of
// with one query). Each display ends before the next phase starts, and // records and their total size from the totals table). Each display
// before an error is returned to be printed. The details are written // ends before the next phase starts, and before an error is returned
// once the last display has ended, so no display is drawn while they // to be printed. The details are written once the last display has
// are written. // ended, so no display is drawn while they are written.
func runInfo(ctx context.Context, stdout io.Writer) error { func runInfo(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
@@ -56,7 +56,7 @@ func runInfo(ctx context.Context, stdout io.Writer) error {
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
// One query counts everything, so the phase never has a total. // One row holds both counts, so the phase never has a total.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: infoPhase("count"), total: -1, label: infoPhase("count"), total: -1,
waitingFor: countingRecords, waitingFor: countingRecords,
@@ -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",
+170 -36
View File
@@ -180,9 +180,9 @@ func capture(t *testing.T, std **os.File) func() string {
} }
// brokenDatabase writes a database that opens cleanly and passes the // brokenDatabase writes a database that opens cleanly and passes the
// schema-version check but has no files table, so the first query // schema-version check but has no tables, so the first query fails
// fails with the database already open: a fatal error on a path that // with the database already open: a fatal error on a path that owns an
// owns an open database. It closes the database the way scan does. // open database. It closes the database the way scan does.
func brokenDatabase(t *testing.T) string { func brokenDatabase(t *testing.T) string {
t.Helper() t.Helper()
@@ -265,8 +265,9 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
assertFatalOutput(t, stderr.String(), stdout()) assertFatalOutput(t, stderr.String(), stdout())
// Proof that the failure happened after the open: only a // Proof that the failure happened after the open: only a
// query against the opened database can report this. // query against the opened database can report this. Each
if !strings.Contains(stderr.String(), "no such table: files") { // subcommand's first query reads totals.
if !strings.Contains(stderr.String(), "no such table: totals") {
t.Errorf("stderr = %q, want the failure to come from a "+ t.Errorf("stderr = %q, want the failure to come from a "+
"query on the open database", stderr.String()) "query on the open database", stderr.String())
} }
@@ -393,7 +394,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 +649,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 +687,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 +714,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 +774,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)
@@ -817,6 +818,23 @@ func scanFixture(t *testing.T) []string {
return dupes return dupes
} }
// storedDiskSize returns the disk_size of the record for path in the
// database at dbPath. A test of a trees run compares with this rather
// than with a new lstat, which on ZFS can differ from the scan's (see
// diskSizeOf).
func storedDiskSize(t *testing.T, dbPath, path string) int64 {
t.Helper()
db, err := openReportDatabase(t.Context(), dbPath)
if err != nil {
t.Fatal(err)
}
defer func() { _ = db.Close() }()
return recordByPath(t, dbRecords(t, db), path).diskSize
}
// scanOK runs scan over operands, fails the test unless it exits 0 with // scanOK runs scan over operands, fails the test unless it exits 0 with
// nothing on stdout, and returns everything it printed to stderr. // nothing on stdout, and returns everything it printed to stderr.
func scanOK(t *testing.T, operands ...string) string { func scanOK(t *testing.T, operands ...string) string {
@@ -928,24 +946,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 +989,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)
@@ -1028,8 +1158,9 @@ func TestRunTreesSucceeds(t *testing.T) {
// The two directories holding the duplicate pair are duplicate // The two directories holding the duplicate pair are duplicate
// trees of each other. // trees of each other.
want := "first\tdupe\tfiles\tsize\n" + want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n" filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\t" +
strconv.FormatInt(storedDiskSize(t, path, dupes[1]), 10) + "\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
@@ -1063,9 +1194,10 @@ func TestRunReportsNeedOnlyReadAccess(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
cmdReport: "first\tdupe\tsize\n" + cmdReport: "first\tdupe\tsize\n" +
dupes[0] + "\t" + dupes[1] + "\t300\n", dupes[0] + "\t" + dupes[1] + "\t300\n",
cmdTrees: "first\tdupe\tfiles\tsize\n" + cmdTrees: "first\tdupe\tfiles\tsize\tdisk_size\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) +
"\t1\t300\n", "\t1\t300\t" +
strconv.FormatInt(storedDiskSize(t, path, dupes[1]), 10) + "\n",
} }
for name, want := range cases { for name, want := range cases {
@@ -1100,8 +1232,11 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
t.Errorf("report stdout = %q, want %q", got, want) t.Errorf("report stdout = %q, want %q", got, want)
} }
want = "first\tdupe\tfiles\tsize\n" + diskSize := storedDiskSize(t, filepath.Join(dir, name), dupes[1])
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n"
want = "first\tdupe\tfiles\tsize\tdisk_size\n" +
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\t" +
strconv.FormatInt(diskSize, 10) + "\n"
if got := runStdout(t, cmdTrees); got != want { if got := runStdout(t, cmdTrees); got != want {
t.Errorf("trees stdout = %q, want %q", got, want) t.Errorf("trees stdout = %q, want %q", got, want)
} }
@@ -1122,7 +1257,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
@@ -1167,9 +1302,8 @@ func holdScanLock(t *testing.T, path string) {
} }
func TestRunSecondScanFails(t *testing.T) { func TestRunSecondScanFails(t *testing.T) {
// README §Database: while one scan holds the lock, a second scan // README §Database: a second scan that finds the lock held fails
// fails at once, naming the lock file, without opening the // at once, naming the lock file, without opening the database.
// database.
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
@@ -1228,7 +1362,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 +1467,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))
+104 -92
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,19 +53,14 @@ 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 until it has the number of records, its total.
const countingRecords = "counting the records" const countingRecords = "counting the records"
// openPhase is the name of the phase in which report, trees and info // openPhase is the name of the phase in which report, trees and info
@@ -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()
} }
+266 -115
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)
@@ -200,46 +200,47 @@ 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) { if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", 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
+12 -15
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"fmt" "fmt"
"io" "io"
@@ -20,13 +21,15 @@ const minGroupSize = 2
// scanRec is one file record from the database. The signature (size, // scanRec is one file record from the database. The signature (size,
// head, tail, content) is the duplicate key; mtime is informational // head, tail, content) is the duplicate key; mtime is informational
// only and used by scan for change detection. // only and used by scan for change detection. diskSize is the bytes the
// file takes on disk, which trees adds up.
type scanRec struct { type scanRec struct {
size int64 size int64
diskSize 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
} }
@@ -44,11 +47,11 @@ func reportPhase(name string) string {
// problem, including a missing database, is fatal. // problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and read (count the records, // own on stderr: open (open the database) and read (read the number of
// then read them in signature order, writing each size's duplicate // records from the totals table, then read the records in signature
// groups to stdout once that size is read; see readReport). Each // order, writing each size's duplicate groups to stdout once that size
// display ends before the next phase starts, and before an error is // is read; see readReport). Each display ends before the next phase
// returned to be printed. // starts, and before an error is returned to be printed.
func runReport(ctx context.Context, stdout io.Writer) error { func runReport(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
@@ -169,12 +172,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, diskSize: 4096, head: h, tail: tl, content: c, path: "/d/A/f"},
{size: 5, head: "h", tail: "t", content: "c", path: awkwardDir + "/f"}, {size: 5, diskSize: 4096, 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)
+260 -511
View File
File diff suppressed because it is too large Load Diff
+501 -694
View File
File diff suppressed because it is too large Load Diff
+22 -23
View File
@@ -22,6 +22,10 @@ type treeNode struct {
digest [sha256.Size]byte digest [sha256.Size]byte
fileCount int64 fileCount int64
totalSize int64 totalSize int64
// diskSize is the bytes the directory's files take on disk. Two
// copies of one tree have the same totalSize, but their diskSize
// can differ.
diskSize int64
} }
// treesPhase names one phase of trees with its place among all of them, // treesPhase names one phase of trees with its place among all of them,
@@ -40,11 +44,11 @@ func treesPhase(name string) string {
// database problem, including a missing database, is fatal. // database problem, including a missing database, is fatal.
// //
// The work runs in four phases, each under a progress display of its // The work runs in four phases, each under a progress display of its
// own on stderr: open (open the database), read (count the records, // own on stderr: open (open the database), read (read the number of
// then read them into the hierarchy), group (group the directories by // records from the totals table, then read the records into the
// digest) and write (write the groups to stdout). Each display ends // hierarchy), group (group the directories by digest) and write (write
// before the next phase starts, and before an error is returned to be // the groups to stdout). Each display ends before the next phase
// printed. // starts, and before an error is returned to be printed.
func runTrees(ctx context.Context, stdout io.Writer) error { func runTrees(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath() dbPath, source, err := databasePath()
if err != nil { if err != nil {
@@ -139,15 +143,16 @@ func readTree(ctx context.Context, db *sql.DB,
// writeTreeGroups writes dupes to stdout as TSV, a header line and then // writeTreeGroups writes dupes to stdout as TSV, a header line and then
// a row for every member of each group but its first, counting each // a row for every member of each group but its first, counting each
// group on prog once its rows are written. stdout may be the terminal // group on prog once its rows are written. The disk_size column is the
// prog is drawn on, so the lines go through prog's writer. It returns // dupe's own. stdout may be the terminal prog is drawn on, so the lines
// the number of rows (dupe trees) and the bytes they could reclaim. // go through prog's writer. It returns the number of rows (dupe trees)
// and the bytes they could reclaim.
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode, func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
prog *progress, prog *progress,
) (int, int64, error) { ) (int, int64, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize") _, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size")
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, 0, fmt.Errorf("write stdout: %w", err)
} }
@@ -159,9 +164,9 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
for _, g := range dupes { for _, g := range dupes {
first := g[0] first := g[0]
for _, n := range g[1:] { for _, n := range g[1:] {
_, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\n", _, err = fmt.Fprintf(out, "%s\t%s\t%d\t%d\t%d\n",
escapePath(first.path), escapePath(n.path), escapePath(first.path), escapePath(n.path),
first.fileCount, first.totalSize) first.fileCount, first.totalSize, n.diskSize)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("write stdout: %w", err) return 0, 0, fmt.Errorf("write stdout: %w", err)
} }
@@ -232,6 +237,7 @@ func (b *treeBuilder) add(r scanRec) {
dir.entries = append(dir.entries, fileEntry(name, r)) dir.entries = append(dir.entries, fileEntry(name, r))
dir.fileCount++ dir.fileCount++
dir.totalSize += r.size dir.totalSize += r.size
dir.diskSize += r.diskSize
} }
// openDir starts the directory called name inside the innermost open // openDir starts the directory called name inside the innermost open
@@ -270,6 +276,7 @@ func (b *treeBuilder) closeTo(n int) {
"d\x00"+name+"\x00"+string(dir.digest[:])) "d\x00"+name+"\x00"+string(dir.digest[:]))
dir.parent.fileCount += dir.fileCount dir.parent.fileCount += dir.fileCount
dir.parent.totalSize += dir.totalSize dir.parent.totalSize += dir.totalSize
dir.parent.diskSize += dir.diskSize
b.dirs = append(b.dirs, dir) b.dirs = append(b.dirs, dir)
} }
} }
@@ -286,18 +293,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 +345,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)
+63 -39
View File
@@ -10,32 +10,31 @@ 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. Each file takes one 4 KiB block on disk.
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, diskSize: 4096, head: f1Head, tail: f1Tail,
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"}, content: f1Content, path: "/d/t1/f1"},
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"}, {size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"}, content: f2Content, path: "/d/t1/sub/f2"},
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"}, {size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, content: f1Content, path: "/d/t2/f1"},
path: "/d/t3/sub/f2renamed"}, {size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
content: f2Content, path: "/d/t2/sub/f2"},
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
content: f1Content, path: "/d/t3/f1"},
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
content: f2Content, path: "/d/t3/sub/f2renamed"},
} }
} }
@@ -153,13 +152,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,
}) })
} }
} }
@@ -204,13 +204,33 @@ func TestRunTreesEscapesPaths(t *testing.T) {
code, exitOK, stderr.String()) code, exitOK, stderr.String())
} }
want := "first\tdupe\tfiles\tsize\n" + want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n" `/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\t4096\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
} }
// TestRunTreesDiskSize runs trees on two copies of a tree whose files
// take different space on disk. The row's disk_size is the sum over the
// dupe copy's files, those in its subdirectory included.
func TestRunTreesDiskSize(t *testing.T) {
f, g := testHash("f"), testHash("g")
recs := []scanRec{
{size: 10, diskSize: 4096, head: f, tail: f, content: f, path: "/a/f"},
{size: 20, diskSize: 4096, head: g, tail: g, content: g, path: "/a/sub/g"},
{size: 10, diskSize: 512, head: f, tail: f, content: f, path: "/b/f"},
{size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"},
}
t.Setenv(databaseEnv, seedDatabase(t, recs))
want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
if got := runStdout(t, cmdTrees); got != want {
t.Errorf("stdout = %q, want %q", got, want)
}
}
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test // TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
// records with stderr not a terminal, and checks every line it prints // records with stderr not a terminal, and checks every line it prints
// there. After the line naming the database, each phase prints a line // there. After the line naming the database, each phase prints a line
@@ -232,7 +252,8 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
code, exitOK, stderr()) code, exitOK, stderr())
} }
report := "first\tdupe\tfiles\tsize\n/d/t1\t/d/t2\t2\t3100\n" report := "first\tdupe\tfiles\tsize\tdisk_size\n" +
"/d/t1\t/d/t2\t2\t3100\t8192\n"
if got := stdout.String(); got != report { if got := stdout.String(); got != report {
t.Errorf("stdout = %q, want %q", got, report) t.Errorf("stdout = %q, want %q", got, report)
} }
@@ -242,11 +263,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, ",
} }
@@ -297,7 +318,9 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
shown = append(shown, line[strings.LastIndex(line, "\r")+1:]) shown = append(shown, line[strings.LastIndex(line, "\r")+1:])
} }
want := []string{"first\tdupe\tfiles\tsize", "/d/t1\t/d/t2\t2\t3100"} want := []string{
"first\tdupe\tfiles\tsize\tdisk_size", "/d/t1\t/d/t2\t2\t3100\t8192",
}
if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) { if len(shown) < len(want) || !slices.Equal(shown[:len(want)], want) {
t.Errorf("terminal shows lines %q, want %q first", shown, want) t.Errorf("terminal shows lines %q, want %q first", shown, want)
} }
@@ -332,11 +355,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 +419,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 +441,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)