Compare commits

..
Author SHA1 Message Date
clawbot be47255598 Show the walk's path on the line below its display (closes #160)
check / check (push) Waiting to run
On a terminal, each draw of the walk shows the path of the last file
it found on the line below, cut short before the last column with
wide characters counted as two columns. The progressbar library's
detail row draws it. Control characters in the path are written out,
as \t or \x1b, so that the terminal does not act on them. The line is
erased before a warning and when the walk ends, so the next phase is
drawn on it. Nothing changes when stderr is not a terminal.

github.com/rivo/uniseg, already in the module graph through the
progressbar library, becomes a direct dependency for the column count.

Model: opus-5-5
2026-11-11 08:22:51 +00:00
clawbot 3732ecef1f TODO.md: keep only open work (closes #167) (#180)
check / check (push) Canceled after 0s
2026-11-11 09:07:13 +01:00
clawbot a9ab6124ae dwim: take the scan flags (closes #162) (#193)
check / check (push) Canceled after 0s
2026-11-11 08:37:18 +01:00
clawbot db56fd0e4c Say in --help where the database is and what each report prints (closes #177) (#189)
check / check (push) Canceled after 0s
2026-11-11 07:22:28 +01:00
clawbot 6caaff935a Add scan --exclude PATH (closes #163) (#165)
check / check (push) Canceled after 0s
2026-11-11 06:37:45 +01:00
clawbot f0b679e9fa Leave removed copies out of report and trees (closes #173) (#184)
check / check (push) Canceled after 0s
2026-11-11 06:07:31 +01:00
clawbot a27d651532 scan: drop the stray [flags] from the usage line (closes #169) (#187)
check / check (push) Canceled after 0s
2026-11-11 05:37:23 +01:00
clawbot a1ec1e0ca4 Hash a file again when its mtime moved back, not only forward (closes #176) (#182)
check / check (push) Canceled after 0s
2026-11-11 05:22:54 +01:00
clawbot 6c529fcc6d Leave empty files out of report and trees (closes #178) (#183)
check / check (push) Canceled after 0s
2026-11-11 03:52:45 +01:00
sneak 202f083cc0 Merge branch 'main' into next
check / check (push) Canceled after 0s
2026-11-10 23:11:52 +01:00
clawbot 1017332d68 Move the command to cmd/sfdupes and the code to internal/sfdupes (closes #157)
check / check (push) Canceled after 0s
`go install sneak.berlin/go/sfdupes/cmd/sfdupes@...` found no package: the command was built from `package main` in the repository root. It now lives in `cmd/sfdupes/main.go`, whose one call is `sfdupes.Main()`, and every other Go file moved with `git mv` to `internal/sfdupes`, as `REPO_POLICIES.md` and the Go style guide require. The `Makefile` builds `./cmd/sfdupes` and sets `Version` by its new import path. README "Install" and `TODO.md` give the new paths; the fleet-written "flat layout" divergence is gone.

Rule suppressed: each test file carries `//nolint:testpackage`, since the tests call unexported functions throughout.

Model: opus-5-5
2026-11-10 21:15:53 +01:00
sneak 5c7284c351 Merge pull request 'next into main: the trial's fixes, real-path arguments, and sfdupes dwim' (#137) from next into main
check / check (push) Failing after 17m1s
Reviewed-on: #137
2026-11-10 05:56:03 +01:00
sneak 7cb10ed303 Merge branch 'main' into next
check / check (push) Successful in 13m54s
2026-11-10 05:55:56 +01:00
clawbot da75b713aa Add sfdupes dwim: scan / then print duplicate trees (closes #154)
check / check (push) Successful in 1m18s
sfdupes dwim, as sneak ordered: with no arguments it runs what scan / runs with no flags, default excludes on, then what trees runs, printing the duplicate trees largest first on stdout. Its last line, on stderr, says that sfdupes trees prints the report again. It needs no setup beyond what scan creates. SIGINT or SIGTERM during the scan stops it as it stops scan, exit 1, with no report; during the report a signal ends it at once, as it ends trees. A positional argument is a usage error, exit 2.

Model: opus-5-5
2026-11-06 20:27:24 +01:00
clawbot 24b0fc486d Resolve scan operands to their real paths (closes #144)
check / check (push) Canceled after 0s
scan now resolves each operand to its real path, every symlink followed, before the walk, as sneak ruled. Files are recorded and warnings printed under that path. With /srv/link a symlink to a directory outside /srv, scan /srv /srv/link/sub now walks sub on its own and keeps its records, where before it dropped sub and deleted them. A symlink operand is scanned at its target. A dangling symlink is a fatal error, exit 1. A .. after a symlink counts from the symlink's target, as realpath does. The .zfs rule applies to the real path. The walk still follows no symlink it finds.

Judgement call: records an earlier scan stored under a path through a symlink are not migrated; there is no installed base.

Model: opus-5-5
2026-11-06 19:56:33 +01:00
clawbot f52bbe5d95 Say ZFS counts a new file's blocks up to seconds after the write (closes #152)
check / check (push) Canceled after 0s
`README.md` and a comment in `scan.go` said ZFS counts a new file's blocks only seconds after the write. The count can land in under a second, so both now say up to seconds after the write, as the comment on `diskSizeOf` already does.

Model: opus-5-5
2026-11-03 05:00:51 +01:00
clawbot 30dae35c51 Stop the disk size tests failing at random on ZFS (closes #150)
check / check (push) Canceled after 0s
The three disk size tests in `scan_test.go` failed at random on ZFS, which failed the build on `next`. ZFS counts a write's blocks up to seconds later, sometimes through several sizes, and `unix.Sync` does not wait for that. The rewrite test now waits for the size to change, then rescans until the size is the same on both sides of a rescan, and compares exactly, all within 30 seconds. The other two tests fail only on a `disk_size` outside the reads around their scan.

Limit: those two can pass on a wrong 512 when ZFS has not yet counted the blocks.

Model: opus-5-5
2026-11-03 04:29:19 +01:00
clawbot 4ecf8dc484 Rewrite TODO.md sentence by sentence (closes #135)
check / check (push) Canceled after 0s
Rewrites `TODO.md` from scratch for #135, by the rules applied to `README.md`. Every completed step keeps its date, its issue or branch and its facts, in short sentences, with lists as bullets. An entry superseded later says so. Entries record history, so they were not corrected against the code.

Known stale: the 2026-10-17 release-rule entry.
Changed: one product name in the 2026-10-15 entry is replaced by a description.

Model: fable-5-1 (rewrite); opus-5-5 (review, landing message)
2026-11-02 23:29:22 +01:00
clawbot 2322f8bb02 Rewrite README.md sentence by sentence for a newcomer
check / check (push) Canceled after 0s
Rewrites `README.md` from scratch for #135, so that a newcomer can follow each sentence on one reading. It leads with what the program is and how to use it, then the reference, then development. Where the old text and the code disagreed, it states what the code does; #140 lists each point. Test comments that cited moved text cite the new headings.

Review scope: full reviews through the 16th, then only text changed since.
Dropped: the permission to add `github.com/spf13/viper`.

Model: fable-5-1 (rewrite); opus-5-5 (review, landing message)
2026-11-02 23:12:27 +01:00
clawbot 4ed2e370b1 Record each file's size on disk and print it in trees (closes #147)
check / check (push) Canceled after 0s
The files table gets a disk_size column: the file's allocated blocks,
st_blocks × 512, from the lstat the walk already does. scan writes it
with every record it writes. When a rescan finds a file with its size
and mtime unchanged but a different disk_size, it updates disk_size
alone, without reading the file. trees adds disk_size up per directory
and prints it as a column after size. On a compressed or sparse file it
is less than size, so two copies of one tree can differ.

Judgement calls: each row's disk_size is the dupe copy's, not the
first's. A file whose record gets only a new disk_size counts as
unchanged.

Model: opus-5-5
2026-10-27 19:56:49 +01:00
clawbot 98193b2896 Keep the record count and total size in a totals table (closes #146)
check / check (push) Canceled after 0s
info counted the records and added up their sizes with a query that
reads every record, which takes minutes on a large database. The
schema now has a totals table of one row, kept current by three
triggers on files, in the transaction of each insert, delete and size
update. info, scan's load phase and the read phases of report and
trees read that row instead of counting over files. scan writes files
with the same statements as before. Each of those writes now fires a
trigger and is slower, so scan keeps SQLite's temporary data in
memory, which wins back part of that time.

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

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

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

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

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

The time remaining is labelled remain, not eta.

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

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

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

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

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

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

Model: opus-5-5
2026-10-26 02:02:48 +01:00
28 changed files with 5322 additions and 3763 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ export CGO_ENABLED = 0
BINARY := sfdupes BINARY := sfdupes
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X sneak.berlin/go/sfdupes/internal/sfdupes.Version=$(VERSION)
.PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean .PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean
@@ -14,7 +14,7 @@ LDFLAGS := -X main.Version=$(VERSION)
# Default target: build the binary. Phony so go build (which has its # Default target: build the binary. Phony so go build (which has its
# own build cache) always decides what to recompile. # own build cache) always decides what to recompile.
sfdupes: sfdupes:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/sfdupes
build: sfdupes build: sfdupes
+1234 -970
View File
File diff suppressed because it is too large. Load diff
+20 -515
View File
@@ -1,528 +1,33 @@
# Workflow # Workflow
- take an issue from the `1.0.0` milestone on the tracker; work not yet on the - Take an issue from the `1.0.0` milestone on the tracker. Work that is not on
tracker gets filed as an issue first the tracker yet is filed as an issue first.
- branch from `next` - Branch from `next`.
- do the work, with tests, in small focused commits - Do the work, with tests, in small focused commits.
- record it at the top of Completed Steps (`TODO.md` changes in the same commit - Delete Next Step from `TODO.md`, in the same commit as the work.
as the work) - Push the branch and open a PR against `next`. Its title ends with
- push the branch and open a PR against `next` whose title ends with ` (closes #N)`.
` (closes #N)` - An independent review gates each merge to `next`. Every finding is addressed
- an independent review gates each merge to `next`; every finding is addressed or rebutted on the PR.
or explicitly rebutted on the PR - Only the owner merges `next` into `main`.
- only the owner merges `next` to `main`
# Status # Status
- pre-1.0 - Pre-1.0.
- the Gitea tracker is authoritative for the pre-1.0 backlog: the open issues - The tracker is authoritative for the pre-1.0 backlog. The open issues under
under the `1.0.0` milestone are what remains before the tag, and this file the `1.0.0` milestone are what remains before the tag.
records history and process, not the queue
# Next Step # Next Step
- take the next issue from the `1.0.0` milestone on the tracker: - Take the next issue from the `1.0.0` milestone,
https://git.eeqj.de/sneak/sfdupes/milestone/17 — the milestone is the source https://git.eeqj.de/sneak/sfdupes/milestone/17. The issues are not restated
of truth for what is left before 1.0.0. Individual issues are deliberately not here, because a copy drifts as soon as the tracker moves.
restated here; a copy in this file drifts out of date the moment the tracker
moves
# Completed Steps
- re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from
`sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder`
stages of the `Dockerfile` the module download, and in `builder` the
system-package install, now come before `ARG CACHEBUST`, so Docker keeps them
until the base image, the install line, `go.mod` or `go.sum` changes, and
every step that lints, tests or compiles still comes after it;
`golangci-lint run` also keeps golangci-lint's cache. README says the same
(2026-10-18, https://git.eeqj.de/sneak/sfdupes/issues/109)
- releases until 1.0 are `v0.0.N` tags on `main`'s tip, so
`go install sneak.berlin/go/sfdupes@latest` installs `main`'s tip once it has
passed the build; README "Install" says so, says what `@main` and `v0.0.1`
give, and how to check an installed binary. `go.mod` retracts `v0.0.1`, the
early build without a database. When the linker set no version,
`sfdupes --version` prints the main module's version that Go recorded in the
binary, the tag or the pseudo-version of a `go install` build, and `dev` only
when neither is known (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/116)
- `scan` creates the database's directory where the database path leads: the
path with its last element cut off as text, never cleaned, so a `..` after a
symlinked directory is followed through the symlink, as when the file is
opened. With `SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a
symlink to `/data/x`, it creates `/data/new` and the database in it, where it
used to create `/srv/new` and then fail (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/119)
- `scan` never records its own database: once the database and its `.lock` file
are open, it notes the device and inode of the database file, its `-wal` and
`-shm` files and its `.lock` file, and the walk passes over a file with one of
them wherever the operands reach it, through a symlinked directory included,
printing no line and counting nothing. A record an earlier scan left for one
of them is deleted (2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/115)
- `scan` never records or reads anything that is not on a persistent filesystem
(`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types),
matched by the device ID of each such filesystem, listed as the scan starts
from `/proc/self/mountinfo` on Linux and the mounted-filesystem list on macOS,
however the walk or the content phase reaches it; no flag turns that off.
Below an operand, its walk does not enter the default excludes either, so
`sfdupes scan /` skips the kernel's view of the running system and the
temporary directories: on Linux `/proc`, `/sys`, `/dev`, `/run`, `/tmp` and
`/var/tmp`, on macOS `/dev`, `/private/tmp`, `/private/var/tmp`,
`/private/var/vm`, `/System/Volumes/VM` and `/System/Volumes/Data`, and a
Windows list, not used yet. An operand at or inside a default exclude is still
walked. The scan prints a line for each path it leaves out, without counting
it as skipped, and the records there count as outside the scanned operands, as
for `-x`. `--no-default-excludes` turns off the default excludes only, and
`scan --help` lists them (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/124)
- the rate a progress display shows covers the items completed over the last 5
seconds, or since the line before when stderr is not a terminal, so it falls
to 0 while nothing completes, and a burst after a wait no longer reads as a
steady rate of over 1,000 files a second; the ETA still comes from the phase's
average. Once no item has completed for 10 seconds, the word `stalled` and
where the phase waits take the rate's place, as `stalled disk/db`: `disk`,
`db`, `out` for stdout or `cpu`, or two of them for a phase that may wait on
either, each phase giving its word where it is defined. A line too wide for
the terminal loses its bar first, then its rate or those words, so the phase,
progress, elapsed time and ETA stay whole. On an 80-column terminal, at counts
like those of README's example, the content phase's checks and reads lose
those words at most times in minutes, and every other phase keeps them
whenever the elapsed time and the ETA are both in minutes. The content phase's
last partial batch is now written under a display of its own (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/125)
- new subcommand `info` prints the database's details on stdout, one per line as
a name, a tab and the value: its path and where it came from, the sizes of the
database file and of its `-wal` file when there is one, when the database file
was last changed, the schema version, the number of records, how many have
head and tail hashes and how many a content hash, and the sizes of all records
added up. It opens the database as `report` does, and shows two phases on
stderr, `[1/2] open` and `[2/2] count`; the schema version and the counts come
from one read-only transaction, the counts from one query (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/117)
- `report` shows its work on stderr in two phases, `[1/2] open` and
`[2/2] read`, with the progress display `scan` and `trees` use, from the
moment it starts until the summary line: `open` says it is opening the
database, also while it waits on a busy one; `read` counts the records, then
reads them against that count in signature order, which SQLite takes from the
`files_signature` index without sorting, so the first record comes at once.
`report` forms the duplicate groups itself, holding only those of the size it
is reading, and writes them ordered by first path before it reads the next
size. stdout and the summary line are unchanged, and SQLite no longer needs a
temporary file to sort in (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/114)
- `trees` shows its work on stderr in four phases, `[1/4] open`, `[2/4] read`,
`[3/4] group` and `[4/4] write`, each with the progress display `scan` uses,
from the moment it starts until the summary line: `open` says it is opening
the database, also while it waits on a busy one; `read` counts the records,
then reads them against that count; `group` counts the directories it groups,
and `write` the groups it writes. A display's rate names what its phase
counts: files, records, directories or groups. stdout and the summary line are
unchanged. When stdout and stderr are the same file, as one terminal is, none
of stdout's lines shares a line with the display; when they are not, the
display keeps being drawn while stdout waits on a slow reader (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/113)
- each `scan` phase's progress display names the phase and its place among the
five, as `[2/5] walk`, from the moment it starts, with its progress, elapsed
time and ETA: `load` counts the records first, the walk's total is estimated
from the records under the operands, and the content phase counts the records
it checks from SQLite's grouping alone, then says it is searching the database
until the first one is returned. On a terminal a phase with an exact total
draws its line followed by the library's bar, and any other a spinner followed
by the line; every display is drawn again several times a second. No line
reaches the terminal's last column, however narrow, also after the terminal is
made narrower during a phase: a line too wide leaves out its rate, then its
bar, keeping the phase, progress, elapsed time and ETA whole, and is cut short
only when those alone do not fit, as below about 70 columns late in a large
scan. In plain mode the line is printed every 5 seconds whether or not an item
completed (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/112)
- `scan`, `report` and `trees` first print a line on stderr naming the database
they use, as an absolute path, and whether that path is the default or comes
from `SFDUPES_DATABASE`; `scan` says when the database is new. README
"Database" says when `scan` writes each file (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/111)
- re-vendor the canonical files and model scripts from `sneak/prompts` `next` at
`e3f79c4`: golangci-lint v2.14.0; lint and test are phases of the `Dockerfile`
that write no image, and `make test` runs the suite under the race detector,
so `Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race` are
gone; every `docker build` in `script/` passes a new `CACHEBUST` value, which
every stage of the `Dockerfile` declares first, so every `RUN` step runs on
every build, while the steps that compile keep Go's build cache and the tests
run with `-count=1`; prettier runs on the host, from the node and yarn
`script/bootstrap` installs, so the `prettier` and `markdown` stages are gone
and the build stage installs `git` and `make` itself; a new push cancels the
workflow's older run on the same branch, and a run stops after 20 minutes;
`.claude/settings.json` is deleted (2026-10-15,
https://git.eeqj.de/sneak/sfdupes/issues/95). Since
https://git.eeqj.de/sneak/sfdupes/issues/109 the module download in `lint`,
`test` and the build stage, and the install of `git` and `make` in the build
stage, come before `ARG CACHEBUST`, and Docker keeps them between builds;
every step that lints, tests or compiles still runs on every build
- with `-x`, an operand under another operand is walked on its own instead of
being dropped when a directory from it (from its directory, for a regular
file) up to but not including that operand is on a different filesystem than
that operand, whose walk then cannot reach it, and the records of files gone
from below it are deleted (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/106)
- with `-x`, `scan` keeps the records below a directory on another filesystem
under its operand, and counts their sizes in the size census, instead of
deleting them (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99)
- the hash and content phases open each file without blocking and check that it
is still a regular file, so one replaced by a FIFO or device node after it was
statted is warned about and skipped instead of hanging the scan (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/100)
- the scan lock file is named from the database's path with symlinks resolved,
after `scan` creates the database file if it is missing, so a scan through a
symlink to the database fails while another scan holds the lock (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/101)
- on a terminal, a warning for the last file of a phase no longer erases the
phase's finished bar: the bar is drawn again below the warning (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/102)
- `scan` records mtime to the nanosecond, as whole seconds in `mtime` plus
`mtime_nsec`, and compares it at that resolution, so a same-size rewrite
within the same second is re-hashed (2026-10-07,
https://git.eeqj.de/sneak/sfdupes/issues/12)
- cut the narration from `TODO.md` Completed Steps and from the comments in
`script/`, `Dockerfile` and `Dockerfile.lint` (gone since
https://git.eeqj.de/sneak/sfdupes/issues/95); §Workflow now branches from and
merges to `next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
- `make test-race` ran the test suite under the race detector in a cgo-enabled
container, outside `make check` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/18). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 `make test` itself runs the suite
under the race detector, in the `Dockerfile`'s Debian-based `test` phase, and
`make test-race` is gone
- a bare `docker build .` failed, naming `script/cibuild` and `script/docker`,
rather than serve the gates from cache (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/39). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 a bare build succeeds, as
`REPO_POLICIES.md` requires, and may serve the gates from cache; the builds in
`script/` pass a new `CACHEBUST` value, so theirs always run
- `make fmt` and `make fmt-check` run prettier over all Markdown, and CI checks
it; all Markdown reformatted (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/19)
- `script/lint` writes no image, so a run no longer leaves an untagged one
behind (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/48)
- tests cover a missing database, `scan` keeping stdout empty, its skip warning,
the `report` and `trees` summary lines, and every subcommand going through
`runE` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16)
- `.golangci.yml` replaced with the current canonical copy, which uses
`gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/26)
- a test fails when either `hashWorker` cancellation check in `scan.go` is
removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83)
- a database path holding `?`, `#` or `%` opens exactly the file it names
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55)
- `scan` rejects `--workers` below 1 as a usage error instead of running
single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10)
- a test fails when either walk cancellation check in `scan.go` is removed
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81)
- test that `scan` refuses a database with another schema version (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/64)
- correct four inaccurate comments in `cancel_test.go` and rename
`walkCancelInFlightDirs` to `walkCancelInFlightFiles` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/33)
- test the `-x` filesystem-boundary rules in `subdirJob` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/17)
- `scan` creates the schema in one transaction; a version-0 database with a
`files` table is refused with a clear schema-version error (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/11)
- README documents install, Docker, a daily cron scan and how to read and check
the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54)
- the `Dockerfile` build stage kept the Go module cache out of `builder`'s home
and copied the sources with `--chown`, so no `chown -R` walked them
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 there is no `builder` user and
nothing changes owner: the build stage only compiles, as root, and the tests
run as `nobody` in the `test` phase
- `--version` prints `sfdupes VERSION` to stdout; README documents it and
`--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15)
- `scan` stops cleanly on `SIGINT` or `SIGTERM`: commits what it has hashed,
deletes nothing more, exits 1 (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/5)
- `report` and `trees` stream the records instead of holding them all in memory;
the schema gains the `files_signature` index (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/14)
- progress prints at once on a non-terminal, uses a real terminal test, and
prints warnings through a spinner instead of racing its redraw (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/13)
- warn about and skip symlink, socket, FIFO, device and `.zfs` operands, keeping
the records beneath them (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/9)
- `scan` holds a lock on a lock file beside the database for its whole run, so a
second `scan` fails at once with exit 1 (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/53)
- test stdout write failures in `report` and `trees`; README states that
`| head` ends sfdupes by `SIGPIPE` and `>&-` writes to `/dev/null`
(2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/30)
- `report` and `trees` open the database read-only, and `scan` leaves it out of
WAL mode, so reading needs only read access (2026-10-03, closes
https://git.eeqj.de/sneak/sfdupes/issues/8)
- escape tabs, newlines, carriage returns and backslashes in report, trees and
warning paths; the root directory's path is `/` (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/7)
- stamp the git tag or short commit in a plain `docker build .` instead of `dev`
(2026-10-02, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/67): `.dockerignore` sends `.git`
without `.git/config`; the build stage stamps the `VERSION` build argument,
else `git describe --tags --always`, and fails if the context carries `.git`
and the version is still empty, `dev` or `unknown`. CI checks out the full
history (`fetch-depth: 0`) so it stamps the same value as `make build`.
- replace the 1 KiB end-window sampling with the head/tail plus content-hash
ladder (2026-09-22, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/61); README "Duplicate detection"
documents every rung. A file under 10 MiB is hashed in full, and its `head`,
`tail` and `content` all hold that hash. A larger file gets only its 64 KiB
`head` and `tail` in the hash phase; the content phase, after the update
phase, reads it for `content` (the whole file below 50 MiB, gigabyte-spaced 1
MiB samples at or above) only when its size, `head` and `tail` match another
record's from this scan or an earlier one, and never reads a file gone or
changed since its record was written. `report` and `trees` leave out any
record without a `content` hash. The `content` column is part of the version 1
schema.
- remove the dead `files.dat` references from `Makefile`, `.gitignore` and
`.dockerignore` (2026-09-21, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/22)
- fix the lint-image pin comments and `FROM` form in `Dockerfile` and
`Dockerfile.lint` (2026-08-10, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/25): both pins became the policy
`# image:vX.Y.Z, YYYY-MM-DD` comment over a bare `FROM image@sha256:...`,
without the false `(Debian-based)` note or the tag. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 the `Dockerfile`'s `lint` phase
holds the only golangci-lint pin, in that form, so `Dockerfile.lint` and
`script/verify-lint-image-pin`, which compared the two pins, are gone.
- run all linting in Docker via `Dockerfile.lint` and `script/lint` (2026-08-10,
branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/46): per the
owner ruling the linter is never installed on a host, and
`golangci-lint config verify` runs before `golangci-lint run`.
`script/bootstrap` stopped installing or pinning the linter, and
`script/verify-linter-pin` was retired. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 both commands run in the
`Dockerfile`'s `lint` phase, which `script/lint` builds alone and the build
stage depends on through `COPY --from=lint /src/go.sum /dev/null`;
`Dockerfile.lint` and `script/verify-lint-image-pin` are gone. Nothing inside
an image build may run docker, so the phase calls `golangci-lint` directly.
- install the Docker build stage's prerequisites by running `script/bootstrap`
instead of `apk add --no-cache make` inline (2026-08-09, branch
`dockerfile-bootstrap`, closes https://git.eeqj.de/sneak/sfdupes/issues/42),
with `script/verify-linter-pin` failing the build unless the linter copied
from the lint stage was the version `script/bootstrap` pinned. Builds then
took up to 5m14s cold, mostly in a `chown -R` of the module cache, filed as
https://git.eeqj.de/sneak/sfdupes/issues/43. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 the build stage installs `git` and
`make` with `apk add --no-cache` and only compiles; the `lint` and `test`
phases are the gates
- bust the Docker layer cache for the gate steps, so `script/cibuild` and
`script/docker` cannot report a green they did not earn (2026-08-09, branch
`cibuild-cache-bust`, closes https://git.eeqj.de/sneak/sfdupes/issues/32): the
`Dockerfile` copies the tree before its gates, so on an unchanged tree Docker
served them from cache and the build exited 0 having run nothing. The fix was
a `CHECK_EPOCH` build argument; since
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/`
passes a new `CACHEBUST` value instead, which every stage of the `Dockerfile`
declares before every step that lints, tests or compiles. Run as root, the
tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the
`chmod(0)` the test relies on, so the `test` phase runs them as `nobody`
- check the installed golangci-lint version in `script/bootstrap` instead of
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
`GOLANGCI_LINT_VERSION`, with the `go install` module ref derived from it, and
any installed version that is not the pin — older, newer, absent or
unparseable — is reinstalled. `go install` writes into `GOBIN` (or
`GOPATH/bin`) while `make lint` runs the first `golangci-lint` on `PATH`, so
bootstrap re-reads the effective version after installing and, on a mismatch,
prints both paths and both versions and exits non-zero; it does not reorder
`PATH` or delete anyone's binary. The `--version` call keeps its stderr and is
bounded by `timeout(1)` where that exists. `git`, `make` and `go` keep
presence-only checks. Verified by bootstrapping this host from v2.10.1 to
v2.12.2 and again to a no-op, and by stub runs of the script under `dash`
covering a thirteen-input version-parse matrix, a shadowed install that must
exit non-zero, an install destination not on `PATH`, `GOBIN` set, and a wedged
binary that must hit the timeout; `make check` and `make lint` are clean at
v2.12.2, so v2.10.1 was not hiding any findings on `main`
- unwind the hash worker pool on the error path (2026-08-09, branch
`hash-pool-cleanup`, closes https://git.eeqj.de/sneak/sfdupes/issues/6): the
pool is now an owned, context-aware `hashPool`: every blocking send in the
feeder and the workers selects on `ctx.Done()`, `jobs` is closed on every path
out, and `hashPhase` defers `pool.stop()`, which cancels and then drains
`results` until the last goroutine has exited — draining is what frees a
worker already parked on a send. `ctx` is threaded from `cmd.Context()`
through `runScan`, `syncScan`, both worker pools and the whole database layer,
as the first parameter everywhere. The walk pool gets the same treatment plus
a `ctx.Err()` guard after the walk: a cancelled walk yields a partial size
census, and every file it never reached looks vanished to the update phase.
That phase's own `BeginTx` also fails on the cancelled context before deleting
anything, but the guard is the barrier that still holds once an interrupted
scan may commit what it has. Tests drive `run(scan)` against a database whose
insert trigger aborts and assert that the scan fails instead of hanging and
that `runtime.NumGoroutine()` polls back to its pre-scan baseline; others
cancel a scan part-way through the walk, deterministically, by counting its
own consultations of `ctx.Done()`, and assert that it stops at the guard
holding a partial census and a still-populated record index, with every record
intact. Direct tests of `sendEvent`, the walk workers, `dispatchDirs`,
`feedHashJobs`, `hashWorker` and `hashPhase` cover the remaining cancellation
branches of both pools
- guarantee the database is closed on every fatal exit path (2026-08-09, branch
`db-close-on-fatal`, closes https://git.eeqj.de/sneak/sfdupes/issues/4):
`fatalf` and its `os.Exit(1)` are gone, so the deferred `db.Close()` — and
with it the SQLite WAL checkpoint — now actually runs when a subcommand fails;
`runScan`, `runReport`, `runTrees`, `loadRecords` and `resolveRoots` return
errors instead. The single exit point is `run` in `main.go`: it maps a
`fatalError` (anything a subcommand returned) to exit 1 and cobra's own
argument and flag errors to exit 2, which keeps a runtime failure from being
reported as a usage error or printing the usage text. New `main_test.go`
drives the CLI in-process and asserts the exit codes from README §Error
handling plus the stdout/stderr split, including that a fatal error raised
after the database is open leaves no `-wal`/`-shm` sidecar behind for `scan`,
`report` or `trees`
- update golangci-lint to v2.12.2 with the canonical config (2026-08-09, branch
`golangci-v2.12.2`, merged as `38a01bd`, closes
https://git.eeqj.de/sneak/sfdupes/issues/3): bumped the pinned linter in the
`Dockerfile` lint stage and `script/bootstrap` from v2.12.1 to v2.12.2, and
replaced `.golangci.yml` with the canonical file — the linter settings (`lll`,
`funlen`, `cyclop`, `dupl` thresholds) now live under `linters.settings` per
the v2 schema, so they are actually applied; no new lint findings surfaced
- convert Makefile targets to scripts-to-rule-them-all `script/` entrypoints
like the other managed repos (2026-07-26, commit `3abeacf`, closes
https://git.eeqj.de/sneak/sfdupes/issues/1): all 12 `script/` entrypoints
exist (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`,
`fmt-check`, `check`, `docker`, `cibuild`, `precommit`, `install-precommit`)
and every Makefile target is now a thin shim over them
- make the binary the default Make target (2026-07-24, branch
`make-default-target`): plain `make` now builds `sfdupes` (previously it ran
`check` plus `build`); `make build` remains as an alias
- scan-wide phases, concurrent operands, batched updates (2026-07-24, branch
`scan-wide-phases`): all operands seed the shared walk pool and every pass
runs once over the whole scan, so totals and ETAs are scan-global; the
per-operand walk/hash/update cycles and their stderr announcements are gone;
the update pass commits in batched transactions — the filesystem is
authoritative and the database an eventually-consistent reflection, so
scan-level atomicity is not required
- split the stat pass back out of the walk (2026-07-24, branch
`parallel-phases`): phases are strictly sequential again — walk, stat, hash,
update per operand — with parallelism only inside each phase; the walk
enumerates paths with per-directory workers and the stat pass lstats them with
per-file workers, restoring the exact total/ETA stat bar
- announce each operand on stderr before its passes (2026-07-24, branch
`scan-operand-progress`): with per-operand walk/hash/update cycles, a
multi-operand run (e.g. `scan /srv/*`) showed pass totals that looked like the
whole run's
- parallel walk (2026-07-24, branch `parallel-walk`): the walk pass was a single
goroutine and took hours at ~20M files on a busy pool (observed: 22M files in
4h on a ZFS server); it is now a per-directory worker-pool traversal that
records size/mtime during the walk (folding away the separate stat pass,
halving metadata I/O), and each `PATH` operand commits in its own transaction
so an interrupted scan keeps completed operands
- persistent scan database (2026-07-24, branch `persistent-database`): `scan`
now maintains a SQLite database (`modernc.org/sqlite`, pure Go, cgo stays
disabled) keyed by absolute path that survives between runs — a rescan hashes
only new or changed files (by mtime/size), deletes records for files vanished
from under the scanned operands, and leaves records outside them untouched, so
`scan` can be cronned daily; `report` and `trees` read the database (no
positional arguments) instead of a scan stream. Database at
`/var/lib/sfdupes/db.sqlite`, overridable via `SFDUPES_DATABASE`; WAL
journaling plus a single-transaction update keep a report run during a scan
safe
- add the `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`), tag `v0.0.1`,
and push `main` plus tags (2026-07-23)
- `scan` CLI rework (2026-07-23, branch `scan-required-paths`): required
`PATH...` operands via cobra flags replacing the `/srv` `-root` default; new
`-x`/`--one-file-system` flag (GNU convention) to stop at filesystem
boundaries, which are crossed by default
- bring the repo into full policy compliance (2026-07-23, branch
`repo-policy-compliance`; checklist below)
- `git init` with README-only first commit; code baseline committed on `main`
(2026-07-22)
- implement `scan`, `report`, and `trees` subcommands (pre-git history)
# Future Steps # Future Steps
- possible later features (explicitly out of scope per README): full-content - Possible later features, out of scope per README, are a byte-for-byte check of
verification of candidates, removal-script helpers candidates and helper scripts for removal.
# Repo Policy Compliance # Accepted Divergences
Audited 2026-07-22 against `REPO_POLICIES.md` (2026-07-06), the existing repo None.
checklist, and the Go styleguide. Code is already gofmt-clean, so no standalone
formatting commit is needed.
- [x] `.gitignore` missing — the compiled `sfdupes` binary and `files.dat` sit
untracked in the tree; needs OS/editor/Go artifacts plus secrets patterns
- [x] `.editorconfig` missing
- [x] `LICENSE` missing and README has no License section (MIT assumed from
house convention — user to confirm)
- [x] `REPO_POLICIES.md` missing from repo root
- [x] `.golangci.yml` missing (install canonical copy); code must then pass
`make lint` (150 findings fixed; `make lint` is clean)
- [x] `Makefile` lacks required targets `test`, `lint`, `fmt`, `fmt-check`,
`docker`, `hooks`; `check` currently depends on `build`, which writes the
binary (`make check` must not modify files)
- [x] no tests — `go test ./...` has nothing to run; policy requires real tests
with a 30-second timeout and the conditional `-v` rerun pattern (suite
covers parsing, grouping, digests, suppression, hashing, and the scan
pipeline; 64% coverage)
- [x] `Dockerfile` missing — Go multistage with hash-pinned images: fail-fast
lint stage, build stage running `make check`
- [x] `.dockerignore` missing
- [x] `.gitea/workflows/check.yml` missing (`docker build .` on push, checkout
action pinned by commit SHA)
- [x] README lacks required sections: Description first line
(name/purpose/category/license/author), Getting Started, Rationale, TODO,
License, Author
- [x] README non-goal "no git repository setup and no CI" is stale now that the
repo is under git with CI
- [x] pre-commit hook not installed (`make hooks` once the target exists)
Accepted divergences (no action):
- flat single-package layout with `.go` files in the repo root — fine for a
small single-binary tool per the Go styleguide; the tracker audit agrees
+29
View File
@@ -0,0 +1,29 @@
// Command sfdupes quickly identifies candidate duplicate files across
// very large filesystems without reading every byte of every file.
// Files are considered duplicates when their sizes are equal and they
// agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
// KiB, and of the content. The content hash covers the whole file under
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// file is never read in full. scan maintains a persistent SQLite
// database of file signatures (SFDUPES_DATABASE, default
// /var/lib/sfdupes/db.sqlite) that the other subcommands read.
//
// Usage:
//
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
// sfdupes report > dupes.tsv
// sfdupes trees > dupetrees.tsv
// sfdupes info
// sfdupes dwim
// sfdupes --version
//
// See README.md for the complete specification.
package main
import (
"os"
"sneak.berlin/go/sfdupes/internal/sfdupes"
)
func main() { os.Exit(sfdupes.Main()) }
+1 -1
View File
@@ -3,6 +3,7 @@ module sneak.berlin/go/sfdupes
go 1.25.7 go 1.25.7
require ( require (
github.com/rivo/uniseg v0.4.7
github.com/schollz/progressbar/v3 v3.19.1 github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.46.0 golang.org/x/sys v0.46.0
@@ -18,7 +19,6 @@ require (
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
modernc.org/libc v1.74.1 // indirect modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
@@ -1,7 +1,8 @@
package main package sfdupes //nolint:testpackage // cancels the unexported runScan and hashWorker
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)
} }
} }
@@ -357,8 +358,9 @@ func TestRunScanInterrupted(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
dir := buildSmokeTree(t) dir := buildSmokeTree(t)
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false, opts := scanOptions{workers: walkCancelWorkers}
false)
err := runScan(t.Context(), []string{dir}, opts)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -381,8 +383,7 @@ func TestRunScanInterrupted(t *testing.T) {
go func() { go func() {
defer close(done) defer close(done)
err = runScan(cancelledContext(t), []string{dir}, walkCancelWorkers, err = runScan(cancelledContext(t), []string{dir}, opts)
false, false)
}() }()
awaitReturn(t, done, "runScan") awaitReturn(t, done, "runScan")
@@ -409,8 +410,7 @@ func TestRunScanInterrupted(t *testing.T) {
got, before) got, before)
} }
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false, err = runScan(t.Context(), []string{dir}, opts)
false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -436,7 +436,7 @@ func TestRunScanInterruptedMidHash(t *testing.T) {
dir := buildWalkCancelTree(t) dir := buildWalkCancelTree(t)
err := runScan(newWalkClock(hashCancelAtDone), []string{dir}, err := runScan(newWalkClock(hashCancelAtDone), []string{dir},
walkCancelWorkers, false, false) scanOptions{workers: walkCancelWorkers})
if !errors.Is(err, errInterrupted) { if !errors.Is(err, errInterrupted) {
t.Fatalf("runScan interrupted mid-hash = %v, want %v", t.Fatalf("runScan interrupted mid-hash = %v, want %v",
err, errInterrupted) err, errInterrupted)
@@ -762,7 +762,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 +804,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)
+183 -56
View File
@@ -1,25 +1,6 @@
// Command sfdupes quickly identifies candidate duplicate files across // Package sfdupes implements the sfdupes command and the database its
// very large filesystems without reading every byte of every file. // subcommands share.
// Files are considered duplicates when their sizes are equal and they package sfdupes
// agree on a short ladder of SHA-256 hashes. A file under 10 MiB is
// hashed in full. A larger file is compared on the hashes of its first
// and last 64 KiB, and only when those match another file's is its
// content hash computed and compared: of the whole file when it is
// under 50 MiB, or of gigabyte-spaced 1 MiB samples when it is 50 MiB
// or larger. scan maintains a persistent SQLite database of file
// signatures (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite)
// that the other subcommands read.
//
// Usage:
//
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
// sfdupes report > dupes.tsv
// sfdupes trees > dupetrees.tsv
// sfdupes info
// sfdupes --version
//
// See README.md for the complete specification.
package main
import ( import (
"context" "context"
@@ -29,6 +10,7 @@ import (
"os" "os"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"slices"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -47,6 +29,70 @@ const (
cmdReport = "report" cmdReport = "report"
cmdTrees = "trees" cmdTrees = "trees"
cmdInfo = "info" cmdInfo = "info"
cmdDwim = "dwim"
)
// databaseHelp is the paragraph of the root and scan help that names
// the database (README "Location").
const databaseHelp = "scan and dwim store what they find in a database " +
"file. report, trees\nand info read it. The file is $" + databaseEnv +
" when that is set and\nnot empty, and otherwise " +
defaultDatabasePath + ", where creating it\nneeds root.\n"
// escapesHelp is the paragraph of the report and trees help on how
// paths are escaped (README "Report output format").
const escapesHelp = `Four characters in a path are escaped, so each row stays one line:
\\ a backslash
\t a tab
\n a newline
\r a carriage return
`
// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line
// summary in the help of their subcommands.
const (
reportHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate file. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that file
size the file's size in bytes
Rows are ordered by size, largest first, then by path.
` + escapesHelp
treesHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate directory tree. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that tree
files the number of regular files in one copy
size their total size in bytes
disk_size the bytes the dupe copy takes on disk
Rows are ordered by size, largest first, then by path.
` + escapesHelp
infoHelp = `The details go to stdout, one per line. Each line holds a name and its
value, separated by a tab:
database the path, and where it came from
database file size in bytes, then rounded, as 2147483648 (2.0 GiB)
-wal file size in bytes, then rounded; only if a -wal file exists
modified when the database file last changed, in UTC
schema version the version of the database's layout
records the number of records
size of recorded files their total size, in bytes, then rounded
`
dwimHelp = `dwim runs what "sfdupes scan /" runs with the same flags, then what
"sfdupes trees" runs. Run it as root, so that the scan can read every
file. The scan is stored in the database, as scan stores it, so
"sfdupes trees" prints the report again later.
`
) )
// errNoSubcommand is returned by the root command when it is invoked // errNoSubcommand is returned by the root command when it is invoked
@@ -54,10 +100,14 @@ const (
// cobra prints for it is the whole message. // cobra prints for it is the whole message.
var errNoSubcommand = errors.New("no subcommand") var errNoSubcommand = errors.New("no subcommand")
// errWorkersBelowOne is the usage error for a scan --workers value // errWorkersBelowOne is the usage error for a --workers value below 1.
// below 1.
var errWorkersBelowOne = errors.New("--workers must be at least 1") var errWorkersBelowOne = errors.New("--workers must be at least 1")
// errEmptyExcludeValue is the usage error for an empty --exclude value,
// as from --exclude "$DIR" with DIR unset. Taken as a path, it
// would name the working directory.
var errEmptyExcludeValue = errors.New("--exclude must not be empty")
// Version is the build version, injected at link time via -ldflags // Version is the build version, injected at link time via -ldflags
// (see the Makefile); empty when the linker set none, as for a binary // (see the Makefile); empty when the linker set none, as for a binary
// built by go install. buildVersion turns it into what --version prints. // built by go install. buildVersion turns it into what --version prints.
@@ -86,20 +136,22 @@ func buildVersion(linkerVersion string, info *debug.BuildInfo) string {
return info.Main.Version return info.Main.Version
} }
func main() { // Main runs the command line in os.Args and returns the process exit
// code.
func Main() int {
// Once the reader of a stdout pipe has gone, as in "sfdupes report | // Once the reader of a stdout pipe has gone, as in "sfdupes report |
// head", the Go runtime ends the process with SIGPIPE on the next // head", the Go runtime ends the process with SIGPIPE on the next
// write instead of returning an error (README "Error handling"). // write instead of returning an error (README "Error handling").
// Registering for SIGPIPE with os/signal would change that. // Registering for SIGPIPE with os/signal would change that.
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) return run(os.Args[1:], os.Stdout, os.Stderr)
} }
// run executes args against the command tree and returns the process // run executes args against the command tree and returns the process
// exit code. It is the program's single exit point: the subcommands // exit code. It is the program's single exit point: the subcommands
// return their errors instead of exiting, so every deferred cleanup — // return their errors instead of exiting, so every deferred cleanup —
// above all closing the database, which checkpoints the SQLite WAL — // above all closing the database, which checkpoints the SQLite WAL —
// runs before the process ends. The report, trees and info subcommands // runs before the process ends. The report, trees, info and dwim
// write their data to stdout. // subcommands write their data to stdout.
func run(args []string, stdout, stderr io.Writer) int { func run(args []string, stdout, stderr io.Writer) int {
// A nil slice makes cobra fall back to os.Args, which would let a // A nil slice makes cobra fall back to os.Args, which would let a
// test binary's own flags reach the command tree. // test binary's own flags reach the command tree.
@@ -203,53 +255,116 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
}), }),
} }
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd) root.Long = root.Short + ".\n\n" + databaseHelp
reportCmd.Long = reportCmd.Short + ".\n\n" + reportHelp
treesCmd.Long = treesCmd.Short + ".\n\n" + treesHelp
infoCmd.Long = infoCmd.Short + ".\n\n" + infoHelp
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd,
newDwimCommand(stdout))
return root return root
} }
// newScanCommand builds the scan subcommand, with its --workers, -x and // scanOptions holds the values of the flags that scan and dwim share:
// --no-default-excludes flags. Its help lists the default excludes of // --workers, -x, --no-default-excludes and --exclude.
// the system it runs on, and the filesystem types it never records type scanOptions struct {
// anything on. workers int
oneFS bool
noDefaultExcludes bool
excludePaths []string
}
// scanFlagsUsage names the flags that scan and dwim share, for their
// usage lines.
const scanFlagsUsage = "[--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]..."
// addScanFlags adds the flags that scan and dwim share to cmd, which
// stores their values in opts.
func addScanFlags(cmd *cobra.Command, opts *scanOptions) {
cmd.Flags().IntVar(&opts.workers, "workers", runtime.NumCPU(),
"concurrent workers for the walk and hash phases")
cmd.Flags().BoolVarP(&opts.oneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
cmd.Flags().BoolVar(&opts.noDefaultExcludes, "no-default-excludes",
false, "enter the default excludes, the paths listed above")
// A string array, not a slice, so a comma in a path is not a
// separator.
cmd.Flags().StringArrayVar(&opts.excludePaths, "exclude", nil,
"never enter or record `PATH` or anything below it; repeatable")
}
// check rejects the flag values that are usage errors, before the
// command runs. It is the PreRunE of scan and dwim.
func (o *scanOptions) check(cmd *cobra.Command, _ []string) error {
err := checkScanWorkers(cmd, o.workers)
if err != nil {
return err
}
return rejectEmptyExcludeValue(cmd, o.excludePaths)
}
// newScanCommand builds the scan subcommand. Its help names the
// database, and lists the default excludes of the system it runs on and
// the filesystem types it never records anything on.
func newScanCommand() *cobra.Command { func newScanCommand() *cobra.Command {
var ( var opts scanOptions
scanWorkers int
scanOneFS bool
scanNoDefaultExcludes bool
)
short := "Walk trees and synchronize the scan database" short := "Walk trees and synchronize the scan database"
scanCmd := &cobra.Command{ scanCmd := &cobra.Command{
Use: cmdScan + " [--workers N] [-x] [--no-default-excludes] PATH...", Use: cmdScan + " " + scanFlagsUsage + " PATH...",
Short: short, // The Use line already names every flag, so cobra must not
Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS), // append its generic "[flags]" to it.
Args: cobra.MinimumNArgs(1), DisableFlagsInUseLine: true,
PreRunE: func(cmd *cobra.Command, _ []string) error { Short: short,
return checkScanWorkers(cmd, scanWorkers) Long: short + ".\n\n" + databaseHelp + "\n" +
}, defaultExcludesHelp(runtime.GOOS),
Args: cobra.MinimumNArgs(1),
PreRunE: opts.check,
RunE: runE(func(ctx context.Context, args []string) error { RunE: runE(func(ctx context.Context, args []string) error {
ctx, stop := interruptContext(ctx) ctx, stop := interruptContext(ctx)
defer stop() defer stop()
return runScan(ctx, args, scanWorkers, scanOneFS, return runScan(ctx, args, opts)
scanNoDefaultExcludes)
}), }),
} }
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(), addScanFlags(scanCmd, &opts)
"concurrent workers for the walk, hash, and content phases")
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
false, "enter the default excludes, the paths listed above")
return scanCmd return scanCmd
} }
// checkScanWorkers rejects a scan --workers value below 1. That is a // newDwimCommand builds the dwim subcommand, which takes the scan flags
// usage error reported in one line: cobra prints the returned message // with the meaning they have for scan. Its help says what dwim runs,
// without the usage text, and run exits 2. // then lists the default excludes and the filesystem types the scan
// never records anything on.
func newDwimCommand(stdout io.Writer) *cobra.Command {
var opts scanOptions
short := "Scan /, then print the duplicate-tree report"
dwimCmd := &cobra.Command{
Use: cmdDwim + " " + scanFlagsUsage,
DisableFlagsInUseLine: true,
Short: short,
Long: short + ".\n\n" + dwimHelp + "\n" +
defaultExcludesHelp(runtime.GOOS),
Args: cobra.NoArgs,
PreRunE: opts.check,
RunE: runE(func(ctx context.Context, _ []string) error {
return runDwim(ctx, "/", opts, stdout)
}),
}
addScanFlags(dwimCmd, &opts)
return dwimCmd
}
// checkScanWorkers rejects a --workers value below 1. That is a usage
// error reported in one line: cobra prints the returned message without
// the usage text, and run exits 2.
func checkScanWorkers(cmd *cobra.Command, workers int) error { func checkScanWorkers(cmd *cobra.Command, workers int) error {
if workers >= 1 { if workers >= 1 {
return nil return nil
@@ -260,6 +375,18 @@ func checkScanWorkers(cmd *cobra.Command, workers int) error {
return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers) return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers)
} }
// rejectEmptyExcludeValue rejects an empty --exclude value. That
// is a usage error reported in one line, as for --workers below 1.
func rejectEmptyExcludeValue(cmd *cobra.Command, excludePaths []string) error {
if !slices.Contains(excludePaths, "") {
return nil
}
cmd.SilenceUsage = true
return errEmptyExcludeValue
}
// runE adapts a subcommand implementation, or the version print, to // runE adapts a subcommand implementation, or the version print, to
// cobra's RunE. Cobra prints the error and the command's usage text for // cobra's RunE. Cobra prints the error and the command's usage text for
// every error RunE returns, but a subcommand that ran and failed has no // every error RunE returns, but a subcommand that ran and failed has no
+528 -72
View File
@@ -1,4 +1,4 @@
package main package sfdupes //nolint:testpackage // drives the unexported run
import ( import (
"bytes" "bytes"
@@ -14,12 +14,20 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"golang.org/x/sys/unix"
) )
// usageMarker is the first line of cobra's usage text, which a usage // usageMarker is the first line of cobra's usage text, which a usage
// error prints and a runtime failure must not. // error prints and a runtime failure must not.
const usageMarker = "Usage:" const usageMarker = "Usage:"
// helpFlag asks for the help text.
const helpFlag = "--help"
// workersFlag is the scan and dwim flag that sets the worker count.
const workersFlag = "--workers"
// TestMain stops the tests at once, saying why, when their temporary // TestMain stops the tests at once, saying why, when their temporary
// directory is not on a persistent filesystem: scan records nothing // directory is not on a persistent filesystem: scan records nothing
// there (README "Rules for the walk"), so most scan tests would fail // there (README "Rules for the walk"), so most scan tests would fail
@@ -180,9 +188,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 +273,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 +402,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
@@ -567,7 +576,8 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
}{ }{
cmdScan: { cmdScan: {
run: func() error { run: func() error {
return runScan(t.Context(), []string{missing}, 1, false, false) return runScan(t.Context(), []string{missing},
scanOptions{workers: 1})
}, },
want: scanWant, want: scanWant,
}, },
@@ -619,6 +629,7 @@ func TestRunUsageErrors(t *testing.T) {
"report with args": {[]string{cmdReport, "x"}, usageMarker}, "report with args": {[]string{cmdReport, "x"}, usageMarker},
"trees with args": {[]string{cmdTrees, "x"}, usageMarker}, "trees with args": {[]string{cmdTrees, "x"}, usageMarker},
"info with args": {[]string{cmdInfo, "x"}, usageMarker}, "info with args": {[]string{cmdInfo, "x"}, usageMarker},
"dwim with args": {[]string{cmdDwim, "x"}, usageMarker},
"unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker}, "unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker},
"unknown subcommand": {[]string{"nope"}, "unknown command"}, "unknown subcommand": {[]string{"nope"}, "unknown command"},
} }
@@ -648,7 +659,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) {
@@ -657,7 +668,7 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
args := []string{cmdScan, "--workers", workers, t.TempDir()} args := []string{cmdScan, workersFlag, workers, t.TempDir()}
code := run(args, &stdout, &stderr) code := run(args, &stdout, &stderr)
if code != exitUsage { if code != exitUsage {
@@ -686,9 +697,9 @@ 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{{helpFlag}, {"-h"}, {cmdScan, helpFlag}}
for _, args := range cases { for _, args := range cases {
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -708,13 +719,119 @@ func TestRunHelp(t *testing.T) {
args, got) args, got)
} }
} }
// The scan help lists --exclude with its argument.
var stdout, stderr bytes.Buffer
run([]string{cmdScan, "-h"}, &stdout, &stderr)
if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("scan -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag)
}
}
func TestRunScanUsageLine(t *testing.T) {
t.Parallel()
// README §Subcommands shows this usage line for scan, and the help
// and the usage error both print it.
const want = "\n sfdupes scan [--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]... PATH...\n"
cases := map[string][]string{
"help": {cmdScan, "-h"},
"missing paths": {cmdScan},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
run(args, &stdout, &stderr)
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr = %q, want the usage line %q",
args, stderr.String(), want)
}
})
}
}
func TestRunHelpDescribesDatabaseAndOutput(t *testing.T) {
t.Parallel()
// README §Settings: the help names the database, and says what
// report, trees, info and dwim print.
database := []string{
"scan and dwim store", databaseEnv, defaultDatabasePath, "needs root",
}
escapes := []string{`\\`, `\t`, `\n`, `\r`}
// The subcommand whose help is printed; none for the root.
cases := []struct {
subcommand []string
want []string
}{
{nil, database},
{[]string{cmdScan}, database},
{
[]string{cmdReport},
append([]string{
"tab-separated", "\n first ", "\n dupe ", "\n size ",
"largest first",
}, escapes...),
},
{
[]string{cmdTrees},
append([]string{
"tab-separated", "\n first ", "\n dupe ", "\n files ",
"\n size ", "\n disk_size ", "largest first",
}, escapes...),
},
{
[]string{cmdInfo},
[]string{
"\n database ", "\n database file size ",
"\n -wal file size ", "\n modified ",
"\n schema version ", "\n records ",
"\n size of recorded files ",
},
},
{
[]string{cmdDwim},
[]string{
"sfdupes scan /", "as root", "sfdupes trees", "database",
},
},
}
for _, tc := range cases {
var stdout, stderr bytes.Buffer
args := slices.Concat(tc.subcommand, []string{helpFlag})
code := run(args, &stdout, &stderr)
if code != exitOK {
t.Fatalf("run(%v) = %d, want %d", args, code, exitOK)
}
for _, want := range tc.want {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) help lacks %q:\n%s",
args, want, stderr.String())
}
}
}
} }
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 +890,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)
@@ -796,7 +913,7 @@ func TestRunVersionWriteFailureIsFatal(t *testing.T) {
func scanFixture(t *testing.T) []string { func scanFixture(t *testing.T) []string {
t.Helper() t.Helper()
dir := t.TempDir() dir := realTempDir(t)
dupes := []string{ dupes := []string{
writeFile(t, dir, "one/a.bin", pattern(1, 300)), writeFile(t, dir, "one/a.bin", pattern(1, 300)),
@@ -817,6 +934,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 {
@@ -849,7 +983,7 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := t.TempDir() dir := realTempDir(t)
writeFile(t, dir, "a.bin", pattern(1, 300)) writeFile(t, dir, "a.bin", pattern(1, 300))
// Same size as a.bin, so the scan reads it, and the read fails. // Same size as a.bin, so the scan reads it, and the read fails.
@@ -877,75 +1011,292 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
assertNoSidecars(t, path) assertNoSidecars(t, path)
} }
func TestRunScanSkipsSymlinkOperand(t *testing.T) { // realTempDir returns a new temporary directory by its real path, so
path := testDBPath(t) // tests hold where the temporary directory is reached through a
t.Setenv(databaseEnv, path) // symlink.
func realTempDir(t *testing.T) string {
t.Helper()
dir := t.TempDir() dir, err := filepath.EvalSymlinks(t.TempDir())
writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(filepath.Join(dir, "target"), link)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Scanning a directory through the symlink stores a record beneath return dir
// the symlink's own path for a file beneath its target.
scanOK(t, filepath.Join(link, "sub"))
assertOperandSkipped(t, path, link, "symlink",
filepath.Join(link, "sub", "f"))
} }
// symlink creates link pointing at target.
func symlink(t *testing.T, target, link string) {
t.Helper()
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
}
// TestRunScanFollowsSymlinkOperand scans link, a symlink to target.
// The operand is scanned at its real path, and its file is recorded
// there.
func TestRunScanFollowsSymlinkOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
stderr := scanOK(t, link)
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)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanWalksOperandUnderSymlinkOperand scans link and link/sub,
// with link a symlink to target. Both resolve under target, so
// target/sub is walked once, as part of target.
func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) { func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := t.TempDir() dir := realTempDir(t)
writeFile(t, dir, "target/sub/f", pattern(1, 10)) f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link") link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
err := os.Symlink(filepath.Join(dir, "target"), link) stderr := scanOK(t, link, filepath.Join(link, "sub"))
if err != nil {
t.Fatal(err) 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)
} }
// link is dropped as a symlink, but link/sub must still be scanned, if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
// not dropped as lying under link. t.Errorf("records = %q, want %q", got, []string{f})
scanOK(t, link, filepath.Join(link, "sub"))
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
} }
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(link, "sub", "f"))
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { // TestRunScanWalksSymlinkedOperandBelowOperand is the case of
// https://git.eeqj.de/sneak/sfdupes/issues/144: scan dir dir/link/sub,
// with dir/link a symlink to a directory outside dir. The walk of dir
// does not follow link, so dir/link/sub is walked on its own at its
// real path, and the record an earlier scan stored there survives.
func TestRunScanWalksSymlinkedOperandBelowOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs") dir := realTempDir(t)
snapshot := filepath.Join(zfs, "snapshot", "hourly") a := writeFile(t, dir, "a", pattern(1, 10))
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not outside := realTempDir(t)
// itself named .zfs. f := writeFile(t, outside, "sub/f", pattern(2, 10))
scanOK(t, snapshot)
symlink(t, outside, filepath.Join(dir, "link"))
scanOK(t, filepath.Join(outside, "sub"))
stderr := scanOK(t, dir, filepath.Join(dir, "link", "sub"))
summary := "scan: 2 files seen (1 added, 0 updated, 0 removed, " +
"1 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
want := []string{a, f}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
}
// TestRunScanDropsSymlinkOperandInsideOperand scans dir and link, with
// link, outside dir, a symlink to dir/real. link resolves inside dir,
// so it is dropped as covered by dir, and its file is recorded once.
func TestRunScanDropsSymlinkOperandInsideOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "real/f", pattern(1, 10))
link := filepath.Join(realTempDir(t), "link")
symlink(t, filepath.Join(dir, "real"), link)
stderr := scanOK(t, dir, link)
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)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanDanglingSymlinkOperandIsFatal scans a valid directory and
// a symlink whose target does not exist. The scan fails with exit 1 and
// an error naming the symlink as given, before it walks anything, so no
// database is created.
func TestRunScanDanglingSymlinkOperandIsFatal(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
writeFile(t, dir, "f", pattern(1, 10))
dangling := filepath.Join(realTempDir(t), "dangling")
symlink(t, filepath.Join(dir, "missing"), dangling)
stdout := captureStdout(t)
var stderr bytes.Buffer
code := run([]string{cmdScan, dir, dangling}, os.Stdout, &stderr)
if code != exitFatal {
t.Errorf("run(scan %s %s) = %d, want %d", dir, dangling, code,
exitFatal)
}
assertFatalOutput(t, stderr.String(), stdout())
want := "sfdupes: resolve " + dangling + ": "
if !strings.Contains(stderr.String(), want) {
t.Errorf("stderr = %q, want %q", stderr.String(), want)
}
_, err := os.Stat(path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("database: stat error %v, want it missing", err)
}
}
// TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath runs scan .
// after a cd through home/proj, a symlink to data/proj. The file is
// recorded under data/proj, its real path.
func TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
err := os.Mkdir(filepath.Join(dir, "home"), 0o750)
if err != nil {
t.Fatal(err)
}
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
// t.Chdir also sets PWD to the path through the symlink, as a
// shell does after cd.
t.Chdir(filepath.Join(dir, "home", "proj"))
scanOK(t, ".")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanFollowsSymlinkBeforeDotDot scans a path with a ".." after
// home/proj, a symlink to data/proj: home/proj/.. from dir, and .. after
// a cd through home/proj. As with realpath, the ".." counts from
// data/proj, so data is scanned and home is not.
func TestRunScanFollowsSymlinkBeforeDotDot(t *testing.T) {
for _, tc := range []struct{ wd, operand string }{
{"", "home/proj/.."},
{"home/proj", ".."},
} {
t.Run(tc.operand, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
g := writeFile(t, dir, "data/g", pattern(2, 10))
writeFile(t, dir, "home/h", pattern(3, 10))
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
// t.Chdir also sets PWD to the path given, as a shell does
// after cd.
t.Chdir(filepath.Join(dir, tc.wd))
scanOK(t, tc.operand)
want := []string{f, g}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
})
}
}
// TestRunScanSkipsSymlinkToFIFOOperand scans link, a symlink to a FIFO.
// The scan warns about the FIFO by its real path, counts it as skipped,
// and exits 0.
func TestRunScanSkipsSymlinkToFIFOOperand(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t)
fifo := filepath.Join(dir, "fifo")
err := unix.Mkfifo(fifo, 0o600)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
symlink(t, fifo, link)
stderr := scanOK(t, link)
warning := "walk " + fifo + ": skipping FIFO operand\n"
if !strings.Contains(stderr, warning) {
t.Errorf("stderr = %q, want %q", stderr, warning)
}
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
}
func TestRunScanSkipsZFSOperand(t *testing.T) {
zfs := filepath.Join(realTempDir(t), ".zfs")
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
// No scan records a file inside a .zfs directory, so the record
// beneath the operand is written straight into the database.
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 +1325,107 @@ 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 := realTempDir(t)
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 its real path, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t)
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps")
symlink(t, filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
stderr := scanOK(t, filepath.Join(snaps, "daily"))
daily := filepath.Join(dir, "pool", ".zfs", "snapshot", "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 pool/data. The operand is scanned as usual, and its file
// is recorded by its real path.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data")
symlink(t, filepath.Join(dir, "pool", "data"), data)
stderr := scanOK(t, filepath.Join(data, "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)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{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 +1480,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 +1516,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 +1554,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 +1579,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 +1624,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 +1684,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 +1789,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))
+221 -163
View File
@@ -1,7 +1,8 @@
package main package sfdupes
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,35 +140,50 @@ 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, made absolute by absolutePath, and where the path came from,
// of it when it is relative, and where the path came from, "default" or // "default" or "from SFDUPES_DATABASE", for the line each subcommand
// "from SFDUPES_DATABASE", for the line each subcommand prints first on // prints first on stderr.
// stderr.
func databasePath() (string, string, error) { func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default" path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" { if p := os.Getenv(databaseEnv); p != "" {
path, source = p, "from "+databaseEnv path, source = p, "from "+databaseEnv
} }
if filepath.IsAbs(path) { abs, err := absolutePath(path)
return path, source, nil
}
// The working directory as the kernel reports it, with no symlink
// in it, as README "Database" says. os.Getwd may return PWD
// instead, which can name it through a symlink.
wd, err := unix.Getwd()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err) return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
} }
// Not filepath.Abs or filepath.Join: they clean the path, which return abs, source, nil
// drops each ".." together with the directory name before it. When }
// that directory is a symlink, the cleaned path names another file.
// absolutePath returns path unchanged when it is absolute, and
// otherwise the working directory, a "/" and path. The working
// directory is the one the kernel reports, with no symlink in it;
// os.Getwd may return PWD instead, which can name it through a symlink.
// The result is not cleaned, as filepath.Abs and filepath.Join would
// clean it. Cleaning drops each ".." together with the directory name
// before it. When that name is a symlink, the cleaned path names
// another file than the one the system reaches by following the symlink
// first.
func absolutePath(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
wd, err := unix.Getwd()
if err != nil {
return "", err
}
// The working directory ends in "/" only when it is the root. // The working directory ends in "/" only when it is the root.
return strings.TrimSuffix(wd, "/") + "/" + path, source, nil return strings.TrimSuffix(wd, "/") + "/" + path, nil
} }
// createDatabaseDirectory creates the directory the database at path // createDatabaseDirectory creates the directory the database at path
@@ -144,9 +207,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 +488,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{
if err != nil { createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
return fmt.Errorf("create schema: %w", err) totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
} } {
_, err = tx.ExecContext(ctx, stmt)
_, err = tx.ExecContext(ctx, createIndexSQL) if err != nil {
if err != nil { return fmt.Errorf("create schema: %w", err)
return fmt.Errorf("create schema: %w", err) }
} }
_, err = tx.ExecContext(ctx, _, err = tx.ExecContext(ctx,
@@ -474,7 +542,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 +550,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 +560,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 +601,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 +620,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 +636,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 +666,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 +694,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 +705,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 +732,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 +740,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 +758,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 +787,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 +810,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 +843,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 +873,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 +886,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,
+227 -76
View File
@@ -1,7 +1,8 @@
package main package sfdupes //nolint:testpackage // calls the unexported database openers
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() t.Parallel()
db := openTestDB(t) hexText := strings.Repeat("ab", sha256.Size)
rows, err := db.QueryContext(t.Context(), cases := []struct {
"EXPLAIN QUERY PLAN "+signatureRowsSQL) name string
update string
value any
}{
{"one-byte head", "UPDATE files SET head = ?", []byte{0}},
{"hex-text head", "UPDATE files SET head = ?", hexText},
{"one-byte tail", "UPDATE files SET tail = ?", []byte{0}},
{"hex-text tail", "UPDATE files SET tail = ?", hexText},
{"one-byte content", "UPDATE files SET content = ?", []byte{0}},
{"hex-text content", "UPDATE files SET content = ?", hexText},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
db := openTestDB(t)
err := applyChanges(t.Context(), db,
[]scanRec{{path: "/a/f"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
_, err = db.ExecContext(t.Context(), c.update, c.value)
if err != nil {
t.Fatal(err)
}
loads := map[string]func() error{
"loadFileRows": func() error {
return loadFileRows(t.Context(), db, func(int64) {},
func(scanRec) {})
},
"loadSignatureRows": func() error {
return loadSignatureRows(t.Context(), db, func(int64) {},
func(scanRec) error { return nil })
},
}
for name, load := range loads {
err := load()
if !errors.Is(err, errHashSize) ||
!strings.Contains(err.Error(), "/a/f") {
t.Errorf("%s: err = %v, want %v naming /a/f",
name, err, errHashSize)
}
}
})
}
}
// 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)
}
}
+38
View File
@@ -0,0 +1,38 @@
package sfdupes
import (
"context"
"fmt"
"io"
"os"
)
// runDwim implements the dwim subcommand: it runs what "sfdupes scan
// root" runs with the scan flags in opts, then what "sfdupes trees"
// runs, and ends with a line on stderr saying how to print the report
// again. The dwim command passes "/" as root. It catches SIGINT and
// SIGTERM as scan does, but only during the scan. An error from the
// scan, an interrupt included, ends it before the report. During the
// report, a signal ends it at once, as it ends trees.
func runDwim(
ctx context.Context, root string, opts scanOptions, stdout io.Writer,
) error {
scanCtx, stop := interruptContext(ctx)
err := runScan(scanCtx, []string{root}, opts)
stop()
if err != nil {
return err
}
err = runTrees(ctx, stdout)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr,
`dwim: run "sfdupes trees" to print this report again`)
return nil
}
+210
View File
@@ -0,0 +1,210 @@
package sfdupes //nolint:testpackage // calls the unexported runDwim
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestRunDwimPrintsLargestTreesFirst(t *testing.T) {
// dwim scans its root, then prints the trees report, the larger of
// two duplicate-tree pairs first, and ends by saying how to print
// the report again.
t.Setenv(databaseEnv, testDBPath(t))
root := realTempDir(t)
small := []string{
filepath.Dir(writeFile(t, root, "a1/f.bin", pattern(1, 300))),
filepath.Dir(writeFile(t, root, "a2/f.bin", pattern(1, 300))),
}
large := []string{
filepath.Dir(writeFile(t, root, "b1/f.bin", pattern(2, 5000))),
filepath.Dir(writeFile(t, root, "b2/f.bin", pattern(2, 5000))),
}
var stdout bytes.Buffer
stderr := captureStderr(t)
err := runDwim(t.Context(), root, scanOptions{workers: 1}, &stdout)
if err != nil {
t.Fatalf("runDwim: %v; stderr: %s", err, stderr())
}
lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("stdout = %q, want a header and two groups",
stdout.String())
}
wantFirst := large[0] + "\t" + large[1] + "\t1\t5000\t"
if !strings.HasPrefix(lines[1], wantFirst) {
t.Errorf("first group = %q, want it to start %q", lines[1], wantFirst)
}
wantSecond := small[0] + "\t" + small[1] + "\t1\t300\t"
if !strings.HasPrefix(lines[2], wantSecond) {
t.Errorf("second group = %q, want it to start %q",
lines[2], wantSecond)
}
last := "\ndwim: run \"sfdupes trees\" to print this report again\n"
if got := stderr(); !strings.HasSuffix(got, last) {
t.Errorf("stderr = %q, want it to end %q", got, last)
}
}
func TestRunDwimScanErrorPrintsNoReport(t *testing.T) {
// A fatal scan error ends dwim before the report: a root that does
// not exist fails the scan as it fails scan itself.
t.Setenv(databaseEnv, testDBPath(t))
missing := filepath.Join(realTempDir(t), "missing")
var stdout bytes.Buffer
stderr := captureStderr(t)
err := runDwim(t.Context(), missing, scanOptions{workers: 1}, &stdout)
if err == nil {
t.Fatal("runDwim on a missing root: error = nil, want it fatal")
}
if got := stdout.String(); got != "" {
t.Errorf("stdout = %q, want no report", got)
}
if got := stderr(); strings.Contains(got, "trees:") ||
strings.Contains(got, "dwim:") {
t.Errorf("stderr = %q, want nothing from trees or dwim", got)
}
}
func TestRunDwimExcludeLeavesTreeOut(t *testing.T) {
// dwim passes its --exclude paths to the scan: an excluded copy is
// not recorded, so its pair is not reported.
t.Setenv(databaseEnv, testDBPath(t))
root := realTempDir(t)
kept := []string{
filepath.Dir(writeFile(t, root, "a1/f.bin", pattern(1, 300))),
filepath.Dir(writeFile(t, root, "a2/f.bin", pattern(1, 300))),
}
writeFile(t, root, "b1/f.bin", pattern(2, 5000))
excluded := filepath.Dir(writeFile(t, root, "b2/f.bin", pattern(2, 5000)))
var stdout bytes.Buffer
stderr := captureStderr(t)
opts := scanOptions{workers: 1, excludePaths: []string{excluded}}
err := runDwim(t.Context(), root, opts, &stdout)
if err != nil {
t.Fatalf("runDwim: %v; stderr: %s", err, stderr())
}
lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("stdout = %q, want a header and one group",
stdout.String())
}
want := kept[0] + "\t" + kept[1] + "\t1\t300\t"
if !strings.HasPrefix(lines[1], want) {
t.Errorf("group = %q, want it to start %q", lines[1], want)
}
skipped := "scan: skipped " + excluded + " (--exclude)\n"
if got := stderr(); !strings.Contains(got, skipped) {
t.Errorf("stderr = %q, want it to contain %q", got, skipped)
}
}
func TestRunDwimTakesScanFlags(t *testing.T) {
// The dwim command parses and checks the scan flags as scan does.
// Each case stops before the walk of / begins, and before the
// database is created. "dwim --exclude /" excludes its own operand.
cases := map[string]struct {
args []string
code int
want string
}{
"--exclude /": {
[]string{cmdDwim, excludeFlag, "/"},
exitFatal,
"sfdupes: scan /: operand excluded by --exclude /\n",
},
"--exclude empty": {
[]string{cmdDwim, excludeFlag, ""},
exitUsage,
"Error: --exclude must not be empty\n",
},
"--workers 0": {
[]string{cmdDwim, workersFlag, "0", "-x", "--no-default-excludes"},
exitUsage,
"Error: --workers must be at least 1, got 0\n",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
stdout := captureStdout(t)
stderr := captureStderr(t)
code := run(tc.args, os.Stdout, os.Stderr)
if code != tc.code {
t.Errorf("run(%q) = %d, want %d", tc.args, code, tc.code)
}
if !strings.HasSuffix(stderr(), tc.want) || stdout() != "" {
t.Errorf("stdout, stderr = %q, %q, want \"\" and stderr "+
"ending %q", stdout(), stderr(), tc.want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
func TestRunDwimHelpListsScanFlags(t *testing.T) {
t.Parallel()
// README "Subcommands" shows this usage line for dwim.
const want = "\n sfdupes dwim [--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]...\n"
var stdout, stderr bytes.Buffer
run([]string{cmdDwim, "-h"}, &stdout, &stderr)
if !strings.Contains(stderr.String(), want) {
t.Errorf("dwim -h stderr = %q, want the usage line %q",
stderr.String(), want)
}
if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("dwim -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag)
}
// README "Settings": the help lists the default excludes and the
// filesystem types the walk never records anything on.
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
if !strings.Contains(stderr.String(), help) {
t.Errorf("dwim -h stderr = %q, want it to hold %q", stderr.String(),
help)
}
}
+105 -13
View File
@@ -1,8 +1,9 @@
package main package sfdupes
// This file holds two rules for the walk (README "Rules for the walk"): // This file holds three rules for the walk (README "Rules for the
// walk"): the paths given with --exclude, which the scan never enters;
// the default excludes, the paths a scan's walk does not enter below an // the default excludes, the paths a scan's walk does not enter below an
// operand unless scan is given --no-default-excludes, and the // operand unless scan is given --no-default-excludes; and the
// filesystems that are not persistent, on which the scan never records // filesystems that are not persistent, on which the scan never records
// or reads anything. // or reads anything.
@@ -12,6 +13,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -43,17 +45,25 @@ const typesPerHelpLine = 6
// readMountInfo reads. // readMountInfo reads.
var errMountInfoLine = errors.New("unexpected line") var errMountInfoLine = errors.New("unexpected line")
// errExcludedOperand reports an operand at or below a path given with
// --exclude.
var errExcludedOperand = errors.New("operand excluded by --exclude")
// walkRules holds the rules for the walk that a scan sets up as it // walkRules holds the rules for the walk that a scan sets up as it
// starts. excludes are the default excludes, or none with // starts. excludePaths are the paths given with --exclude, each with
// --no-default-excludes. nonPersistent maps the device ID of every // the device ID and inode number of the file it names (see
// mounted filesystem that is not persistent to its type; the scan // excludePathInodes), which the walk neither enters nor records,
// records and reads nothing on one of those, whatever the flags. // whatever path leads to them. defaultExcludes are the default
// databaseFiles are the scan's own database files, which the walk // excludes, or none with --no-default-excludes. nonPersistent maps the
// passes over (see databaseFiles in db.go). // device ID of every mounted filesystem that is not persistent to its
// type; the scan records and reads nothing on one of those, whatever
// the flags. databaseFiles are the scan's own database files, which the
// walk passes over (see databaseFiles in db.go).
type walkRules struct { type walkRules struct {
excludes []string excludePaths []fileRec
nonPersistent map[uint64]string defaultExcludes []string
databaseFiles []fileRec nonPersistent map[uint64]string
databaseFiles []fileRec
} }
// defaultExcludes returns the default excludes of the system goos // defaultExcludes returns the default excludes of the system goos
@@ -97,6 +107,88 @@ func isDefaultExclude(path string, excludes []string) bool {
return false return false
} }
// excludePathInodes returns each of paths, the --exclude values, as its
// real path (see realPaths) with the device ID and inode number of the
// file there. The walk compares what it reaches with those, as it does
// with the database files, so it also leaves out an --exclude path
// spelled another way, such as in another letter case on macOS.
func excludePathInodes(paths []string) ([]fileRec, error) {
resolved, err := realPaths(paths)
if err != nil {
return nil, err
}
files := make([]fileRec, 0, len(resolved))
for _, path := range resolved {
fi, err := os.Lstat(path)
if err != nil {
return nil, err
}
dev, ino := inodeOfInfo(fi)
files = append(files, fileRec{path: path, dev: dev, ino: ino})
}
return files, nil
}
// matchingExcludePath returns the path of the one of excludePaths with
// the device ID and inode number of fi, an lstat result, or "" when
// there is none.
func matchingExcludePath(fi fs.FileInfo, excludePaths []fileRec) string {
dev, ino := inodeOfInfo(fi)
f := fileRec{dev: dev, ino: ino}
for _, exclude := range excludePaths {
if sameInode(exclude, f) {
return exclude.path
}
}
return ""
}
// checkOperandsNotExcluded returns an error when one of roots, the
// operands, or a directory above it is one of excludePaths, the
// --exclude paths, by device ID and inode number. The error names the
// operand and the --exclude path. The roots are absolute, so the
// directories above each one end at /. A path here that lstat cannot
// read is an error too.
func checkOperandsNotExcluded(roots []string, excludePaths []fileRec) error {
for _, root := range roots {
for path := root; ; path = filepath.Dir(path) {
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("scan %s: %w", root, err)
}
exclude := matchingExcludePath(fi, excludePaths)
if exclude != "" {
return fmt.Errorf("scan %s: %w %s", root, errExcludedOperand,
exclude)
}
if path == "/" {
break
}
}
}
return nil
}
// excludePathLine returns the line the scan prints for path when fi,
// its lstat, makes it one of rules.excludePaths, and "" when it does
// not.
func (rules walkRules) excludePathLine(path string, fi fs.FileInfo) string {
if matchingExcludePath(fi, rules.excludePaths) == "" {
return ""
}
return fmt.Sprintf("scan: skipped %s (--exclude)", path)
}
// nonPersistentFilesystemTypes returns the filesystem types that are // nonPersistentFilesystemTypes returns the filesystem types that are
// not persistent: filesystems that the kernel makes up or that are kept // not persistent: filesystems that the kernel makes up or that are kept
// in memory. devfs is the macOS filesystem of /dev; the others are // in memory. devfs is the macOS filesystem of /dev; the others are
@@ -162,7 +254,7 @@ func readMountInfo(mountinfo io.Reader) (map[uint64]string, error) {
return devices, lines.Err() return devices, lines.Err()
} }
// defaultExcludesHelp describes, for the scan command's help, the // defaultExcludesHelp describes, for the scan and dwim help, the
// default excludes of the system goos names and, where the scan lists // default excludes of the system goos names and, where the scan lists
// the mounted filesystems, the types it never records anything on. // the mounted filesystems, the types it never records anything on.
func defaultExcludesHelp(goos string) string { func defaultExcludesHelp(goos string) string {
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package main package sfdupes
import ( import (
"slices" "slices"
@@ -1,6 +1,6 @@
//go:build !darwin //go:build !darwin
package main package sfdupes
import ( import (
"fmt" "fmt"
@@ -1,4 +1,4 @@
package main package sfdupes //nolint:testpackage // checks the unexported defaultExcludes
import ( import (
"bytes" "bytes"
@@ -14,10 +14,15 @@ import (
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
) )
// excludeFlag is the scan flag that names a path the walk never enters.
const excludeFlag = "--exclude"
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 +169,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,12 +190,8 @@ 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{defaultExcludes: []string{excluded}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -203,11 +203,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)
} }
@@ -242,7 +237,7 @@ func TestScanWalksOperandInDefaultExclude(t *testing.T) {
db := openTestDB(t) db := openTestDB(t)
st, err := syncScan(t.Context(), db, roots, 1, false, st, err := syncScan(t.Context(), db, roots, 1, false,
walkRules{excludes: []string{excluded}}) walkRules{defaultExcludes: []string{excluded}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -270,7 +265,7 @@ func TestScanNamesNoDefaultExcludeThatIsAnOperand(t *testing.T) {
f := writeFile(t, excluded, "f", pattern(1, 10)) f := writeFile(t, excluded, "f", pattern(1, 10))
st, err := syncScan(t.Context(), db, []string{dir, excluded}, 1, false, st, err := syncScan(t.Context(), db, []string{dir, excluded}, 1, false,
walkRules{excludes: []string{excluded}}) walkRules{defaultExcludes: []string{excluded}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -294,7 +289,7 @@ func TestRunScanTempDirOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := t.TempDir() dir := realTempDir(t)
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) { if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
t.Skip(dir + " is not inside a default exclude") t.Skip(dir + " is not inside a default exclude")
} }
@@ -364,6 +359,217 @@ func TestRunScanNoDefaultExcludes(t *testing.T) {
assertRecorded(t, path, f, true) assertRecorded(t, path, f, true)
} }
// TestRunScanExcludeNested scans a tree through the command with an
// --exclude path two levels below the operand. The walk records nothing
// at or below it and says so. The record an earlier scan left there is
// kept although its file is gone.
func TestRunScanExcludeNested(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
excluded := filepath.Join(dir, "a", "b")
kept := writeFile(t, dir, "a/f", pattern(1, 10))
gone := writeFile(t, excluded, "gone", pattern(2, 10))
scanOK(t, dir)
err := os.Remove(gone)
if err != nil {
t.Fatal(err)
}
unseen := writeFile(t, excluded, "c/new", pattern(3, 10))
stderr := scanOK(t, excludeFlag, excluded, dir)
line := "scan: skipped " + excluded + " (--exclude)\n"
if !strings.Contains(stderr, line) {
t.Errorf("stderr = %q, want %q", stderr, line)
}
assertRecorded(t, path, kept, true)
assertRecorded(t, path, gone, true)
assertRecorded(t, path, unseen, false)
}
// TestRunScanExcludeRepeated gives --exclude twice, once for a
// directory and once for a file, the file through a symlink to it. The
// walk records neither, and records the file beside them.
func TestRunScanExcludeRepeated(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
inDir := writeFile(t, dir, "skip/f", pattern(1, 10))
file := writeFile(t, dir, "skip-file", pattern(2, 10))
kept := writeFile(t, dir, "keep", pattern(3, 10))
link := filepath.Join(t.TempDir(), "link")
symlink(t, file, link)
scanOK(t, excludeFlag, filepath.Dir(inDir), excludeFlag, link, dir)
assertRecorded(t, path, inDir, false)
assertRecorded(t, path, file, false)
assertRecorded(t, path, kept, true)
}
// TestRunScanExcludeReachedByAnotherPath gives --exclude a directory
// through a symlink and a file through "..", so the walk reaches
// neither by the path given. The walk also reaches the file through a
// hard link. It records none of these, prints a line naming each, and
// records the file beside them.
func TestRunScanExcludeReachedByAnotherPath(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
inDir := writeFile(t, dir, "a/cache/f", pattern(1, 10))
file := writeFile(t, dir, "a/big", pattern(2, 10))
kept := writeFile(t, dir, "keep", pattern(3, 10))
hardLink := filepath.Join(dir, "big-link")
err := os.Link(file, hardLink)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(t.TempDir(), "link")
symlink(t, filepath.Join(dir, "a"), link)
// Joined as text, since filepath.Join would drop the "..".
dotDot := dir + "/a/cache/../big"
stderr := scanOK(t, excludeFlag, filepath.Join(link, "cache"),
excludeFlag, dotDot, dir)
for _, skipped := range []string{filepath.Dir(inDir), file, hardLink} {
line := "scan: skipped " + skipped + " (--exclude)\n"
if !strings.Contains(stderr, line) {
t.Errorf("stderr = %q, want %q", stderr, line)
}
}
assertRecorded(t, path, inDir, false)
assertRecorded(t, path, file, false)
assertRecorded(t, path, hardLink, false)
assertRecorded(t, path, kept, true)
}
// TestRunScanExcludedOperand checks that an operand at or below an
// --exclude path is a fatal error that names both, and so is an
// --exclude path that does not exist. An operand that is a hard link to
// an --exclude file is at it. Neither scan writes a database.
func TestRunScanExcludedOperand(t *testing.T) {
dir := realTempDir(t)
sub := filepath.Join(dir, "sub")
f := writeFile(t, sub, "f", pattern(1, 10))
hardLink := filepath.Join(dir, "f-link")
err := os.Link(f, hardLink)
if err != nil {
t.Fatal(err)
}
cases := map[string]struct {
args []string
want string
}{
"at it": {
[]string{excludeFlag, sub, sub},
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
},
"below it": {
[]string{excludeFlag, dir, sub},
"scan " + sub + ": operand excluded by --exclude " + dir + "\n",
},
"after an operand it does not exclude": {
[]string{dir, excludeFlag, sub, sub},
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
},
"a hard link to it": {
[]string{excludeFlag, f, hardLink},
"scan " + hardLink + ": operand excluded by --exclude " + f + "\n",
},
"exclude missing": {
[]string{excludeFlag, filepath.Join(dir, "nope"), dir},
"--exclude: resolve " + filepath.Join(dir, "nope") + ": ",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
stdout := captureStdout(t)
stderr := captureStderr(t)
code := run(append([]string{cmdScan}, tc.args...), os.Stdout,
os.Stderr)
if code != exitFatal {
t.Errorf("run(scan %q) = %d, want %d", tc.args, code,
exitFatal)
}
assertFatalOutput(t, stderr(), stdout())
if !strings.Contains(stderr(), "sfdupes: "+tc.want) {
t.Errorf("stderr = %q, want %q", stderr(), tc.want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
// TestRunScanRejectsEmptyExclude checks that an empty --exclude value,
// given as its own argument, after "=" or after another --exclude, is
// a usage error reported in one line before anything is scanned
// (README "Settings").
func TestRunScanRejectsEmptyExclude(t *testing.T) {
dir := t.TempDir()
cases := map[string][]string{
"separate": {excludeFlag, "", dir},
"joined by =": {excludeFlag + "=", dir},
"after a path": {excludeFlag, t.TempDir(), excludeFlag, "", dir},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
var stdout, stderr bytes.Buffer
code := run(append([]string{cmdScan}, args...), &stdout,
&stderr)
if code != exitUsage {
t.Errorf("run(scan %q) = %d, want %d", args, code,
exitUsage)
}
want := "Error: --exclude must not be empty\n"
if stdout.String() != "" || stderr.String() != want {
t.Errorf("stdout, stderr = %q, %q, want \"\", %q",
stdout.String(), stderr.String(), want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
// TestRunScanSkipsNonPersistentOperand scans, through the command, a // TestRunScanSkipsNonPersistentOperand scans, through the command, a
// directory on a filesystem that is not persistent, with and without // directory on a filesystem that is not persistent, with and without
// --no-default-excludes: either way it is skipped, with a line saying // --no-default-excludes: either way it is skipped, with a line saying
@@ -391,9 +597,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 +618,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 +630,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 +641,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
+7 -10
View File
@@ -1,4 +1,4 @@
package main package sfdupes
import ( import (
"context" "context"
@@ -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))
+12 -14
View File
@@ -1,4 +1,4 @@
package main package sfdupes //nolint:testpackage // seeds the database with unexported scanRec
import ( import (
"fmt" "fmt"
@@ -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",
}, },
} }
+211 -101
View File
@@ -1,4 +1,4 @@
package main package sfdupes
import ( import (
"fmt" "fmt"
@@ -9,7 +9,10 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf8"
"github.com/rivo/uniseg"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
"golang.org/x/term" "golang.org/x/term"
) )
@@ -34,10 +37,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 +55,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 +90,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 +114,14 @@ 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. Each draw
// emits no ANSI redraws: it prints line as the phase starts, every // of the walk shows the path it reached last on the line below. When
// plainInterval while it runs, and as it ends. // stderr is not a TTY it emits no ANSI redraws: it prints line as the
// phase starts, every plainInterval while it runs, and as it ends.
// //
// The draws come from a goroutine of their own, so mu guards the fields // 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 +137,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
@@ -139,9 +144,10 @@ type progress struct {
estimate bool estimate bool
waitingFor string waitingFor string
stalledOn string stalledOn string
path string // the path the walk reached last; "" for none
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 +221,110 @@ 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)
if p.path != "" {
// The library draws the path on the line below and returns to
// the phase's line. The path is escaped as warnings are, and its
// other control characters are written out, so that the terminal
// shows them instead of acting on them. It stays clear of the
// last column, so that no terminal wraps it.
shown := escapeControlChars(escapePath(p.path))
_ = p.bar.AddDetail(cutToColumns(shown, width-1))
}
} }
// newBar has the library make what draw draws: a bar for the phase's // escapeControlChars returns s with each control character written as
// total when withBar is set, and a spinner when not. Either shows the // \x and its code in two hex digits, as \x1b for the escape character.
// text draw gives it, and nothing of its own besides the spinner, or // The control characters are U+0000 to U+001F, U+007F and U+0080 to
// the bar and its percent. p.mu must be held. // U+009F, as unicode.IsControl reports them. A byte from 0x80 to 0x9F
func (p *progress) newBar(withBar bool, width int) { // that is not part of a valid UTF-8 character is written the same way,
// as \x9b, since a terminal not in UTF-8 mode acts on it. Every other
// byte is kept as it is.
func escapeControlChars(s string) string {
var b strings.Builder
for rest := s; rest != ""; {
char, size := utf8.DecodeRuneInString(rest)
if char == utf8.RuneError && size == 1 {
// Not part of a valid UTF-8 character. A terminal not in
// UTF-8 mode reads the byte as the character with its code,
// so that 0x9b is a control character there and 0xe9 is not.
char = rune(rest[0])
}
if unicode.IsControl(char) {
fmt.Fprintf(&b, `\x%02x`, char)
} else {
b.WriteString(rest[:size])
}
rest = rest[size:]
}
return b.String()
}
// cutToColumns returns s cut to at most columns terminal columns, at a
// character boundary. A wide character, as in Chinese, takes two
// columns.
func cutToColumns(s string, columns int) string {
used, end, state := 0, 0, -1
for rest := s; rest != ""; {
char, next, width, nextState := uniseg.FirstGraphemeClusterInString(
rest, state)
if used+width > columns {
break
}
used += width
end += len(char)
rest, state = next, nextState
}
return s[:end]
}
// clear erases the display from the terminal: its line, and the path on
// the line below when one is shown. p.mu must be held.
func (p *progress) clear() {
if p.bar == nil {
return
}
if p.path != "" {
_ = p.bar.AddDetail("")
}
_ = p.bar.Clear()
}
// newBar has the library make what draw draws: its spinner, followed by
// the text draw gives it and nothing of the library's own. p.mu must be
// held.
func (p *progress) newBar(width int) {
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 +333,38 @@ 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),
// The line below the phase's line, for the walk's path.
progressbar.OptionSetMaxDetailRow(1),
) )
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
@@ -326,6 +396,19 @@ func (p *progress) increment() {
p.lastItem = now p.lastItem = now
} }
// setPath records the path the walk reached last, which a display on a
// terminal shows at its next draw.
func (p *progress) setPath(path string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.path = path
}
// warnf prints a one-line warning to stderr without corrupting the // warnf prints a one-line warning to stderr without corrupting the
// display. The whole message is escaped like a report's path columns, // display. The whole message is escaped like a report's path columns,
// so a path holding a newline cannot split the warning. // so a path holding a newline cannot split the warning.
@@ -339,11 +422,9 @@ func (p *progress) warnf(format string, args ...any) {
p.mu.Lock() p.mu.Lock()
defer p.mu.Unlock() defer p.mu.Unlock()
if p.bar != nil { // The warning takes the display's line; the next draw shows the
// The warning takes the display's line; the next draw shows // display again, on the line below.
// the display again, on the line below. p.clear()
_ = p.bar.Clear()
}
fmt.Fprintln(os.Stderr, msg) fmt.Fprintln(os.Stderr, msg)
} }
@@ -404,9 +485,7 @@ func (w *progressWriter) Flush() error {
w.p.mu.Lock() w.p.mu.Lock()
defer w.p.mu.Unlock() defer w.p.mu.Unlock()
if w.p.bar != nil { w.p.clear()
_ = w.p.bar.Clear()
}
} }
_, err := w.out.Write(w.buf) _, err := w.out.Write(w.buf)
@@ -426,6 +505,13 @@ func (p *progress) finish() {
close(p.stop) close(p.stop)
<-p.done <-p.done
// The walk's path is only for someone watching the walk. Its line is
// erased, and the next phase is drawn on it.
p.mu.Lock()
p.clear()
p.path = ""
p.mu.Unlock()
p.draw() p.draw()
if p.width != nil { if p.width != nil {
@@ -438,22 +524,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 +552,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 +636,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 +649,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()
} }
@@ -1,4 +1,4 @@
package main package sfdupes //nolint:testpackage // drives the unexported startProgress
import ( import (
"fmt" "fmt"
@@ -6,6 +6,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"sync" "sync"
@@ -23,7 +24,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 +118,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 +157,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 +185,9 @@ func TestLineFitsRoom(t *testing.T) {
start: ago, rateStart: ago, lastItem: now, start: ago, rateStart: ago, lastItem: now,
drawn: []drawnCount{{at: ago}}, drawn: []drawnCount{{at: ago}},
} }
content := &progress{ load := &progress{
label: scanPhase("content"), total: -1, label: scanPhase("load"), total: -1,
waitingFor: "searching the database", start: ago, rateStart: ago, waitingFor: countingRecords, start: ago, rateStart: ago,
} }
started := now.Add(-50 * time.Second) started := now.Add(-50 * time.Second)
@@ -198,48 +199,49 @@ func TestLineFitsRoom(t *testing.T) {
} }
cases := []struct { cases := []struct {
p *progress p *progress
room int room int
percent bool bar bool
want string want string
}{{ }{{
p: hash, room: 57, percent: true, p: hash, room: 60,
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
}, { }, {
p: hash, room: 56, percent: true, p: hash, room: 59,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
}, { }, {
p: hash, room: 45, percent: true, p: hash, room: 48,
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40", want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
}, { }, {
// Before the library's bar, which shows the percent. // The bar shows the percent.
p: hash, room: 57, percent: false, p: hash, room: 74, bar: true,
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s", want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" +
" 20% |██ |",
}, { }, {
p: content, room: 40, percent: true, p: load, room: 43,
want: "[5/5] content: elapsed 10s, eta unknown", want: "[1/4] load: elapsed 10s, remain unknown",
}, { }, {
p: stalled, room: 65, percent: true, p: stalled, room: 68,
want: "[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " + want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"eta 3m20s", "remain 3m20s",
}, { }, {
p: stalled, room: 64, percent: true, p: stalled, room: 67,
want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s", want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
}} }}
for _, tc := range cases { for _, tc := range cases {
if got := tc.p.line(now, tc.room, tc.percent); got != tc.want { if got := tc.p.line(now, tc.room, tc.bar); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q", t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got,
tc.room, tc.percent, got, tc.want) tc.want)
} }
} }
} }
// TestSetTotalStartsRate checks that setTotal starts a phase's rate, // TestSetTotalStartsRate checks that setTotal starts a phase's rate,
// its ETA and its time with no record read from when the total becomes // its time remaining and its time with no record read from when the
// known, not from when the phase started waiting for it: after 20 // total becomes known, not from when the phase started waiting for it:
// seconds of waiting for the total, the phase has not stalled 5 seconds // after 20 seconds of waiting for the total, the phase has not stalled 5
// after it, and has 10 seconds after it with no record read. // seconds after it, and has 10 seconds after it with no record read.
func TestSetTotalStartsRate(t *testing.T) { func TestSetTotalStartsRate(t *testing.T) {
t.Parallel() t.Parallel()
@@ -255,15 +257,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 +274,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 +318,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 +338,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 +368,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 +412,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 +428,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 +459,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 +482,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 +508,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 +555,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 +573,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 +597,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,13 +642,10 @@ 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/disk"},
{label: treesPhase("write"), stalledOn: "out"}, {label: treesPhase("write"), stalledOn: "out"},
{label: reportPhase("read"), stalledOn: "db/out"}, {label: reportPhase("read"), stalledOn: "db/disk/out"},
} }
for _, params := range phases { for _, params := range phases {
@@ -658,19 +654,22 @@ func TestTerminalStalledLines(t *testing.T) {
approx = "~" approx = "~"
} }
shown := drawStalled(params, 45678, 12345, // The percent is in the line, or beside the bar.
shown := drawStalled(params, 100, 45678, 12345,
4*time.Minute+30*time.Second) 4*time.Minute+30*time.Second)
want := params.label + ": [12345/" + approx + "45678] 27%, stalled " + for _, want := range []string{
params.stalledOn + ", elapsed 4m30s, eta 12m9s" params.label + ": [12345/" + approx + "45678]",
", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s",
if !strings.Contains(shown, want) { } {
t.Errorf("terminal shows %q, want %q", shown, want) if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want)
}
} }
shown = drawStalled(params, 9876543, 1234567, 150*time.Minute) shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute)
for _, want := range []string{ for _, want := range []string{
params.label + ": [1234567/" + approx + "9876543] 12%, ", params.label + ": [1234567/" + approx + "9876543] 12%, ",
", elapsed 2h30m0s, eta 17h30m0s", ", elapsed 2h30m0s, remain 17h30m0s",
} { } {
if !strings.Contains(shown, want) { if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want) t.Errorf("terminal shows %q, want %q", shown, want)
@@ -693,7 +692,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 +714,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 +889,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 +929,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 +978,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 +1000,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
@@ -987,3 +1139,164 @@ func TestWarningAfterLastItemKeepsBar(t *testing.T) {
t.Errorf("terminal shows %q, want a count of 2 of 2", shown) t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
} }
} }
func TestCutToColumns(t *testing.T) {
t.Parallel()
cases := []struct {
s string
columns int
want string
}{
{"/srv/a", 10, "/srv/a"},
{"/srv/photos", 8, "/srv/pho"},
// Each of these two fullwidth letters takes two columns.
{"/AB/a", 4, "/A"},
{"/AB/a", 5, "/AB"},
// An accent that combines with the letter before stays with it.
{"/café/a", 5, "/café"},
{"/srv", 0, ""},
}
for _, tc := range cases {
if got := cutToColumns(tc.s, tc.columns); got != tc.want {
t.Errorf("cutToColumns(%q, %d) = %q, want %q",
tc.s, tc.columns, got, tc.want)
}
}
}
func TestEscapeControlChars(t *testing.T) {
t.Parallel()
cases := map[string]string{
"/srv/music": "/srv/music",
"/a\x1b[2Jb": `/a\x1b[2Jb`,
"/a\x00\a\x7fb": `/a\x00\x07\x7fb`,
// U+009B takes two bytes in UTF-8.
"/a\u009b2Jb": `/a\x9b2Jb`,
// Byte 0x9b alone is not valid UTF-8. A terminal not in UTF-8
// mode acts on it, as on every byte from 0x80 to 0x9f.
"/a\x9b2Jb": `/a\x9b2Jb`,
"/a\x80\x9f\xa0b": `/a\x80\x9f` + "\xa0b",
// In ě, which is c4 9b in UTF-8, byte 0x9b is part of a valid
// character.
"/aě2Jb": "/aě2Jb",
"/café/AB": "/café/AB",
"/latin1-caf\xe9": "/latin1-caf\xe9",
}
for in, want := range cases {
if got := escapeControlChars(in); got != want {
t.Errorf("escapeControlChars(%q) = %q, want %q", in, got, want)
}
}
}
// pathLines returns the text of each line the library wrote below a
// display's line. It writes one as an erase to the end of the line, a
// carriage return, the text and a newline.
func pathLines(out string) []string {
matches := regexp.MustCompile("\x1b\\[K\r([^\n]*)\n").
FindAllStringSubmatch(out, -1)
lines := make([]string, 0, len(matches))
for _, m := range matches {
lines = append(lines, m[1])
}
return lines
}
// TestTerminalWalkShowsPathBelow checks that the walk's display on a
// terminal 30 columns wide shows the path it reached last on the line
// below, cut to 29 columns. That line is erased before a warning is
// printed, and again when the walk ends, so the next phase is drawn on
// an empty line.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkShowsPathBelow(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 30 }, testRedraw, time.Now)
const shown = "/srv/photos/2026/holiday/beac"
p.setPath("/srv/photos/2026/holiday/beach.jpg")
p.increment()
awaitOutput(t, stderr, shown, 1)
p.warnf("warning")
before, _, _ := strings.Cut(stderr(), "warning")
if lines := pathLines(before); lines[len(lines)-1] != "" {
t.Errorf("line below shows %q as the warning is printed, want it "+
"empty", lines[len(lines)-1])
}
// Drawn again below the warning.
awaitOutput(t, stderr, shown, strings.Count(before, shown)+1)
p.finish()
lines := pathLines(stderr())
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
if last := lines[len(lines)-1]; last != "" {
t.Errorf("line below the display shows %q after the walk, want it "+
"empty", last)
}
}
// TestTerminalWalkEscapesControlChars checks that a walked path holding
// escape characters, which would clear the screen and set the window
// title, puts none on the line below the walk's display. The line shows
// each control character written out, as \x1b.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkEscapesControlChars(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 80 }, testRedraw, time.Now)
p.setPath("/srv/\x1b[2J\x1b]0;title\a/beach.jpg")
p.increment()
awaitOutput(t, stderr, "beach.jpg", 1)
p.finish()
lines := pathLines(stderr())
for _, line := range lines {
if strings.Contains(line, "\x1b") {
t.Errorf("line below the display = %q, want no escape "+
"character", line)
}
}
const shown = `/srv/\x1b[2J\x1b]0;title\x07/beach.jpg`
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
}
// TestPlainWalkShowsNoPath checks that the walk's display prints no path
// when stderr is not a terminal.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainWalkShowsNoPath(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, nil, testRedraw, time.Now)
p.setPath("/srv/a")
p.increment()
p.finish()
if strings.Contains(stderr(), "/srv/a") {
t.Errorf("stderr = %q, want no path", stderr())
}
}
+61 -36
View File
@@ -1,13 +1,17 @@
package main package sfdupes
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -20,14 +24,16 @@ 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
mtime time.Time diskSize int64
head string mtime time.Time
tail string head [sha256.Size]byte
content string tail [sha256.Size]byte
path string content [sha256.Size]byte
path string
} }
// reportPhase names one phase of report with its place among all of // reportPhase names one phase of report with its place among all of
@@ -38,17 +44,17 @@ func reportPhase(name string) string {
} }
// runReport implements the report subcommand: it prints the file-level // runReport implements the report subcommand: it prints the file-level
// duplicates report as TSV on stdout. It never touches the scanned // duplicates report as TSV on stdout. Of the scanned filesystem it
// filesystem; its only I/O is the database, stdout, and stderr. It // only checks, with pathExists, that each path in a duplicate group is
// names the database on stderr before it opens it. Any database // still there. It names the database on stderr before it opens it. Any
// problem, including a missing database, is fatal. // 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 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 {
@@ -74,13 +80,14 @@ func runReport(ctx context.Context, stdout io.Writer) error {
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
// The read phase writes the report to stdout while it reads the // The read phase checks the paths of each duplicate group on disk
// records, so it may wait on either. // and writes the report to stdout while it reads the records, so it
// may wait on any of the three.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: reportPhase("read"), unit: "records", total: -1, label: reportPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db/out", waitingFor: countingRecords, stalledOn: "db/disk/out",
}) })
report, err := readReport(ctx, db, dbPath, stdout, prog) report, err := readReport(ctx, db, dbPath, stdout, pathExists, prog)
prog.finish() prog.finish()
@@ -101,14 +108,15 @@ func runReport(ctx context.Context, stdout io.Writer) error {
// dbPath, as its total, then reads every record in signature order, // dbPath, as its total, then reads every record in signature order,
// counting each on prog, and writes the report to stdout: the header // counting each on prog, and writes the report to stdout: the header
// line, then the rows of each size's duplicate groups once that size is // line, then the rows of each size's duplicate groups once that size is
// read. stdout may be the terminal prog is drawn on, so the lines go // read. A path for which exists returns false is left out of its group.
// through prog's writer. It returns what it wrote, with the totals for // stdout may be the terminal prog is drawn on, so the lines go through
// the summary line. // prog's writer. It returns what it wrote, with the totals for the
// summary line.
func readReport(ctx context.Context, db *sql.DB, dbPath string, func readReport(ctx context.Context, db *sql.DB, dbPath string,
stdout io.Writer, prog *progress, stdout io.Writer, exists func(path string) bool, prog *progress,
) (*reportWriter, error) { ) (*reportWriter, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
report := &reportWriter{out: out} report := &reportWriter{out: out, exists: exists}
_, err := fmt.Fprintln(out, "first\tdupe\tsize") _, err := fmt.Fprintln(out, "first\tdupe\tsize")
if err != nil { if err != nil {
@@ -155,6 +163,10 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
type reportWriter struct { type reportWriter struct {
out io.Writer out io.Writer
// exists reports whether a path is still on disk. A path that is
// gone is left out of its group.
exists func(path string) bool
sig scanRec // the signature being read; its path is not used sig scanRec // the signature being read; its path is not used
paths []string // the paths read so far with sig paths []string // the paths read so far with sig
groups [][]string // the duplicate groups of sig.size, each sorted groups [][]string // the duplicate groups of sig.size, each sorted
@@ -169,12 +181,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)
@@ -197,11 +203,20 @@ func (w *reportWriter) add(r scanRec) error {
return nil return nil
} }
// endGroup ends the paths read with w.sig: two or more are a duplicate // endGroup ends the paths read with w.sig: two or more that are still
// group, held with its paths sorted until writeGroups writes it. // on disk are a duplicate group, held with its paths sorted until
// writeGroups writes it. Empty files form no group, since removing one
// frees nothing. Only the paths of a possible group are checked, never
// a path with no duplicate or an empty file.
func (w *reportWriter) endGroup() { func (w *reportWriter) endGroup() {
if len(w.paths) < minGroupSize { if len(w.paths) >= minGroupSize && w.sig.size > 0 {
// A path with no duplicate is dropped, and its slice reused. w.paths = slices.DeleteFunc(w.paths, func(p string) bool {
return !w.exists(p)
})
}
if len(w.paths) < minGroupSize || w.sig.size == 0 {
// The paths are dropped, and their slice reused.
w.paths = w.paths[:0] w.paths = w.paths[:0]
return return
@@ -248,6 +263,16 @@ func (w *reportWriter) finish() error {
return w.writeGroups() return w.writeGroups()
} }
// pathExists reports whether path is still on disk, for report and
// trees. It is false only when lstat finds that the path, or a
// directory above it, is gone. Any other error counts as present, so
// a user who may not look inside a directory still sees its copies.
func pathExists(path string) bool {
_, err := os.Lstat(path)
return !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTDIR)
}
// escapePath returns a path as it is written in a report column (README // escapePath returns a path as it is written in a report column (README
// "Report output format"): a backslash, tab, newline or carriage return // "Report output format"): a backslash, tab, newline or carriage return
// becomes \\, \t, \n or \r, and every other byte is kept as it is. // becomes \\, \t, \n or \r, and every other byte is kept as it is.
@@ -1,7 +1,8 @@
package main package sfdupes //nolint:testpackage // checks the unexported dupeGroup results
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"},
} }
} }
@@ -58,15 +72,37 @@ type dupeGroup struct {
paths []string paths []string
} }
// everyPathExists stands in for pathExists where a test's records name
// made-up paths that are not on disk.
func everyPathExists(string) bool { return true }
// recsOnDisk returns a new directory and recs with each path moved
// under it, after creating an empty file at each moved path: report and
// trees then find every path still on disk.
func recsOnDisk(t *testing.T, recs []scanRec) (string, []scanRec) {
t.Helper()
dir := realTempDir(t)
moved := make([]scanRec, len(recs))
for i, r := range recs {
r.path = writeFile(t, dir, r.path, nil)
moved[i] = r
}
return dir, moved
}
// dupeGroups returns the duplicate groups report reads from db, in // dupeGroups returns the duplicate groups report reads from db, in
// report order, from the rows it writes: a group's rows come together, // report order, from the rows it writes: a group's rows come together,
// each naming the group's first path. // each naming the group's first path. Every path counts as still on
// disk.
func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup { func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup {
t.Helper() t.Helper()
var out bytes.Buffer var out bytes.Buffer
_, err := readReport(t.Context(), db, "", &out, nil) _, err := readReport(t.Context(), db, "", &out, everyPathExists, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -113,7 +149,8 @@ func dupeGroupsOf(t *testing.T, recs []scanRec) []dupeGroup {
} }
func TestRunReportEscapesPaths(t *testing.T) { func TestRunReportEscapesPaths(t *testing.T) {
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs())) dir, recs := recsOnDisk(t, awkwardPairRecs())
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -124,7 +161,7 @@ func TestRunReportEscapesPaths(t *testing.T) {
} }
want := "first\tdupe\tsize\n" + want := "first\tdupe\tsize\n" +
`/d/\tone\ntwo\rthree\\four/f` + "\t/d/A/f\t5\n" dir + `/d/\tone\ntwo\rthree\\four/f` + "\t" + dir + "/d/A/f\t5\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)
} }
@@ -143,8 +180,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),
} }
} }
@@ -158,7 +195,8 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// Counts the records read, and draws nothing. // Counts the records read, and draws nothing.
prog := &progress{now: time.Now} prog := &progress{now: time.Now}
_, err = readReport(t.Context(), db, "", failingWriter{}, prog) _, err = readReport(t.Context(), db, "", failingWriter{},
everyPathExists, prog)
if !errors.Is(err, errWriteFailed) || if !errors.Is(err, errWriteFailed) ||
!strings.HasPrefix(err.Error(), "write stdout: ") { !strings.HasPrefix(err.Error(), "write stdout: ") {
t.Errorf("error = %v, want write stdout: %v", err, errWriteFailed) t.Errorf("error = %v, want write stdout: %v", err, errWriteFailed)
@@ -173,7 +211,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" +
@@ -189,7 +227,8 @@ const smokeReport = "first\tdupe\tsize\n" +
// it ends; read counts the 6 records before it reads them. The summary // it ends; read counts the 6 records before it reads them. The summary
// comes last, and stdout holds the report alone. // comes last, and stdout holds the report alone.
func TestRunReportProgressNamesEachPhase(t *testing.T) { func TestRunReportProgressNamesEachPhase(t *testing.T) {
path := seedDatabase(t, smokeTreeRecs()) dir, recs := recsOnDisk(t, smokeTreeRecs())
path := seedDatabase(t, recs)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
stderr := captureStderr(t) stderr := captureStderr(t)
@@ -202,8 +241,9 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
code, exitOK, stderr()) code, exitOK, stderr())
} }
if got := stdout.String(); got != smokeReport { report := strings.ReplaceAll(smokeReport, "/d/", dir+"/d/")
t.Errorf("stdout = %q, want %q", got, smokeReport) if got := stdout.String(); got != report {
t.Errorf("stdout = %q, want %q", got, report)
} }
// How each line starts; the rate at the end of a phase varies. // How each line starts; the rate at the end of a phase varies.
@@ -211,7 +251,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",
@@ -252,7 +292,7 @@ func TestReadReportOnTerminal(t *testing.T) {
waitingFor: countingRecords, waitingFor: countingRecords,
}, terminalWidth, time.Hour, time.Now) }, terminalWidth, time.Hour, time.Now)
_, err = readReport(t.Context(), db, "", os.Stderr, p) _, err = readReport(t.Context(), db, "", os.Stderr, everyPathExists, p)
p.finish() p.finish()
@@ -277,15 +317,18 @@ 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"},
) )
_, recs = recsOnDisk(t, recs)
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
slices.Reverse(reversed) slices.Reverse(reversed)
@@ -386,16 +429,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)
@@ -416,19 +462,150 @@ func TestDupeGroups(t *testing.T) {
} }
} }
// TestDupeGroupsLeavesOutEmptyFiles checks that empty files form no
// duplicate group, while 1-byte files still do.
func TestDupeGroupsLeavesOutEmptyFiles(t *testing.T) {
t.Parallel()
e, x := sha256.Sum256(nil), testHash("x")
recs := []scanRec{
{size: 0, head: e, tail: e, content: e, path: "/a/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/b/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/c/__init__.py"},
{size: 1, head: x, tail: x, content: x, path: "/tiny1"},
{size: 1, head: x, tail: x, content: x, path: "/tiny2"},
}
groups := dupeGroupsOf(t, recs)
if len(groups) != 1 || groups[0].size != 1 ||
!slices.Equal(groups[0].paths, []string{"/tiny1", "/tiny2"}) {
t.Fatalf("groups = %+v, want only /tiny1 /tiny2 of size 1", groups)
}
}
// TestReadReportChecksOnlyPossibleDuplicates checks that report asks
// whether a path is on disk only when its signature has two or more
// records, and never for an empty file.
func TestReadReportChecksOnlyPossibleDuplicates(t *testing.T) {
t.Parallel()
a, u, e := testHash("a"), testHash("u"), sha256.Sum256(nil)
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/copy/1"},
{size: 9, head: a, tail: a, content: a, path: "/copy/2"},
{size: 9, head: a, tail: a, content: a, path: "/copy/3"},
{size: 9, head: u, tail: u, content: u, path: "/lonely"},
{size: 0, head: e, tail: e, content: e, path: "/empty1"},
{size: 0, head: e, tail: e, content: e, path: "/empty2"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
var checked []string
exists := func(path string) bool {
checked = append(checked, path)
return true
}
_, err = readReport(t.Context(), db, "", io.Discard, exists, nil)
if err != nil {
t.Fatal(err)
}
slices.Sort(checked)
want := []string{"/copy/1", "/copy/2", "/copy/3"}
if !slices.Equal(checked, want) {
t.Errorf("paths checked on disk = %q, want %q", checked, want)
}
}
// TestReadReportDropsGroupLeftWithOnePath checks that a group of two
// paths, one of them gone, is no duplicate group. If kept, it would
// write no row, but the summary line would count it.
func TestReadReportDropsGroupLeftWithOnePath(t *testing.T) {
t.Parallel()
a := testHash("a")
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/a/kept"},
{size: 9, head: a, tail: a, content: a, path: "/a/gone"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
exists := func(path string) bool { return path != "/a/gone" }
report, err := readReport(t.Context(), db, "", io.Discard, exists, nil)
if err != nil {
t.Fatal(err)
}
if report.dupeGroups != 0 {
t.Errorf("duplicate groups = %d, want 0", report.dupeGroups)
}
}
// TestReadReportKeepsGroupLeftWithTwoPaths checks that a group of three
// paths, one of them gone, is a duplicate group of the other two.
func TestReadReportKeepsGroupLeftWithTwoPaths(t *testing.T) {
t.Parallel()
a := testHash("a")
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/b/kept1"},
{size: 9, head: a, tail: a, content: a, path: "/b/gone"},
{size: 9, head: a, tail: a, content: a, path: "/b/kept2"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
exists := func(path string) bool { return path != "/b/gone" }
var out bytes.Buffer
report, err := readReport(t.Context(), db, "", &out, exists, nil)
if err != nil {
t.Fatal(err)
}
if report.dupeGroups != 1 {
t.Errorf("duplicate groups = %d, want 1", report.dupeGroups)
}
want := "first\tdupe\tsize\n/b/kept1\t/b/kept2\t9\n"
if got := out.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want)
}
}
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 +624,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 +647,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 +674,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 +702,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)
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package main package sfdupes
import "syscall" import "syscall"
@@ -1,6 +1,6 @@
//go:build !darwin //go:build !darwin
package main package sfdupes
import "syscall" import "syscall"
File diff suppressed because it is too large. Load diff
+52 -36
View File
@@ -1,4 +1,4 @@
package main package sfdupes
import ( import (
"context" "context"
@@ -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,
@@ -34,17 +38,18 @@ func treesPhase(name string) string {
// runTrees implements the trees subcommand: it reads every record from // runTrees implements the trees subcommand: it reads every record from
// the database in path order, reconstructs the directory hierarchy from // the database in path order, reconstructs the directory hierarchy from
// the record paths, computes a Merkle-style digest per directory, and // the record paths, computes a Merkle-style digest per directory, and
// prints maximal duplicate-tree groups as TSV on stdout. It never // prints maximal duplicate-tree groups as TSV on stdout. Of the scanned
// touches the scanned filesystem; its only I/O is the database, stdout, // filesystem it only checks, with pathExists, that the root of each
// and stderr. It names the database on stderr before it opens it. Any // duplicate tree is still there, never the files below it. It names the
// database problem, including a missing database, is fatal. // database on stderr before it opens it. Any 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 {
@@ -82,13 +87,13 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
return fmt.Errorf("database %s: %w", dbPath, err) return fmt.Errorf("database %s: %w", dbPath, err)
} }
// Grouping works in memory, so it waits on nothing but the // Grouping works in memory, then checks the root of each duplicate
// processor. // tree on disk, so it waits on the processor or the disk.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: treesPhase("group"), unit: "directories", label: treesPhase("group"), unit: "directories",
total: int64(len(allDirs)), stalledOn: "cpu", total: int64(len(allDirs)), stalledOn: "cpu/disk",
}) })
dupes := collectTreeGroups(allDirs, super, prog) dupes := collectTreeGroups(allDirs, super, pathExists, prog)
prog.finish() prog.finish()
@@ -139,15 +144,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 +165,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 +238,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 +277,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,26 +294,21 @@ 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
// prog as it is grouped, and returns every maximal group with two or // prog as it is grouped, and returns every maximal group with two or
// more members, each group's members sorted by path, groups ordered by // more members for which exists returns true, each group's members
// tree size descending then by first path ascending. // sorted by path, groups ordered by tree size descending then by first
// path ascending. A group whose trees hold 0 bytes is left out, since
// removing one frees nothing. The groups it implies hold 0 bytes too,
// so leaving it out hides nothing. exists is called only on the members
// of maximal groups.
func collectTreeGroups(allDirs []*treeNode, super *treeNode, func collectTreeGroups(allDirs []*treeNode, super *treeNode,
prog *progress, exists func(path string) bool, prog *progress,
) [][]*treeNode { ) [][]*treeNode {
groups := make(map[[sha256.Size]byte][]*treeNode) groups := make(map[[sha256.Size]byte][]*treeNode)
for _, d := range allDirs { for _, d := range allDirs {
@@ -317,7 +320,19 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
var dupes [][]*treeNode var dupes [][]*treeNode
for _, g := range groups { for _, g := range groups {
if len(g) < minGroupSize || suppressed(g, super) { // The members share a digest, so they share a total size.
if len(g) < minGroupSize || g[0].totalSize == 0 ||
suppressed(g, super) {
continue
}
// A member that is gone leaves the group. A suppressed group is
// still implied: its members still on disk have parents still
// on disk, and those parents' group is checked the same way.
g = slices.DeleteFunc(g, func(n *treeNode) bool {
return !exists(n.path)
})
if len(g) < minGroupSize {
continue continue
} }
@@ -347,7 +362,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)
+205 -51
View File
@@ -1,41 +1,42 @@
package main package sfdupes //nolint:testpackage // checks the unexported treeNode results
import ( import (
"bytes" "bytes"
"crypto/sha256"
"database/sql" "database/sql"
"os" "os"
"slices" "slices"
"strconv"
"strings" "strings"
"testing" "testing"
"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 +154,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,
}) })
} }
} }
@@ -178,7 +180,7 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
t.Fatalf("directory paths = %q, want %q", got, want) t.Fatalf("directory paths = %q, want %q", got, want)
} }
groups := collectTreeGroups(dirs, super, nil) groups := collectTreeGroups(dirs, super, everyPathExists, nil)
gotGroups := groupPaths(groups) gotGroups := groupPaths(groups)
wantGroups := [][]string{{"/a", "/c"}} wantGroups := [][]string{{"/a", "/c"}}
@@ -194,7 +196,8 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
} }
func TestRunTreesEscapesPaths(t *testing.T) { func TestRunTreesEscapesPaths(t *testing.T) {
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs())) dir, recs := recsOnDisk(t, awkwardPairRecs())
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -204,22 +207,45 @@ 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" dir + `/d/\tone\ntwo\rthree\\four` + "\t" + dir + "/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"},
}
dir, recs := recsOnDisk(t, recs)
t.Setenv(databaseEnv, seedDatabase(t, recs))
want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
dir + "/a\t" + dir + "/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
// naming it with its place among the four as it starts and another as // naming it with its place among the four as it starts and another as
// it ends. read counts the 6 records before it reads them, group counts // it ends. read counts the 6 records before it reads them, group counts
// the 8 directories, and write the 1 group. The summary comes last, and // the directories, and write the 1 group. The summary comes last, and
// stdout holds the report alone. // stdout holds the report alone.
func TestRunTreesProgressNamesEachPhase(t *testing.T) { func TestRunTreesProgressNamesEachPhase(t *testing.T) {
path := seedDatabase(t, smokeTreeRecs()) dir, recs := recsOnDisk(t, smokeTreeRecs())
path := seedDatabase(t, recs)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
stderr := captureStderr(t) stderr := captureStderr(t)
@@ -232,21 +258,27 @@ 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" +
dir + "/d/t1\t" + dir + "/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)
} }
// The smoke-test records name 8 directories, / included. Moved under
// dir, they name one more for each slash in dir.
dirs := strconv.Itoa(8 + strings.Count(dir, "/"))
// How each line starts; the rate at the end of a phase varies. // How each line starts; the rate at the end of a phase varies.
want := []string{ want := []string{
"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/" + dirs + "] 0%, 0 directories/s, elapsed 0s, " +
"[3/4] group: [8/8] 100%, ", "remain unknown",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, eta unknown", "[3/4] group: [" + dirs + "/" + dirs + "] 100%, ",
"[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, ",
} }
@@ -274,7 +306,7 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
func TestWriteTreeGroupsOnTerminal(t *testing.T) { func TestWriteTreeGroupsOnTerminal(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
super, dirs := treeOf(t, smokeTreeRecs()) super, dirs := treeOf(t, smokeTreeRecs())
dupes := collectTreeGroups(dirs, super, nil) dupes := collectTreeGroups(dirs, super, everyPathExists, nil)
// No timed draws: the display is drawn as it starts and as it ends. // No timed draws: the display is drawn as it starts and as it ends.
p := startProgress(progressParams{ p := startProgress(progressParams{
@@ -297,7 +329,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 +366,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)
@@ -354,7 +387,7 @@ func TestCollectTreeGroupsMaximal(t *testing.T) {
super, dirs := treeOf(t, smokeTreeRecs()) super, dirs := treeOf(t, smokeTreeRecs())
groups := collectTreeGroups(dirs, super, nil) groups := collectTreeGroups(dirs, super, everyPathExists, nil)
want := [][]string{{"/d/t1", "/d/t2"}} want := [][]string{{"/d/t1", "/d/t2"}}
if got := groupPaths(groups); !slices.EqualFunc(got, want, if got := groupPaths(groups); !slices.EqualFunc(got, want,
@@ -378,14 +411,16 @@ func TestCollectTreeGroupsDeterministic(t *testing.T) {
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
forward := groupPaths(collectTreeGroups(dirs, super, nil)) forward := groupPaths(collectTreeGroups(dirs, super, everyPathExists,
nil))
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
slices.Reverse(reversed) slices.Reverse(reversed)
superR, dirsR := treeOf(t, reversed) superR, dirsR := treeOf(t, reversed)
backward := groupPaths(collectTreeGroups(dirsR, superR, nil)) backward := groupPaths(collectTreeGroups(dirsR, superR, everyPathExists,
nil))
if !slices.EqualFunc(forward, backward, slices.Equal) { if !slices.EqualFunc(forward, backward, slices.Equal) {
t.Fatalf("output depends on record order: %v vs %v", t.Fatalf("output depends on record order: %v vs %v",
forward, backward) forward, backward)
@@ -397,14 +432,44 @@ 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)
got := groupPaths(collectTreeGroups(dirs, super, nil)) got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil))
want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
}
// TestCollectTreeGroupsLeavesOutEmptyTrees checks that directories
// holding only empty files form no group, and that an empty file still
// counts toward the digest of a tree that holds other files.
func TestCollectTreeGroupsLeavesOutEmptyTrees(t *testing.T) {
t.Parallel()
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"),
sha256.Sum256(nil)
recs := []scanRec{
{size: 0, head: e, tail: e, content: e, path: "/e/a/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/e/b/.gitkeep"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
{size: 0, head: e, tail: e, content: e, path: "/p/x1/.gitkeep"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
{size: 0, head: e, tail: e, content: e, path: "/p/x2/.gitkeep"},
// Without the empty file, /p/x3 is no copy of /p/x1.
{size: 10, head: h, tail: tl, content: c, path: "/p/x3/f"},
}
super, dirs := treeOf(t, recs)
got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil))
want := [][]string{{"/p/x1", "/p/x2"}} want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) { if !slices.EqualFunc(got, want, slices.Equal) {
@@ -418,18 +483,107 @@ 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)
got := groupPaths(collectTreeGroups(dirs, super, nil)) got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil))
want := [][]string{{"/p/a/x", "/q/b/x"}} want := [][]string{{"/p/a/x", "/q/b/x"}}
if !slices.EqualFunc(got, want, slices.Equal) { if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want) t.Fatalf("groups = %v, want %v", got, want)
} }
} }
// TestCollectTreeGroupsChecksOnlyReportedCopies checks that trees asks
// whether a directory is on disk only for the members of the groups it
// returns. It never asks about /a/proj/sub, which lies inside a copy
// and whose group the group of /a/proj implies.
func TestCollectTreeGroupsChecksOnlyReportedCopies(t *testing.T) {
t.Parallel()
f, g, u := testHash("f"), testHash("g"), testHash("u")
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/a/proj/f"},
{size: 20, head: g, tail: g, content: g, path: "/a/proj/sub/g"},
{size: 10, head: f, tail: f, content: f, path: "/b/proj/f"},
{size: 20, head: g, tail: g, content: g, path: "/b/proj/sub/g"},
// /a lacks this file, so /a and /b are no copies of each other.
{size: 30, head: u, tail: u, content: u, path: "/b/lonely"},
}
super, dirs := treeOf(t, recs)
var checked []string
exists := func(path string) bool {
checked = append(checked, path)
return true
}
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
want := [][]string{{"/a/proj", "/b/proj"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
slices.Sort(checked)
if !slices.Equal(checked, want[0]) {
t.Errorf("directories checked on disk = %q, want %q",
checked, want[0])
}
}
// TestCollectTreeGroupsDropsGroupLeftWithOneCopy checks that a group of
// two copies, one of them gone, is not returned. If kept, it would
// write no row, but the summary line would count it.
func TestCollectTreeGroupsDropsGroupLeftWithOneCopy(t *testing.T) {
t.Parallel()
f := testHash("f")
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/c/kept/f"},
{size: 10, head: f, tail: f, content: f, path: "/c/gone/f"},
}
super, dirs := treeOf(t, recs)
exists := func(path string) bool { return path != "/c/gone" }
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
if len(got) != 0 {
t.Errorf("groups = %v, want none", got)
}
}
// TestCollectTreeGroupsKeepsGroupLeftWithTwoCopies checks that a group
// of three copies, one of them gone, is returned with the other two.
func TestCollectTreeGroupsKeepsGroupLeftWithTwoCopies(t *testing.T) {
t.Parallel()
f := testHash("f")
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/d/kept1/f"},
{size: 10, head: f, tail: f, content: f, path: "/d/gone/f"},
{size: 10, head: f, tail: f, content: f, path: "/d/kept2/f"},
}
super, dirs := treeOf(t, recs)
exists := func(path string) bool { return path != "/d/gone" }
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
want := [][]string{{"/d/kept1", "/d/kept2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Errorf("groups = %v, want %v", got, want)
}
}