Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f51a4da78 | ||
|
|
4ecf8dc484 | ||
|
|
2322f8bb02 | ||
|
|
4ed2e370b1 | ||
|
|
98193b2896 | ||
|
|
ee09cda355 |
@@ -20,7 +20,7 @@
|
||||
|
||||
# Next Step
|
||||
|
||||
- Take the next issue from the `1.0.0` milestone:
|
||||
- Take the next issue from the `1.0.0` milestone,
|
||||
https://git.eeqj.de/sneak/sfdupes/milestone/17. The issues are not restated
|
||||
here, because a copy drifts as soon as the tracker moves.
|
||||
|
||||
@@ -29,40 +29,72 @@
|
||||
Newest first. An entry ends with its date and its issue or branch, where those
|
||||
are known.
|
||||
|
||||
- `TODO.md` rewritten from scratch by the same rules as `README.md`: short
|
||||
sentences, lists as bullets, nothing a newcomer reads twice. Every completed
|
||||
step is kept, with its date and its issue or branch. The policy-compliance
|
||||
checklist of 2026-07-22 is folded into its step (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
- The three disk size tests in `scan_test.go` no longer fail at random on ZFS.
|
||||
`unix.Sync` can return before ZFS has counted a file's blocks, and one ZFS
|
||||
commit can take a file through several sizes. The test that needs the size to
|
||||
change waits for it, for at most 30 seconds. Each test reads the size before
|
||||
and after its scan. The rewrite test rescans until the two reads match, then
|
||||
compares exactly. The other two fail only on a `disk_size` outside the two
|
||||
reads (2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/150)
|
||||
|
||||
- `TODO.md` rewritten from scratch by the same rules as `README.md`, sentence by
|
||||
sentence. Every completed step is kept, with its date and its issue or branch.
|
||||
The policy-compliance checklist of 2026-07-22 is folded into its step
|
||||
(2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
|
||||
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
|
||||
it. It leads with what the program is and how to use it. A reference section
|
||||
covers the settings, the database, the output formats and the exit codes;
|
||||
development and testing come last. Every statement was checked against the
|
||||
code on `next` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
covers every setting and every subcommand. Development and testing come last.
|
||||
Every statement was checked against the code on `next`. Five comments in
|
||||
`main_test.go` cite "Settings", where their rules now live. One comment each
|
||||
in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
|
||||
now says (2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
|
||||
- Each record holds `disk_size`, the bytes the file takes on disk. That is its
|
||||
allocated blocks, `st_blocks` × 512, from the `lstat` call the walk already
|
||||
makes. `trees` prints a `disk_size` column after `size`. It is the sum over
|
||||
the `dupe` copy's files. On a compressed or sparse file `disk_size` differs
|
||||
from `size`. A rescan updates the `disk_size` of an unchanged file whose size
|
||||
on disk changed, without reading the file (2026-10-27,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/147)
|
||||
|
||||
- The database keeps the number of records and the sum of their sizes in the one
|
||||
row of a `totals` table. Three triggers on `files` keep that row current.
|
||||
`info` reads that row. So do `scan`'s `load` phase and the `read` phases of
|
||||
`report` and `trees`, for their totals. No count reads every record any more.
|
||||
The triggers make each write to `files` slower. `scan` keeps SQLite's
|
||||
temporary data in memory. That wins back part of that time (2026-10-27,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/146)
|
||||
|
||||
- an operand at or inside a directory named `.zfs` is never scanned, whether
|
||||
given alone or under another operand. `scan` warns about it, counts it as
|
||||
skipped and does not walk it (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||
|
||||
- A file or directory removed while the scan runs is not an error, in the walk
|
||||
or in the hash phase. The scan prints no line for it and does not count it as
|
||||
skipped. When the first of several hard links to a file is gone, the hash
|
||||
phase reads the next one instead. On a terminal every progress display starts
|
||||
with the spinner, the hash phase's included. A phase with an exact total ends
|
||||
its line with its percent and a 10-column bar at the terminal's right edge,
|
||||
which no longer moves as the line's length changes. A line too wide for the
|
||||
its line with its percent and a 10-column bar at the terminal's right edge.
|
||||
The bar no longer moves as the line's length changes. A line too wide for the
|
||||
terminal now loses its rate before its bar. The time remaining is labelled
|
||||
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133)
|
||||
|
||||
- The `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
|
||||
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
|
||||
raw from the hash phase through the database to `report` and `trees`. Reading
|
||||
a record whose hash is not 32 bytes is an error (2026-10-26,
|
||||
bytes of a SHA-256. That is half the size of the hex text they held. The
|
||||
hashes stay raw from the hash phase through the database to `report` and
|
||||
`trees`. Reading a record whose hash is not 32 bytes is an error (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/134)
|
||||
|
||||
- Every file the walk records gets three hashes in the hash phase. `head` and
|
||||
`tail` are the SHA-256 of its first and last 64 KiB. `content` is the SHA-256
|
||||
of the whole file under 100 MiB, and of 1 MiB at each gigabyte from 100 MiB
|
||||
up. Gone are:
|
||||
- The hash phase gives three hashes to every file that the walk records. `head`
|
||||
and `tail` are the SHA-256 of its first and last 64 KiB. `content` is the
|
||||
SHA-256 of the whole file under 100 MiB, and of 1 MiB at each gigabyte from
|
||||
100 MiB up. Gone are:
|
||||
- the 10 MiB and 50 MiB thresholds;
|
||||
- the size census;
|
||||
- the size census, which counted the sizes of every walked file and of every
|
||||
record outside the operands. A file was hashed only when the census showed
|
||||
another file of its size;
|
||||
- the records without hashes;
|
||||
- the content phase.
|
||||
|
||||
@@ -71,72 +103,110 @@ are known.
|
||||
with a content hash, since every record has all three (2026-10-25,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/132)
|
||||
|
||||
- `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` re-vendored from
|
||||
`sneak/prompts` `next` at `0653579`. Two steps now come before
|
||||
`ARG CACHEBUST`: the module download in the `lint`, `test` and `builder`
|
||||
stages of the `Dockerfile`, and the package install in `builder`. Docker keeps
|
||||
them until the base image, the install line, `go.mod` or `go.sum` changes.
|
||||
Every step that lints, tests or compiles still comes after it.
|
||||
`golangci-lint run` also keeps the linter's own cache. README says the same
|
||||
(2026-10-18, https://git.eeqj.de/sneak/sfdupes/issues/109)
|
||||
- `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` were copied
|
||||
again from `sneak/prompts` `next` at `0653579`. Two steps now come before
|
||||
`ARG CACHEBUST`:
|
||||
- the module download in the `lint`, `test` and `builder` stages of the
|
||||
`Dockerfile`;
|
||||
- the package install in `builder`.
|
||||
|
||||
- Release rule set: until 1.0, each tip of `main` that passes the build gets the
|
||||
next `v0.0.N` tag, so `go install sneak.berlin/go/sfdupes@latest` installs it.
|
||||
`go.mod` retracts `v0.0.1`, the early build without a database. When the
|
||||
linker set no version, `sfdupes --version` prints the version Go recorded in
|
||||
the binary: the tag, or the pseudo-version of a `go install` build. It prints
|
||||
`dev` only when neither is known. README "Install" documented the rule, what
|
||||
`@main` and `v0.0.1` give, and how to check an installed binary (2026-10-17,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/116)
|
||||
Docker keeps them until one of these changes:
|
||||
- the base image;
|
||||
- the install line;
|
||||
- `go.mod` or `go.sum`.
|
||||
|
||||
Every step that checks or compiles the code still comes after
|
||||
`ARG CACHEBUST`. `golangci-lint run` also keeps the linter's own cache.
|
||||
README says the same (2026-10-18,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/109)
|
||||
|
||||
- The release rule was set. Until 1.0, each tip of `main` that passes the build
|
||||
gets the next `v0.0.N` tag. `go install sneak.berlin/go/sfdupes@latest` then
|
||||
installs it. `go.mod` retracts `v0.0.1`, the early build without a database.
|
||||
When the linker set no version, `sfdupes --version` prints the version Go
|
||||
recorded in the binary. That is the tag, or the pseudo-version of a
|
||||
`go install` build. It prints `dev` only when neither is known. README
|
||||
"Install" documented:
|
||||
- the rule;
|
||||
- what `@main` and `v0.0.1` give;
|
||||
- how to check an installed binary.
|
||||
|
||||
(2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/116)
|
||||
|
||||
- `scan` creates the database's directory where the database path leads. The
|
||||
directory is the path with its last element cut off as text, never cleaned. A
|
||||
`..` after a symlinked directory therefore goes through the symlink, as it
|
||||
does when the file is opened. With
|
||||
directory is the path with its last element cut off as text, so a `..` in it
|
||||
stays. A `..` after a symlinked directory therefore goes through the symlink,
|
||||
as it does when the file is opened. With
|
||||
`SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a symlink to
|
||||
`/data/x`, it now creates `/data/new` and the database in it. 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 the `.lock` file. The walk passes over a file with one of
|
||||
them wherever it reaches 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)
|
||||
are open, it notes the device and inode of each of these:
|
||||
- the database file;
|
||||
- its `-wal` and `-shm` files;
|
||||
- the `.lock` file.
|
||||
|
||||
- `scan` never records or reads anything on a filesystem that is not persistent:
|
||||
`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types. It
|
||||
matches them by device ID. As the scan starts, it lists the mounted
|
||||
The walk passes over any file with the device and inode of one of them,
|
||||
however it reaches the file. It prints no line for such a file and counts
|
||||
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 on a filesystem that is not persistent,
|
||||
such as `tmpfs` or `proc`. Such a filesystem is one the kernel makes up or
|
||||
keeps in memory. `scan` matches them by device ID, however the walk or the
|
||||
content phase reaches a path. As the scan starts, it lists the mounted
|
||||
filesystems of those types, from `/proc/self/mountinfo` on Linux and from the
|
||||
mount list on macOS. No flag turns that off. Below an operand, the 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 those are
|
||||
`/proc`, `/sys`, `/dev`, `/run`, `/tmp` and `/var/tmp`. On macOS they are
|
||||
`/dev`, `/private/tmp`, `/private/var/tmp`, `/private/var/vm`,
|
||||
`/System/Volumes/VM` and `/System/Volumes/Data`. A Windows list exists in the
|
||||
code but is not used. 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)
|
||||
not enter the default excludes either. So `sfdupes scan /` skips the kernel's
|
||||
view of the running system and the temporary directories. On Linux those are:
|
||||
|
||||
- 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. 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 time remaining 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`. The
|
||||
places are `disk`, `db`, `out` for stdout and `cpu`, or two of them for a
|
||||
phase that may wait on either. Each phase gives 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. Every
|
||||
other phase keeps them whenever the elapsed time and the ETA are both in
|
||||
minutes. The content phase's last partial batch is written under a display of
|
||||
its own (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/125)
|
||||
```
|
||||
/proc, /sys, /dev, /run, /tmp, /var/tmp
|
||||
```
|
||||
|
||||
On macOS they are:
|
||||
|
||||
```
|
||||
/dev, /private/tmp, /private/var/tmp, /private/var/vm
|
||||
/System/Volumes/VM, /System/Volumes/Data
|
||||
```
|
||||
|
||||
A Windows list exists in the code but is not used. 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. 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)
|
||||
|
||||
- A progress display's rate covers the items completed over the last 5 seconds.
|
||||
When stderr is not a terminal, it covers the items since the line before. It
|
||||
falls to 0 while nothing completes. A burst after a wait no longer reads as a
|
||||
steady rate of over 1,000 files a second. The time remaining still comes from
|
||||
the phase's average. Once no item has completed for 10 seconds, the line shows
|
||||
`stalled` and where the phase waits in place of the rate, as
|
||||
`stalled disk/db`. The places are:
|
||||
- `disk`;
|
||||
- `db`;
|
||||
- `out`, for stdout;
|
||||
- `cpu`.
|
||||
|
||||
A phase that may wait in two places names both. Each phase's word is set in
|
||||
the code that defines the phase. A line too wide for the terminal loses its
|
||||
bar first, then its rate or those words. Four parts stay whole:
|
||||
- the phase;
|
||||
- the progress;
|
||||
- the elapsed time;
|
||||
- the ETA, the time remaining.
|
||||
|
||||
The content phase had one display for its checks and one for its reads. Both
|
||||
lost those words on an 80-column terminal, at counts like those of README's
|
||||
example. That happened for most elapsed times and ETAs in minutes. Every
|
||||
other phase kept them whenever the elapsed time and the ETA were both in
|
||||
minutes. The content phase writes its last partial batch, the records not
|
||||
yet 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:
|
||||
@@ -144,13 +214,15 @@ are known.
|
||||
- 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 have a content hash;
|
||||
- the number of records;
|
||||
- how many records have `head` and `tail` hashes;
|
||||
- how many records have a content hash;
|
||||
- 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,
|
||||
one read-only transaction, a read of the database at one moment. The counts
|
||||
come 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
|
||||
@@ -159,84 +231,96 @@ are known.
|
||||
opening the database, also while it waits on a busy one. `read` counts the
|
||||
records, then reads them against that count in signature order. SQLite takes
|
||||
that order 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,
|
||||
record comes at once. `report` forms the duplicate groups itself and holds
|
||||
only those of the size it is reading. It 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.
|
||||
The display runs from the moment `trees` 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, no line of stdout 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)
|
||||
- `trees` shows its work on stderr in four phases, from `[1/4] open` to
|
||||
`[4/4] write`, each with the progress display `scan` uses. The display runs
|
||||
from the moment `trees` 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;
|
||||
- groups.
|
||||
|
||||
stdout and the summary line are unchanged. When stdout and stderr are the
|
||||
same file, as one terminal is, no line of stdout 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`. It runs from the moment the phase starts and shows its
|
||||
progress, elapsed time and ETA. `load` counts the records first. The walk's
|
||||
total is estimated from the records under the operands. 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. Any other
|
||||
phase draws 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. It 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)
|
||||
five, as `[2/5] walk`. It runs from the moment that the phase starts. It
|
||||
shows:
|
||||
- the progress;
|
||||
- the elapsed time;
|
||||
- the ETA, the time remaining.
|
||||
|
||||
`load` counts the records first. The walk's total is estimated from the
|
||||
records under the operands. The content phase takes its total from SQLite's
|
||||
grouping alone. Until the first record is returned, it says it is searching
|
||||
the database. On a terminal a phase with an exact total draws its line
|
||||
followed by the bar of the progress-bar library. Any other phase draws 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 the
|
||||
terminal is, even after it is made narrower during a phase. A line too wide
|
||||
leaves out its rate, then its bar. The rest stays whole. The line is cut
|
||||
short only when the rest alone does not fit, as below about 70 columns late
|
||||
in a large scan. When stderr is not a terminal, 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
|
||||
they use, as an absolute path. The line says 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)
|
||||
|
||||
- The canonical files and model scripts re-vendored from `sneak/prompts` `next`
|
||||
at `e3f79c4`:
|
||||
- The canonical files and model scripts were copied again 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. `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. The steps that compile keep Go's build cache, and the
|
||||
tests run with `-count=1`;
|
||||
`make test` runs the suite under the race detector. Gone are
|
||||
`Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race`;
|
||||
- every `docker build` in `script/` passes a new `CACHEBUST` value. Every
|
||||
stage of the `Dockerfile` declares it first, so every `RUN` step runs on
|
||||
every build. 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` that
|
||||
`script/bootstrap` installs, so the `prettier` and `markdown` stages are
|
||||
gone and the build stage installs `git` and `make` itself;
|
||||
`script/bootstrap` installs. 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.
|
||||
- a coding agent's settings file is deleted.
|
||||
|
||||
Since https://git.eeqj.de/sneak/sfdupes/issues/109 two steps come before
|
||||
`ARG CACHEBUST`: the module download in `lint`, `test` and the build stage,
|
||||
and the install of `git` and `make` in the build stage. Docker keeps them
|
||||
between builds, and every step that lints, tests or compiles still runs on
|
||||
every build (2026-10-15, https://git.eeqj.de/sneak/sfdupes/issues/95)
|
||||
`ARG CACHEBUST`:
|
||||
- the module download in `lint`, `test` and the build stage;
|
||||
- the install of `git` and `make` in the build stage.
|
||||
|
||||
- With `-x`, an operand under another operand is no longer dropped when the
|
||||
outer operand's walk cannot reach it. That is the case when a directory from
|
||||
the inner operand up to, but not including, the outer one is on a different
|
||||
Docker keeps them between builds. Every step that checks or compiles the
|
||||
code still runs on every build (2026-10-15,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/95)
|
||||
|
||||
- With `-x`, an operand under another operand is no longer dropped when a
|
||||
directory between the two, the inner one included, is on a different
|
||||
filesystem than the outer one. The inner operand is then walked on its own.
|
||||
For a regular-file operand the directories checked start at its directory. The
|
||||
records of files gone from below it are deleted (2026-10-14,
|
||||
For a regular-file operand the check starts at its directory. 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)
|
||||
under its operand, instead of deleting them. It counts their sizes in the size
|
||||
census (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. A file replaced by a FIFO or device node after it was
|
||||
statted is warned about and skipped instead of hanging the scan (2026-10-14,
|
||||
is still a regular file. A file that is now a FIFO or device node 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,
|
||||
@@ -262,29 +346,34 @@ are known.
|
||||
- `make test-race` ran the test suite under the race detector in a cgo-enabled
|
||||
container, outside `make check`. 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
|
||||
under the race detector, in the `Dockerfile`'s Debian-based `test` phase.
|
||||
`make test-race` is gone (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/18)
|
||||
|
||||
- A bare `docker build .` failed, naming `script/cibuild` and `script/docker`,
|
||||
rather than serve the gates from cache. Since
|
||||
rather than serve the gates, the lint and test steps, from cache. 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 (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/39)
|
||||
|
||||
- `make fmt` and `make fmt-check` run `prettier` over all Markdown, and CI
|
||||
checks it. All Markdown was reformatted (2026-10-04,
|
||||
- `make fmt` and `make fmt-check` run `prettier` over all Markdown, and the
|
||||
Gitea workflow checks it. All Markdown was 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)
|
||||
- Tests cover:
|
||||
- a missing database;
|
||||
- `scan` keeping stdout empty;
|
||||
- its skip warning;
|
||||
- the `report` and `trees` summary lines;
|
||||
- every subcommand going through `runE`.
|
||||
|
||||
- `.golangci.yml` replaced with the current canonical copy, which uses
|
||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16)
|
||||
|
||||
- `.golangci.yml` was 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)
|
||||
|
||||
@@ -303,8 +392,8 @@ are known.
|
||||
- A test checks that `scan` refuses a database with another schema version
|
||||
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64)
|
||||
|
||||
- Four inaccurate comments in `cancel_test.go` corrected, and
|
||||
`walkCancelInFlightDirs` renamed to `walkCancelInFlightFiles` (2026-10-04,
|
||||
- Four inaccurate comments in `cancel_test.go` were corrected, and
|
||||
`walkCancelInFlightDirs` was renamed to `walkCancelInFlightFiles` (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/33)
|
||||
|
||||
- The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04,
|
||||
@@ -314,13 +403,13 @@ are known.
|
||||
`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)
|
||||
- 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. 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
|
||||
nothing changes owner. The build stage only compiles, as root, and the tests
|
||||
run as `nobody` in the `test` phase (2026-10-04,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/43)
|
||||
|
||||
@@ -335,13 +424,19 @@ are known.
|
||||
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,
|
||||
- Progress prints at once on a non-terminal and uses a real terminal test.
|
||||
Warnings print through a spinner instead of racing its redraw (2026-10-03,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/13)
|
||||
|
||||
- Symlink, socket, FIFO, device and `.zfs` operands are warned about and
|
||||
skipped, and the records beneath them are kept (2026-10-03,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/9)
|
||||
- These operands are warned about and skipped, and the records beneath them are
|
||||
kept:
|
||||
- symlinks;
|
||||
- sockets;
|
||||
- FIFOs;
|
||||
- device nodes;
|
||||
- `.zfs` directories.
|
||||
|
||||
(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,
|
||||
@@ -355,42 +450,47 @@ are known.
|
||||
WAL mode, so reading needs only read access (2026-10-03,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/8)
|
||||
|
||||
- Tabs, newlines, carriage returns and backslashes are escaped in report, trees
|
||||
and warning paths. The root directory's path is `/` (2026-10-03,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/7)
|
||||
- Four characters are escaped in `report`, `trees` and warning paths:
|
||||
- tab;
|
||||
- newline;
|
||||
- carriage return;
|
||||
- backslash.
|
||||
|
||||
- A plain `docker build .` stamps the git tag or short commit instead of `dev`.
|
||||
`.dockerignore` sends `.git` without `.git/config`. The build stage stamps the
|
||||
`VERSION` build argument, else `git describe --tags --always`. It 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` (2026-10-02, branch `next`,
|
||||
The root directory's path is `/` (2026-10-03,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/7)
|
||||
|
||||
- A plain `docker build .` stamps the `git` tag or short commit instead of
|
||||
`dev`. `.dockerignore` sends `.git` without `.git/config`. The build stage
|
||||
stamps the `VERSION` build argument, else `git describe --tags --always`. It
|
||||
fails if the context carries `.git` and the version is still empty, `dev` or
|
||||
`unknown`. The Gitea workflow checks out the full history (`fetch-depth: 0`),
|
||||
so it stamps the same value as `make build` (2026-10-02, branch `next`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/67)
|
||||
|
||||
- The 1 KiB end-window sampling was replaced with the hashes chosen by file
|
||||
size, and README "Duplicate detection" documented every size threshold. A file
|
||||
under 10 MiB was hashed in full, and its `head`, `tail` and `content` all held
|
||||
that hash. A larger file got only its 64 KiB `head` and `tail` in the hash
|
||||
phase. The content phase, after the update phase, read it for `content` only
|
||||
when its size, `head` and `tail` matched another record's, from this scan or
|
||||
an earlier one. `content` was the whole file below 50 MiB, and gigabyte-spaced
|
||||
1 MiB samples at or above. A file gone or changed since its record was written
|
||||
was never read. `report` and `trees` left out any record without a `content`
|
||||
hash. The `content` column is part of the version 1 schema. Superseded by
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/132 (2026-09-22, branch `next`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/61)
|
||||
phase. The content phase ran after the update phase. It read a file for
|
||||
`content` only when its size, `head` and `tail` matched another record's, from
|
||||
this scan or an earlier one. `content` was the whole file below 50 MiB, and
|
||||
gigabyte-spaced 1 MiB samples at or above. It never read a file gone or
|
||||
changed since its record was written. `report` and `trees` left out any record
|
||||
without a `content` hash. The `content` column is part of the version 1
|
||||
schema. Superseded by https://git.eeqj.de/sneak/sfdupes/issues/132
|
||||
(2026-09-22, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/61)
|
||||
|
||||
- The dead `files.dat` references removed from `Makefile`, `.gitignore` and
|
||||
- The dead `files.dat` references were removed from `Makefile`, `.gitignore` and
|
||||
`.dockerignore` (2026-09-21, branch `next`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/22)
|
||||
|
||||
- The lint-image pin comments and `FROM` form in `Dockerfile` and
|
||||
`Dockerfile.lint` fixed. Both pins became the policy
|
||||
`Dockerfile.lint` were fixed. 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. `Dockerfile.lint` and
|
||||
`script/verify-lint-image-pin`, which compared the two pins, are gone
|
||||
holds the only `golangci-lint` pin, in that form. `Dockerfile.lint` is gone,
|
||||
and so is `script/verify-lint-image-pin`, which compared the two pins
|
||||
(2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/25)
|
||||
|
||||
- All linting ran in Docker, via `Dockerfile.lint` and `script/lint`. Per the
|
||||
@@ -399,8 +499,8 @@ are known.
|
||||
`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`'s `lint` phase. `script/lint` builds that phase alone, and the
|
||||
build stage depends on it 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
|
||||
(2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/46)
|
||||
@@ -408,106 +508,144 @@ are known.
|
||||
- The Docker build stage's prerequisites were installed by running
|
||||
`script/bootstrap` instead of `apk add --no-cache make` inline.
|
||||
`script/verify-linter-pin` failed 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
|
||||
lint stage was the version that `script/bootstrap` pinned. Builds then took up
|
||||
to 5m14s from an empty cache, 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`
|
||||
`make` with `apk add --no-cache` and only compiles. The `lint` and `test`
|
||||
phases are the gates (2026-08-09, branch `dockerfile-bootstrap`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/42)
|
||||
|
||||
- The Docker layer cache is busted for the gate steps, so `script/cibuild` and
|
||||
`script/docker` cannot report a green they did not earn. 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
|
||||
- The Docker layer cache is defeated for the gate steps, so `script/cibuild` and
|
||||
`script/docker` cannot report a pass they did not earn. The `Dockerfile`
|
||||
copies the tree before its gates. On an unchanged tree Docker served them from
|
||||
cache, and the build exited 0 without running them. 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`
|
||||
passes a new `CACHEBUST` value instead. Every stage of the `Dockerfile`
|
||||
declares it before every step that checks or compiles the code. 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`
|
||||
(2026-08-09, branch `cibuild-cache-bust`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/32)
|
||||
|
||||
- `script/bootstrap` checked the installed `golangci-lint` version instead of
|
||||
only its presence. The version lived only in `GOLANGCI_LINT_VERSION`, with the
|
||||
`go install` module ref derived from it. Any installed version that was not
|
||||
the pin was reinstalled: older, newer, absent or unparseable. `go install`
|
||||
writes into `GOBIN` (or `GOPATH/bin`) while `make lint` ran the first
|
||||
`golangci-lint` on `PATH`, so bootstrap re-read the effective version after
|
||||
installing. On a mismatch it printed both paths and both versions and exited
|
||||
non-zero; it did not reorder `PATH` or delete anyone's binary. The `--version`
|
||||
call kept its stderr and was bounded by `timeout(1)` where that exists. `git`,
|
||||
`make` and `go` kept presence-only checks. `make check` and `make lint` were
|
||||
clean at v2.12.2, so v2.10.1 had not been hiding findings on `main`.
|
||||
Superseded by https://git.eeqj.de/sneak/sfdupes/issues/46: the linter is no
|
||||
longer installed on a host (2026-08-09, branch `bootstrap-version-check`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/24)
|
||||
`go install` module reference derived from it. Any installed version that was
|
||||
not the pin was reinstalled, whether:
|
||||
- older;
|
||||
- newer;
|
||||
- absent;
|
||||
- unparseable.
|
||||
|
||||
`go install` writes into `GOBIN`, or `GOPATH/bin`, while `make lint` ran the
|
||||
first `golangci-lint` on `PATH`. So bootstrap re-read the effective version
|
||||
after installing. On a mismatch it printed both paths and both versions and
|
||||
exited non-zero. It did not reorder `PATH` or delete anyone's binary. The
|
||||
`--version` call kept its stderr and was bounded by `timeout(1)` where that
|
||||
exists. `git`, `make` and `go` kept presence-only checks. The version check
|
||||
was verified by bootstrapping this host from v2.10.1 to v2.12.2, and again
|
||||
to a no-op. Stub runs of the script under `dash` covered:
|
||||
- a thirteen-input version-parse matrix;
|
||||
- a shadowed install, which must exit non-zero;
|
||||
- an install destination not on `PATH`;
|
||||
- `GOBIN` set;
|
||||
- a wedged binary, which must hit the timeout.
|
||||
|
||||
`make check` and `make lint` were clean at v2.12.2, so v2.10.1 had not been
|
||||
hiding findings on `main`. Superseded by
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/46, since the linter is no longer
|
||||
installed on a host (2026-08-09, branch `bootstrap-version-check`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/24)
|
||||
|
||||
- The hash worker pool unwinds on the error path. The pool is an owned,
|
||||
context-aware `hashPool`. Every blocking send in the feeder and the workers
|
||||
selects on `ctx.Done()`, and `jobs` is closed on every path out. `hashPhase`
|
||||
defers `pool.stop()`, which cancels and then drains `results` until the last
|
||||
goroutine has exited; draining is what frees a worker 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 the goroutine count returns to its
|
||||
pre-scan baseline. Other tests cancel a scan part-way through the walk and
|
||||
assert that it stops at the guard with every record intact. Direct tests of
|
||||
`sendEvent`, the walk workers, `dispatchDirs`, `feedHashJobs`, `hashWorker`
|
||||
and `hashPhase` cover the remaining cancellation branches of both pools
|
||||
(2026-08-09, branch `hash-pool-cleanup`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/6)
|
||||
goroutine has exited. Draining is what frees a worker parked on a send. `ctx`
|
||||
is passed from `cmd.Context()` as the first parameter, through:
|
||||
- `runScan`;
|
||||
- `syncScan`;
|
||||
- both worker pools;
|
||||
- the whole database layer.
|
||||
|
||||
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 that it
|
||||
never reached looks vanished to the update phase. That phase's own `BeginTx`
|
||||
also fails on the cancelled context before deleting anything. The guard is
|
||||
still the barrier once an interrupted scan may commit what it has. Tests
|
||||
drive `run(scan)` against a database whose insert trigger aborts. They
|
||||
assert that the scan fails instead of hanging, and that the goroutine count
|
||||
returns to its pre-scan baseline. Other tests cancel a scan part-way through
|
||||
the walk, deterministically, by counting the scan's own checks of
|
||||
`ctx.Done()`. They assert that it stops at the guard with every record
|
||||
intact. It then holds a partial size census and a still-populated record
|
||||
index. Direct tests cover the remaining cancellation branches of both pools.
|
||||
They test:
|
||||
- `sendEvent`;
|
||||
- the walk workers;
|
||||
- `dispatchDirs`;
|
||||
- `feedHashJobs`;
|
||||
- `hashWorker`;
|
||||
- `hashPhase`.
|
||||
|
||||
(2026-08-09, branch `hash-pool-cleanup`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/6)
|
||||
|
||||
- The database is closed on every fatal exit path. `fatalf` and its `os.Exit(1)`
|
||||
are gone, so the deferred `db.Close()`, and with it the SQLite WAL checkpoint,
|
||||
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` to exit 1 and cobra's own
|
||||
argument and flag errors to exit 2, so a runtime failure is never reported as
|
||||
a usage error or printed with the usage text. `main_test.go` drives the CLI
|
||||
in-process and asserts the exit codes from README "Error handling" and the
|
||||
stdout/stderr split. That includes a fatal error after the database is open
|
||||
leaving no `-wal` or `-shm` file behind, for `scan`, `report` or `trees`
|
||||
(2026-08-09, branch `db-close-on-fatal`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/4)
|
||||
are gone. So the deferred `db.Close()` runs when a subcommand fails, and with
|
||||
it the SQLite WAL checkpoint. These return errors instead:
|
||||
- `runScan`;
|
||||
- `runReport`;
|
||||
- `runTrees`;
|
||||
- `loadRecords`;
|
||||
- `resolveRoots`.
|
||||
|
||||
- `golangci-lint` updated to v2.12.2 with the canonical config, merged as
|
||||
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. So a runtime failure is never reported as a usage error,
|
||||
nor printed with the usage text. `main_test.go` drives the CLI in-process
|
||||
and asserts the exit codes from README "Error handling" and the
|
||||
stdout/stderr split. It also checks that a fatal error after the database is
|
||||
open leaves no `-wal` or `-shm` file behind, for `scan`, `report` or `trees`
|
||||
(2026-08-09, branch `db-close-on-fatal`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/4)
|
||||
|
||||
- `golangci-lint` was updated to v2.12.2 with the canonical config, merged as
|
||||
`38a01bd`. The pinned linter in the `Dockerfile` lint stage and
|
||||
`script/bootstrap` went from v2.12.1 to v2.12.2, and `.golangci.yml` was
|
||||
replaced with the canonical file. The linter settings (`lll`, `funlen`,
|
||||
`cyclop`, `dupl` thresholds) now live under `linters.settings`, per the v2
|
||||
schema, so they are applied. No new lint findings surfaced (2026-08-09, branch
|
||||
`golangci-v2.12.2`, https://git.eeqj.de/sneak/sfdupes/issues/3)
|
||||
replaced with the canonical file. Four thresholds now live under
|
||||
`linters.settings`, as the v2 schema requires, so they are applied:
|
||||
- `lll`;
|
||||
- `funlen`;
|
||||
- `cyclop`;
|
||||
- `dupl`.
|
||||
|
||||
- Makefile targets converted to scripts-to-rule-them-all `script/` entrypoints,
|
||||
like the other managed repos. All 12 entrypoints exist:
|
||||
No new lint findings surfaced (2026-08-09, branch `golangci-v2.12.2`,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/3)
|
||||
|
||||
- `Makefile` targets were converted to scripts-to-rule-them-all `script/`
|
||||
entrypoints, like the other managed repos. All 12 entrypoints exist:
|
||||
- `bootstrap`, `setup` and `projectname`;
|
||||
- `test`, `lint`, `fmt`, `fmt-check` and `check`;
|
||||
- `docker`, `cibuild`, `precommit` and `install-precommit`.
|
||||
|
||||
Every Makefile target is a thin shim over them (2026-07-26, commit
|
||||
Every `Makefile` target does nothing but call its script (2026-07-26, commit
|
||||
`3abeacf`, https://git.eeqj.de/sneak/sfdupes/issues/1)
|
||||
|
||||
- The binary is the default Make target: plain `make` builds `sfdupes`, where it
|
||||
used to run `check` plus `build`. `make build` remains as an alias
|
||||
- The binary is the default `make` target. Plain `make` builds `sfdupes`, where
|
||||
it used to run `check` plus `build`. `make build` remains as an alias
|
||||
(2026-07-24, branch `make-default-target`)
|
||||
|
||||
- Scan-wide phases, concurrent operands and batched updates. 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 and 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 of it, so scan-level atomicity is not
|
||||
required (2026-07-24, branch `scan-wide-phases`)
|
||||
- The phases became scan-wide, with concurrent operands and batched updates. All
|
||||
operands seed the shared walk pool, and every pass runs once over the whole
|
||||
scan. So the totals and ETAs cover the whole scan. The per-operand walk, hash
|
||||
and update cycles and their stderr announcements are gone. The update pass
|
||||
writes in batches, each in its own transaction. The filesystem is
|
||||
authoritative and the database follows it, so one scan's changes need not land
|
||||
all at once (2026-07-24, branch `scan-wide-phases`)
|
||||
|
||||
- The stat pass split back out of the walk. Phases are strictly sequential
|
||||
- The stat pass was split back out of the walk. Phases are strictly sequential
|
||||
again, per operand, with parallelism only inside each phase:
|
||||
- walk;
|
||||
- stat;
|
||||
@@ -515,77 +653,92 @@ are known.
|
||||
- update.
|
||||
|
||||
The walk enumerates paths with per-directory workers, and the stat pass
|
||||
lstats them with per-file workers, restoring the exact total and ETA of the
|
||||
stat bar (2026-07-24, branch `parallel-phases`)
|
||||
lstats them with per-file workers. That restores the exact total and ETA of
|
||||
the stat bar (2026-07-24, branch `parallel-phases`)
|
||||
|
||||
- Each operand is announced on stderr before its passes. With per-operand walk,
|
||||
hash and update cycles, a multi-operand run such as `scan /srv/*` showed pass
|
||||
totals that looked like the whole run's (2026-07-24, branch
|
||||
`scan-operand-progress`)
|
||||
|
||||
- Parallel walk. The walk pass was a single goroutine and took hours at about 20
|
||||
million files on a busy pool: 22 million files in 4 hours on a ZFS server. It
|
||||
is now a per-directory worker-pool traversal that records size and mtime
|
||||
during the walk, folding away the separate stat pass and halving metadata I/O.
|
||||
Each `PATH` operand commits in its own transaction, so an interrupted scan
|
||||
keeps completed operands (2026-07-24, branch `parallel-walk`)
|
||||
- The walk became parallel. The walk pass was a single goroutine and took hours
|
||||
at about 20 million files on a busy pool. One ZFS server took 4 hours for 22
|
||||
million files. It is now a walk by a pool of per-directory workers that
|
||||
records size and mtime as it goes. That folds away the separate stat pass and
|
||||
halves the metadata reads. Each `PATH` operand commits in its own transaction,
|
||||
so an interrupted scan keeps completed operands (2026-07-24, branch
|
||||
`parallel-walk`)
|
||||
|
||||
- Persistent scan database. `scan` 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 and size. It deletes the records of 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, with no positional
|
||||
arguments, instead of a scan stream. The database is at
|
||||
`/var/lib/sfdupes/db.sqlite`, overridable via `SFDUPES_DATABASE`. WAL
|
||||
- `scan` gained a persistent database. It maintains an SQLite database that
|
||||
survives between runs, with the absolute path as each record's key. The driver
|
||||
is `modernc.org/sqlite`, pure Go, so cgo stays disabled. A rescan hashes only
|
||||
new or changed files, by mtime and size. It deletes the records of files
|
||||
vanished from under the scanned operands and leaves records outside them
|
||||
untouched. So `scan` can run daily from `cron`. `report` and `trees` read the
|
||||
database, with no positional arguments, instead of a scan stream. The database
|
||||
is at `/var/lib/sfdupes/db.sqlite`, and `SFDUPES_DATABASE` overrides that. WAL
|
||||
journaling plus a single-transaction update keep a report run during a scan
|
||||
safe (2026-07-24, branch `persistent-database`)
|
||||
|
||||
- The `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`) added, `v0.0.1`
|
||||
tagged, and `main` plus tags pushed (2026-07-23)
|
||||
- The `origin` remote, `git@git.eeqj.de:sneak/sfdupes.git`, was added. `v0.0.1`
|
||||
was tagged, and `main` and the tags were pushed (2026-07-23)
|
||||
|
||||
- `scan` CLI rework. Required `PATH...` operands via cobra replace the `/srv`
|
||||
`-root` default. The new `-x`/`--one-file-system` flag (GNU convention) stops
|
||||
at filesystem boundaries, which are crossed by default (2026-07-23, branch
|
||||
`scan-required-paths`)
|
||||
- The `scan` command line was reworked. Required `PATH...` operands, parsed by
|
||||
cobra, replace the `/srv` `-root` default. The new `-x`, `--one-file-system`
|
||||
flag follows the GNU convention and stops at filesystem boundaries, which the
|
||||
walk crosses by default (2026-07-23, branch `scan-required-paths`)
|
||||
|
||||
- The repo was brought into compliance with `REPO_POLICIES.md` (the version of
|
||||
2026-07-06), the existing repo checklist and the Go styleguide. The audit of
|
||||
2026-07-22 found these gaps, and the branch closed them:
|
||||
- `.gitignore`, with OS, editor, Go and secrets patterns; the compiled
|
||||
`sfdupes` binary and `files.dat` had been sitting untracked in the tree;
|
||||
- `.gitignore`, with the standard patterns for secrets and for OS, editor
|
||||
and Go files. The compiled `sfdupes` binary and `files.dat` had been
|
||||
sitting untracked in the tree;
|
||||
- `.editorconfig`;
|
||||
- `LICENSE` and the README License section. MIT was assumed from house
|
||||
convention, for the owner to confirm;
|
||||
- `REPO_POLICIES.md` in the repo root;
|
||||
- the canonical `.golangci.yml`, with the 150 findings it raised fixed;
|
||||
- the `Makefile` targets `test`, `lint`, `fmt`, `fmt-check`, `docker` and
|
||||
`hooks`, and a `check` target that no longer depends on `build`;
|
||||
- the canonical `.golangci.yml`, with its 150 findings fixed, so that
|
||||
`make lint` is clean;
|
||||
- the `Makefile` targets the policy requires:
|
||||
- `test` and `lint`;
|
||||
- `fmt` and `fmt-check`;
|
||||
- `docker` and `hooks`.
|
||||
|
||||
`check` depended on `build`, which wrote the binary. `make check` must
|
||||
not modify files, so `check` no longer depends on `build`;
|
||||
|
||||
- a test suite, which the policy required with a 30-second timeout and the
|
||||
conditional `-v` rerun. It covers parsing, grouping, digests, suppression,
|
||||
hashing and the scan pipeline, at 64% coverage;
|
||||
- a multistage `Dockerfile` with hash-pinned images, a fail-fast lint stage
|
||||
and a build stage running `make check`, plus `.dockerignore`;
|
||||
conditional `-v` rerun. At 64% coverage, it covers:
|
||||
- parsing and grouping;
|
||||
- digests and suppression;
|
||||
- hashing and the scan pipeline;
|
||||
- a multistage `Dockerfile` and `.dockerignore`. The `Dockerfile` had:
|
||||
- hash-pinned images;
|
||||
- a lint stage that failed fast;
|
||||
- a build stage that ran `make check`;
|
||||
- `.gitea/workflows/check.yml`, running `docker build .` on push with the
|
||||
checkout action pinned by commit SHA;
|
||||
- the README sections the policy requires: a Description first line with the
|
||||
name, purpose, category, license and author, then Getting Started,
|
||||
Rationale, TODO, License and Author;
|
||||
- the README sections the policy requires. The first is a Description whose
|
||||
first line names the project, its purpose and category, its license and
|
||||
its author. Then come Getting Started and Rationale, and at the end TODO,
|
||||
License and Author;
|
||||
- the stale README non-goal "no git repository setup and no CI" removed;
|
||||
- the pre-commit hook installed.
|
||||
- the pre-commit hook, installed with `make hooks` once that target existed.
|
||||
|
||||
The code was already `gofmt`-clean, so no standalone formatting commit was
|
||||
needed (2026-07-23, branch `repo-policy-compliance`)
|
||||
|
||||
- `git init` with a README-only first commit; the code baseline committed on
|
||||
`main` (2026-07-22)
|
||||
- `git init` was run, with a README-only first commit. The code baseline was
|
||||
committed on `main` (2026-07-22)
|
||||
|
||||
- `scan`, `report` and `trees` subcommands implemented (pre-git history)
|
||||
- The `scan`, `report` and `trees` subcommands were implemented (pre-git
|
||||
history)
|
||||
|
||||
# Future Steps
|
||||
|
||||
- Possible later features, out of scope per README: full-content verification of
|
||||
candidates, and helper scripts for removal.
|
||||
- Possible later features, out of scope per README, are a byte-for-byte check of
|
||||
candidates and helper scripts for removal.
|
||||
|
||||
# Accepted Divergences
|
||||
|
||||
|
||||
@@ -47,14 +47,15 @@ const dbFilePerm = 0o644
|
||||
const lockFilePerm = 0o600
|
||||
|
||||
// createTableSQL is the schema applied to a fresh database. Paths are
|
||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
|
||||
// holds whole Unix seconds and mtime_nsec the nanoseconds within that
|
||||
// second. head, tail and content each hold the raw 32 bytes of a
|
||||
// SHA-256.
|
||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8.
|
||||
// disk_size holds the bytes the file takes on disk. mtime holds whole
|
||||
// Unix seconds and mtime_nsec the nanoseconds within that second. head,
|
||||
// tail and content each hold the raw 32 bytes of a SHA-256.
|
||||
const createTableSQL = `
|
||||
CREATE TABLE files (
|
||||
path BLOB PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
disk_size INTEGER NOT NULL,
|
||||
mtime INTEGER NOT NULL,
|
||||
mtime_nsec INTEGER NOT NULL,
|
||||
head BLOB NOT NULL,
|
||||
@@ -69,14 +70,59 @@ const createIndexSQL = `
|
||||
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
|
||||
// the same path.
|
||||
const upsertSQL = `
|
||||
INSERT INTO files (path, size, mtime, mtime_nsec, head, tail, content)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO files (
|
||||
path, size, disk_size, mtime, mtime_nsec, head, tail, content
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (path) DO UPDATE SET
|
||||
size = excluded.size, mtime = excluded.mtime,
|
||||
mtime_nsec = excluded.mtime_nsec,
|
||||
size = excluded.size, disk_size = excluded.disk_size,
|
||||
mtime = excluded.mtime, mtime_nsec = excluded.mtime_nsec,
|
||||
head = excluded.head, tail = excluded.tail,
|
||||
content = excluded.content
|
||||
`
|
||||
@@ -150,9 +196,14 @@ func createDatabaseDirectory(path string) error {
|
||||
// scanParams are the connection parameters for scan: read-write, with
|
||||
// WAL journaling and a busy timeout, so a report can run while a cron
|
||||
// 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)" +
|
||||
"&_pragma=journal_mode(WAL)" +
|
||||
"&_pragma=synchronous(NORMAL)"
|
||||
"&_pragma=synchronous(NORMAL)" +
|
||||
"&_pragma=temp_store(MEMORY)"
|
||||
|
||||
// reportParams are the connection parameters for report, trees and
|
||||
// info: read-only, with the same busy timeout. They set no journal
|
||||
@@ -426,14 +477,14 @@ func createSchema(ctx context.Context, db *sql.DB) error {
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
_, err = tx.ExecContext(ctx, createTableSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, createIndexSQL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
for _, stmt := range []string{
|
||||
createTableSQL, createIndexSQL, createTotalsSQL, insertTotalsSQL,
|
||||
totalsAfterInsertSQL, totalsAfterDeleteSQL, totalsAfterUpdateSQL,
|
||||
} {
|
||||
_, err = tx.ExecContext(ctx, stmt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create schema: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx,
|
||||
@@ -480,7 +531,7 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(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 {
|
||||
return fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
@@ -488,8 +539,8 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
||||
total(n)
|
||||
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
"SELECT path, size, mtime, mtime_nsec, head, tail, content "+
|
||||
"FROM files ORDER BY path")
|
||||
"SELECT path, size, disk_size, mtime, mtime_nsec, head, tail, "+
|
||||
"content FROM files ORDER BY path")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
@@ -503,7 +554,8 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
||||
r scanRec
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
|
||||
err = rows.Scan(&path, &r.size, &r.diskSize, &sec, &nsec,
|
||||
&head, &tail, &content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
@@ -538,10 +590,11 @@ ORDER BY size DESC, head DESC, tail DESC, content DESC
|
||||
`
|
||||
|
||||
// loadSignatureRows gives total the number of records, then streams
|
||||
// every record's signature and path to fn, without its mtime, in the
|
||||
// order of signatureRowsSQL. The count and the records are read in one
|
||||
// transaction, so they agree while a scan is committing. An error from
|
||||
// fn stops the reading and is returned as it is.
|
||||
// every record's signature and path to fn, without its mtime or its
|
||||
// disk_size, in the order of signatureRowsSQL. The count and the
|
||||
// records are read in one transaction, so they agree while a scan is
|
||||
// committing. An error from fn stops the reading and is returned as it
|
||||
// is.
|
||||
func loadSignatureRows(ctx context.Context, db *sql.DB,
|
||||
total func(n int64), fn func(r scanRec) error,
|
||||
) error {
|
||||
@@ -556,7 +609,7 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
|
||||
|
||||
var n int64
|
||||
|
||||
err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
||||
err = tx.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
@@ -622,7 +675,7 @@ func (r *scanRec) setHashes(head, tail, content []byte) error {
|
||||
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var n int64
|
||||
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM files").Scan(&n)
|
||||
err := db.QueryRowContext(ctx, recordCountSQL).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
@@ -630,12 +683,10 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// databaseCountsSQL counts the records and adds up their sizes, in one
|
||||
// pass over the table. A SUM over no rows is NULL, so on an empty table
|
||||
// the SUM falls back to 0.
|
||||
// databaseCountsSQL reads the number of records and the sizes of all
|
||||
// records added up from totals.
|
||||
const databaseCountsSQL = `
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM files
|
||||
SELECT records, size FROM totals
|
||||
`
|
||||
|
||||
// databaseCounts is what info reads from the database: its schema
|
||||
@@ -678,15 +729,15 @@ func readDatabaseCounts(ctx context.Context,
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// loadFileMeta streams every record's path, size and mtime to fn. Scan
|
||||
// change detection needs no hash values, and skipping the hash columns
|
||||
// keeps the scan's in-memory index small on multi-million-file
|
||||
// databases.
|
||||
// loadFileMeta streams every record's path, size, disk_size and mtime
|
||||
// to fn. Scan change detection needs no hash values, and skipping the
|
||||
// hash columns keeps the scan's in-memory index small on
|
||||
// multi-million-file databases.
|
||||
func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
fn func(path string, size int64, mtime time.Time),
|
||||
fn func(path string, m fileMeta),
|
||||
) error {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT path, size, mtime, mtime_nsec FROM files")
|
||||
"SELECT path, size, disk_size, mtime, mtime_nsec FROM files")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
@@ -695,16 +746,19 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
size, sec, nsec int64
|
||||
path []byte
|
||||
m fileMeta
|
||||
sec, nsec int64
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &size, &sec, &nsec)
|
||||
err = rows.Scan(&path, &m.size, &m.diskSize, &sec, &nsec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
fn(string(path), size, time.Unix(sec, nsec))
|
||||
m.mtime = time.Unix(sec, nsec)
|
||||
|
||||
fn(string(path), m)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
@@ -722,21 +776,21 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
// WAL small and let concurrent reports observe progress.
|
||||
const updateBatchSize = 10000
|
||||
|
||||
// applyChanges writes one scan's database changes — upserts for new and
|
||||
// changed files, deletes for vanished ones — in batched transactions.
|
||||
// Progress is rendered on prog (one increment per change).
|
||||
// applyChanges writes upserts for new and changed files and deletes for
|
||||
// vanished ones, in batched transactions. Progress is rendered on prog
|
||||
// (one increment per change).
|
||||
func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
|
||||
deletes []string, prog *progress,
|
||||
) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -745,10 +799,26 @@ func applyChanges(ctx context.Context, db *sql.DB, upserts []scanRec,
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBatch commits one batch of upserts and deletes in a single
|
||||
// transaction.
|
||||
// applyDiskSizes writes the disk_size of each of recs into its record,
|
||||
// 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,
|
||||
deletes []string, prog *progress,
|
||||
diskSizes []fileRec, deletes []string, prog *progress,
|
||||
) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -762,6 +832,11 @@ func applyBatch(ctx context.Context, db *sql.DB, upserts []scanRec,
|
||||
return err
|
||||
}
|
||||
|
||||
err = execDiskSizes(ctx, tx, diskSizes, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = execDeletes(ctx, tx, deletes, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -787,7 +862,7 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
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[:])
|
||||
if err != nil {
|
||||
@@ -800,6 +875,31 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
||||
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.
|
||||
func execDeletes(ctx context.Context, tx *sql.Tx, deletes []string,
|
||||
prog *progress,
|
||||
|
||||
+157
-10
@@ -535,17 +535,12 @@ func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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()
|
||||
|
||||
db := openTestDB(t)
|
||||
|
||||
rows, err := db.QueryContext(t.Context(),
|
||||
"EXPLAIN QUERY PLAN "+signatureRowsSQL)
|
||||
rows, err := db.QueryContext(t.Context(), "EXPLAIN QUERY PLAN "+query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -573,12 +568,164 @@ func TestSignatureRowsNeedNoSort(t *testing.T) {
|
||||
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"}
|
||||
if !slices.Equal(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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ import (
|
||||
func TestDefaultExcludes(t *testing.T) {
|
||||
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{
|
||||
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
||||
goosDarwin: {
|
||||
|
||||
@@ -26,11 +26,11 @@ func infoPhase(name string) string {
|
||||
// it. Any database problem, including a missing database, is fatal.
|
||||
//
|
||||
// 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,
|
||||
// with one query). Each display ends before the next phase starts, and
|
||||
// before an error is returned to be printed. The details are written
|
||||
// once the last display has ended, so no display is drawn while they
|
||||
// are written.
|
||||
// own on stderr: open (open the database) and count (read the number of
|
||||
// records and their total size from the totals table). Each display
|
||||
// ends before the next phase starts, and before an error is returned
|
||||
// to be printed. The details are written once the last display has
|
||||
// ended, so no display is drawn while they are written.
|
||||
func runInfo(ctx context.Context, stdout io.Writer) error {
|
||||
dbPath, source, err := databasePath()
|
||||
if err != nil {
|
||||
@@ -56,7 +56,7 @@ func runInfo(ctx context.Context, stdout io.Writer) error {
|
||||
|
||||
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{
|
||||
label: infoPhase("count"), total: -1,
|
||||
waitingFor: countingRecords,
|
||||
|
||||
+170
-36
@@ -180,9 +180,9 @@ func capture(t *testing.T, std **os.File) func() string {
|
||||
}
|
||||
|
||||
// brokenDatabase writes a database that opens cleanly and passes the
|
||||
// schema-version check but has no files table, so the first query
|
||||
// fails with the database already open: a fatal error on a path that
|
||||
// owns an open database. It closes the database the way scan does.
|
||||
// schema-version check but has no tables, so the first query fails
|
||||
// with the database already open: a fatal error on a path that owns an
|
||||
// open database. It closes the database the way scan does.
|
||||
func brokenDatabase(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -265,8 +265,9 @@ func TestRunFatalAfterOpenClosesDatabase(t *testing.T) {
|
||||
assertFatalOutput(t, stderr.String(), stdout())
|
||||
|
||||
// Proof that the failure happened after the open: only a
|
||||
// query against the opened database can report this.
|
||||
if !strings.Contains(stderr.String(), "no such table: files") {
|
||||
// query against the opened database can report this. Each
|
||||
// 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 "+
|
||||
"query on the open database", stderr.String())
|
||||
}
|
||||
@@ -393,7 +394,7 @@ func TestRunNamesRelativeDatabase(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
|
||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||
// use that file, relative or absolute, and its first line must name
|
||||
@@ -648,7 +649,7 @@ func TestRunUsageErrors(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.
|
||||
for _, workers := range []string{"0", "-1"} {
|
||||
t.Run(workers, func(t *testing.T) {
|
||||
@@ -686,8 +687,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
func TestRunHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Subcommands: help goes to stderr, exits 0, and leaves
|
||||
// stdout empty.
|
||||
// README §Settings: help goes to stderr, exits 0, and leaves stdout
|
||||
// empty.
|
||||
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
||||
|
||||
for _, args := range cases {
|
||||
@@ -713,8 +714,8 @@ func TestRunHelp(t *testing.T) {
|
||||
func TestRunVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Subcommands: the version is one line on stdout, with
|
||||
// nothing on stderr, and exits 0.
|
||||
// README §Settings: the version is one line on stdout, with nothing
|
||||
// on stderr, and exits 0.
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
|
||||
for _, arg := range []string{"--version", "-v"} {
|
||||
@@ -773,8 +774,8 @@ func TestBuildVersion(t *testing.T) {
|
||||
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Error handling: a stdout write failure exits 1, reported
|
||||
// in one line on stderr.
|
||||
// README §Error handling: a failed write to stdout is a fatal error,
|
||||
// exit 1, reported on stderr starting with "sfdupes:".
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := run([]string{"--version"}, failingWriter{}, &stderr)
|
||||
@@ -817,6 +818,23 @@ func scanFixture(t *testing.T) []string {
|
||||
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
|
||||
// nothing on stdout, and returns everything it printed to stderr.
|
||||
func scanOK(t *testing.T, operands ...string) string {
|
||||
@@ -928,24 +946,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
zfs := filepath.Join(t.TempDir(), ".zfs")
|
||||
snapshot := filepath.Join(zfs, "snapshot", "hourly")
|
||||
f := writeFile(t, snapshot, "f", pattern(1, 10))
|
||||
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
|
||||
|
||||
// An operand beneath a .zfs directory is walked, because it is not
|
||||
// itself named .zfs.
|
||||
scanOK(t, snapshot)
|
||||
// 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 scans operand alone and checks that it is skipped
|
||||
// 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
|
||||
// in the database at dbPath.
|
||||
// record for kept, stored beneath operand before this scan, still in the
|
||||
// database at dbPath.
|
||||
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||
kept string,
|
||||
) {
|
||||
@@ -974,6 +989,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||
recordByPath(t, dbRecords(t, db), kept)
|
||||
}
|
||||
|
||||
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
|
||||
// directory, first alone and then under another operand. Each time it
|
||||
// is warned about and counted as skipped, and nothing in it is walked.
|
||||
// The other operand is scanned as usual.
|
||||
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
dir := t.TempDir()
|
||||
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
|
||||
writeFile(t, hourly, "f", pattern(1, 10))
|
||||
f := writeFile(t, dir, "f", pattern(2, 10))
|
||||
|
||||
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
|
||||
|
||||
for _, tc := range []struct {
|
||||
operands []string
|
||||
summary string
|
||||
}{
|
||||
{
|
||||
[]string{hourly},
|
||||
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n",
|
||||
},
|
||||
{
|
||||
[]string{dir, hourly},
|
||||
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n",
|
||||
},
|
||||
} {
|
||||
stderr := scanOK(t, tc.operands...)
|
||||
if !strings.Contains(stderr, warning) ||
|
||||
!strings.Contains(stderr, tc.summary) {
|
||||
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
|
||||
stderr, warning, tc.summary)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := openDB(path, reportParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
|
||||
t.Errorf("records = %q, want %q", got, []string{f})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
|
||||
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
|
||||
// by the path given, counted as skipped, and not walked.
|
||||
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
|
||||
t.Setenv(databaseEnv, testDBPath(t))
|
||||
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
||||
|
||||
snaps := filepath.Join(dir, "snaps")
|
||||
|
||||
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
daily := filepath.Join(snaps, "daily")
|
||||
stderr := scanOK(t, daily)
|
||||
|
||||
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
|
||||
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n"
|
||||
|
||||
if !strings.Contains(stderr, warning) ||
|
||||
!strings.Contains(stderr, summary) {
|
||||
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
|
||||
// a symlink to a directory outside any .zfs directory. The operand is
|
||||
// scanned as usual, and its file is recorded by the path given.
|
||||
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
|
||||
|
||||
data := filepath.Join(dir, "data")
|
||||
|
||||
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
daily := filepath.Join(data, "daily")
|
||||
stderr := scanOK(t, daily)
|
||||
|
||||
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 0 skipped\n"
|
||||
if !strings.Contains(stderr, summary) {
|
||||
t.Errorf("stderr = %q, want %q", stderr, summary)
|
||||
}
|
||||
|
||||
db, err := openDB(path, reportParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
|
||||
}
|
||||
|
||||
func TestRunReportSucceeds(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
@@ -1028,8 +1158,9 @@ func TestRunTreesSucceeds(t *testing.T) {
|
||||
|
||||
// The two directories holding the duplicate pair are duplicate
|
||||
// trees of each other.
|
||||
want := "first\tdupe\tfiles\tsize\n" +
|
||||
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(storedDiskSize(t, path, dupes[1]), 10) + "\n"
|
||||
if got := stdout.String(); got != want {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
@@ -1063,9 +1194,10 @@ func TestRunReportsNeedOnlyReadAccess(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
cmdReport: "first\tdupe\tsize\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]) +
|
||||
"\t1\t300\n",
|
||||
"\t1\t300\t" +
|
||||
strconv.FormatInt(storedDiskSize(t, path, dupes[1]), 10) + "\n",
|
||||
}
|
||||
|
||||
for name, want := range cases {
|
||||
@@ -1100,8 +1232,11 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
||||
t.Errorf("report stdout = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
want = "first\tdupe\tfiles\tsize\n" +
|
||||
filepath.Dir(dupes[0]) + "\t" + filepath.Dir(dupes[1]) + "\t1\t300\n"
|
||||
diskSize := storedDiskSize(t, filepath.Join(dir, name), dupes[1])
|
||||
|
||||
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 {
|
||||
t.Errorf("trees stdout = %q, want %q", got, want)
|
||||
}
|
||||
@@ -1122,7 +1257,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
||||
}
|
||||
|
||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
||||
// 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
|
||||
// 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
|
||||
@@ -1167,9 +1302,8 @@ func holdScanLock(t *testing.T, path string) {
|
||||
}
|
||||
|
||||
func TestRunSecondScanFails(t *testing.T) {
|
||||
// README §Database: while one scan holds the lock, a second scan
|
||||
// fails at once, naming the lock file, without opening the
|
||||
// database.
|
||||
// README §Database: a second scan that finds the lock held fails
|
||||
// at once, naming the lock file, without opening the database.
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
@@ -1228,7 +1362,7 @@ func assertSecondScanFails(t *testing.T, path string) {
|
||||
}
|
||||
|
||||
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
|
||||
// another fails, naming the lock file beside the database's real
|
||||
// path.
|
||||
@@ -1333,8 +1467,8 @@ func TestRunReportsDuringScan(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunStdoutClosedIsFatal(t *testing.T) {
|
||||
// README §Error handling: a stdout write failure exits 1, reported
|
||||
// in one line on stderr.
|
||||
// README §Error handling: a failed write to stdout is a fatal error,
|
||||
// exit 1, reported on stderr starting with "sfdupes:".
|
||||
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv(databaseEnv, testDBPath(t))
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ const fallbackWidth = 80
|
||||
const spinnerMargin = 5
|
||||
|
||||
// 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"
|
||||
|
||||
// openPhase is the name of the phase in which report, trees and info
|
||||
|
||||
@@ -21,14 +21,16 @@ const minGroupSize = 2
|
||||
|
||||
// scanRec is one file record from the database. The signature (size,
|
||||
// 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 {
|
||||
size int64
|
||||
mtime time.Time
|
||||
head [sha256.Size]byte
|
||||
tail [sha256.Size]byte
|
||||
content [sha256.Size]byte
|
||||
path string
|
||||
size int64
|
||||
diskSize int64
|
||||
mtime time.Time
|
||||
head [sha256.Size]byte
|
||||
tail [sha256.Size]byte
|
||||
content [sha256.Size]byte
|
||||
path string
|
||||
}
|
||||
|
||||
// reportPhase names one phase of report with its place among all of
|
||||
@@ -45,11 +47,11 @@ func reportPhase(name string) string {
|
||||
// problem, including a missing database, is fatal.
|
||||
//
|
||||
// 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,
|
||||
// then read them in signature order, writing each size's duplicate
|
||||
// groups to stdout once that size is read; see readReport). Each
|
||||
// display ends before the next phase starts, and before an error is
|
||||
// returned to be printed.
|
||||
// own on stderr: open (open the database) and read (read the number of
|
||||
// records from the totals table, then read the records in signature
|
||||
// order, writing each size's duplicate groups to stdout once that size
|
||||
// is read; see readReport). Each display ends before the next phase
|
||||
// starts, and before an error is returned to be printed.
|
||||
func runReport(ctx context.Context, stdout io.Writer) error {
|
||||
dbPath, source, err := databasePath()
|
||||
if err != nil {
|
||||
|
||||
+2
-2
@@ -38,8 +38,8 @@ func awkwardPairRecs() []scanRec {
|
||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||
|
||||
return []scanRec{
|
||||
{size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
|
||||
{size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
|
||||
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: "/d/A/f"},
|
||||
{size: 5, diskSize: 4096, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,23 +52,26 @@ const workQueueDepth = 1024
|
||||
// has already printed its line, so run prints nothing more.
|
||||
var errInterrupted = errors.New("scan interrupted")
|
||||
|
||||
// fileRec carries one statted file between the scan phases. dev and
|
||||
// ino identify the underlying inode so hard-linked paths can share
|
||||
// one read; both are zero when the platform exposes no inode.
|
||||
// fileRec carries one statted file between the scan phases. diskSize is
|
||||
// the bytes the file takes on disk. dev and ino identify the underlying
|
||||
// inode so hard-linked paths can share one read; both are zero when the
|
||||
// platform exposes no inode.
|
||||
type fileRec struct {
|
||||
path string
|
||||
size int64
|
||||
mtime time.Time
|
||||
dev uint64
|
||||
ino uint64
|
||||
path string
|
||||
size int64
|
||||
diskSize int64
|
||||
mtime time.Time
|
||||
dev uint64
|
||||
ino uint64
|
||||
}
|
||||
|
||||
// fileMeta is the in-memory index entry for one existing database
|
||||
// record: just enough for change detection. Hashes stay on disk; at
|
||||
// record: what the walk compares the file with. Hashes stay on disk; at
|
||||
// tens of millions of records they would dominate the scan's memory.
|
||||
type fileMeta struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
size int64
|
||||
diskSize int64
|
||||
mtime time.Time
|
||||
}
|
||||
|
||||
// scanUnit is what every scan phase counts, as its progress display
|
||||
@@ -339,11 +342,12 @@ type scanStats struct {
|
||||
// whatever remains after the walk and hash phases is deleted by the
|
||||
// update phase.
|
||||
type scanState struct {
|
||||
db *sql.DB
|
||||
existing map[string]fileMeta
|
||||
toHash []fileRec // new and changed files: must be read
|
||||
batch []scanRec
|
||||
st scanStats
|
||||
db *sql.DB
|
||||
existing map[string]fileMeta
|
||||
toHash []fileRec // new and changed files: must be read
|
||||
diskSizes []fileRec // unchanged files whose disk_size changed
|
||||
batch []scanRec
|
||||
st scanStats
|
||||
}
|
||||
|
||||
// syncScan synchronizes the database with the filesystem under roots;
|
||||
@@ -383,7 +387,8 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||
) error {
|
||||
// Types and filesystems are checked before pruning so that an
|
||||
// operand under a dropped one is still scanned, not dropped as lying
|
||||
// under it.
|
||||
// under it, and so that an operand inside a .zfs directory gets its
|
||||
// warning even when it lies under another operand.
|
||||
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
||||
deviceOfPath)
|
||||
|
||||
@@ -411,15 +416,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||
}
|
||||
|
||||
// walkableRoots returns the operands the walk can start from: regular
|
||||
// files, and directories not named .zfs. Every other operand is warned
|
||||
// about, counted as skipped, and dropped. A dropped operand is no
|
||||
// longer a root, so the records stored beneath it are treated as if it
|
||||
// had not been given: those under no other root count as outside the
|
||||
// roots and are not deleted as unverified. An operand on a filesystem
|
||||
// in rules that is not persistent is dropped the same way, with a line
|
||||
// saying so that is not a warning, and is not counted as skipped. An
|
||||
// operand that fails lstat here is kept, and the walk warns about it
|
||||
// unless it no longer exists.
|
||||
// files, and directories not named .zfs, none of them inside a
|
||||
// directory named .zfs. Every other operand is warned about, counted as
|
||||
// skipped, and dropped. A dropped operand is no longer a root, so the
|
||||
// records stored beneath it are treated as if it had not been given:
|
||||
// those under no other root count as outside the roots and are not
|
||||
// deleted as unverified. An operand on a filesystem in rules that is not
|
||||
// persistent is dropped the same way, with a line saying so that is not
|
||||
// a warning, and is not counted as skipped. An operand that fails lstat
|
||||
// here is kept, and the walk warns about it unless it no longer exists.
|
||||
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||
kept := make([]string, 0, len(roots))
|
||||
|
||||
@@ -454,8 +459,8 @@ func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||
// included, so its display counts against the number of records.
|
||||
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||
// Indexing tens of millions of records takes real time; without a
|
||||
// display the scan looks hung before the walk begins. Counting them
|
||||
// first gives the display its total.
|
||||
// display the scan looks hung before the walk begins. The number of
|
||||
// records, read first, gives the display its total.
|
||||
prog := newProgress(progressParams{
|
||||
label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||
waitingFor: countingRecords, stalledOn: "db",
|
||||
@@ -471,24 +476,24 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
||||
|
||||
s.existing = make(map[string]fileMeta)
|
||||
|
||||
return loadFileMeta(ctx, s.db,
|
||||
func(path string, size int64, mtime time.Time) {
|
||||
prog.increment()
|
||||
return loadFileMeta(ctx, s.db, func(path string, m fileMeta) {
|
||||
prog.increment()
|
||||
|
||||
if underAnyRoot(path, roots) {
|
||||
s.existing[path] = fileMeta{size: size, mtime: mtime}
|
||||
}
|
||||
})
|
||||
if underAnyRoot(path, roots) {
|
||||
s.existing[path] = m
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// walkPhase drains the walk and queues every new or changed file for
|
||||
// the hash phase. An unchanged file needs nothing more, and its record
|
||||
// counts as verified. A record at or below a path the walk left out,
|
||||
// because -x stopped at it, it is a default exclude, or it is not on a
|
||||
// persistent filesystem, counts as outside the roots: it is not
|
||||
// deleted. That does not hold below a root at or inside that path,
|
||||
// since that root's own walk verifies the records below it; nor is a
|
||||
// line printed for such a root.
|
||||
// the hash phase. An unchanged file is not read, and its record counts
|
||||
// as verified. When its disk_size differs from the record's, it is
|
||||
// queued for the update phase, which writes the new disk_size. A record
|
||||
// at or below a path the walk left out, because -x stopped at it, it is
|
||||
// a default exclude, or it is not on a persistent filesystem, counts as
|
||||
// outside the roots: it is not deleted. That does not hold below a root
|
||||
// at or inside that path, since that root's own walk verifies the
|
||||
// records below it; nor is a line printed for such a root.
|
||||
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
||||
// The records the last scan left under the roots estimate how many
|
||||
// files the walk will find. A first scan has none, and no estimate.
|
||||
@@ -546,6 +551,13 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
|
||||
delete(s.existing, ev.rec.path)
|
||||
|
||||
s.st.unchanged++
|
||||
|
||||
// The space a file takes on disk can change while its size and
|
||||
// mtime stay the same. ZFS, for one, counts a new file's blocks
|
||||
// only seconds after the write.
|
||||
if old.diskSize != ev.rec.diskSize {
|
||||
s.diskSizes = append(s.diskSizes, ev.rec)
|
||||
}
|
||||
}
|
||||
|
||||
prog.finish()
|
||||
@@ -694,12 +706,13 @@ func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
|
||||
s.resolve(rec.path)
|
||||
|
||||
s.batch = append(s.batch, scanRec{
|
||||
size: rec.size,
|
||||
mtime: rec.mtime,
|
||||
head: r.head,
|
||||
tail: r.tail,
|
||||
content: r.content,
|
||||
path: rec.path,
|
||||
size: rec.size,
|
||||
diskSize: rec.diskSize,
|
||||
mtime: rec.mtime,
|
||||
head: r.head,
|
||||
tail: r.tail,
|
||||
content: r.content,
|
||||
path: rec.path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,7 +728,7 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := applyBatch(ctx, s.db, s.batch, nil, nil)
|
||||
err := applyBatch(ctx, s.db, s.batch, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -726,9 +739,10 @@ func (s *scanState) commitFullBatch(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// updatePhase writes the scan's tail under one progress display: the
|
||||
// final partial batch of hashed records, and deletions for every record
|
||||
// the scan did not verify (vanished files, plus paths that failed to
|
||||
// stat or hash).
|
||||
// final partial batch of hashed records, the new disk_size of every
|
||||
// unchanged file whose disk_size changed, and deletions for every
|
||||
// record the scan did not verify (vanished files, plus paths that
|
||||
// failed to stat or hash).
|
||||
func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
deletes := make([]string, 0, len(s.existing))
|
||||
for path := range s.existing {
|
||||
@@ -740,7 +754,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
|
||||
s.st.removed = len(deletes)
|
||||
|
||||
total := len(s.batch) + len(deletes)
|
||||
total := len(s.batch) + len(s.diskSizes) + len(deletes)
|
||||
prog := newProgress(progressParams{
|
||||
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
||||
stalledOn: "db",
|
||||
@@ -755,6 +769,11 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
||||
|
||||
s.batch = nil
|
||||
|
||||
err = applyDiskSizes(ctx, s.db, s.diskSizes, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return applyChanges(ctx, s.db, nil, deletes, prog)
|
||||
}
|
||||
|
||||
@@ -874,9 +893,24 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
|
||||
|
||||
// operandWarning returns the one-line warning for an operand the walk
|
||||
// does not start from, naming the path and what it is, or "" for one it
|
||||
// does: a regular file, or a directory not named .zfs. Symlinks are
|
||||
// never followed, including as operands.
|
||||
// does: a regular file, or a directory not named .zfs, that is not
|
||||
// inside a directory named .zfs, neither as given nor with its symlinks
|
||||
// resolved. Symlinks are never followed, including as operands.
|
||||
func operandWarning(root string, fi fs.FileInfo) string {
|
||||
// The walk never enters a .zfs directory, so it starts from nothing
|
||||
// inside one either, not even through a symlink. An operand whose
|
||||
// symlinks cannot be resolved is checked as given.
|
||||
resolved, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
resolved = root
|
||||
}
|
||||
|
||||
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
|
||||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
|
||||
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
|
||||
root)
|
||||
}
|
||||
|
||||
var kind string
|
||||
|
||||
switch mode := fi.Mode(); {
|
||||
@@ -945,11 +979,12 @@ func seedRoot(ctx context.Context, root string, rules walkRules,
|
||||
|
||||
dev, ino := inodeOfInfo(fi)
|
||||
rec := fileRec{
|
||||
path: root,
|
||||
size: fi.Size(),
|
||||
mtime: fi.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
path: root,
|
||||
size: fi.Size(),
|
||||
diskSize: diskSizeOfInfo(fi),
|
||||
mtime: fi.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}
|
||||
|
||||
if !isDatabaseFile(rec, rules) {
|
||||
@@ -1129,11 +1164,12 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
||||
|
||||
dev, ino := inodeOfInfo(info)
|
||||
rec := fileRec{
|
||||
path: p,
|
||||
size: info.Size(),
|
||||
mtime: info.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
path: p,
|
||||
size: info.Size(),
|
||||
diskSize: diskSizeOfInfo(info),
|
||||
mtime: info.ModTime(),
|
||||
dev: dev,
|
||||
ino: ino,
|
||||
}
|
||||
|
||||
if !isDatabaseFile(rec, rules) {
|
||||
@@ -1225,6 +1261,23 @@ func inodeOfInfo(fi fs.FileInfo) (uint64, uint64) {
|
||||
return statDev(st), st.Ino
|
||||
}
|
||||
|
||||
// statBlockSize is the unit of st_blocks in an lstat result on Linux and
|
||||
// macOS, whatever the filesystem's own block size.
|
||||
const statBlockSize = 512
|
||||
|
||||
// diskSizeOfInfo returns the bytes a file takes on disk: its allocated
|
||||
// blocks, st_blocks × 512. On a compressed or sparse file it is less
|
||||
// than the file's size. It is 0 when the platform exposes no block
|
||||
// count.
|
||||
func diskSizeOfInfo(fi fs.FileInfo) int64 {
|
||||
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
return st.Blocks * statBlockSize
|
||||
}
|
||||
|
||||
// hashResult carries the hashes computed for one inode run (or the
|
||||
// error that prevented computing them) from the pool's workers to the
|
||||
// hash phase. Its run leaves out the paths found removed.
|
||||
|
||||
+183
-5
@@ -42,6 +42,30 @@ func writeFile(t *testing.T, dir, name string, data []byte) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// diskSizeOf returns the bytes the file at path takes on disk, from its
|
||||
// own lstat: st_blocks counts 512-byte units. ZFS counts a write's
|
||||
// blocks only when it commits the write, up to seconds later, and one
|
||||
// commit can take the file through several growing sizes. So this value
|
||||
// can change at any moment. Neither fsync nor unix.Sync hastens that,
|
||||
// but a unix.Sync call often takes long enough that the blocks are
|
||||
// counted when it returns. A test that needs the value to change waits
|
||||
// for the change. A test that compares it with a scan's disk_size reads
|
||||
// it before and after the scan. The rewrite test rescans until the two
|
||||
// reads match, then compares exactly. The other two tests fail only on
|
||||
// a disk_size outside the two reads.
|
||||
func diskSizeOf(t *testing.T, path string) int64 {
|
||||
t.Helper()
|
||||
|
||||
var st syscall.Stat_t
|
||||
|
||||
err := syscall.Lstat(path, &st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return st.Blocks * 512
|
||||
}
|
||||
|
||||
// pattern returns n bytes of deterministic content seeded by tag.
|
||||
func pattern(tag byte, n int) []byte {
|
||||
data := make([]byte, n)
|
||||
@@ -1064,8 +1088,9 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// buildSmokeTree recreates the README smoke-test filesystem layout
|
||||
// with deterministic content and returns the tree root.
|
||||
// buildSmokeTree recreates the tree of the test under README
|
||||
// "Definition of done" with deterministic content and returns the tree
|
||||
// root.
|
||||
func buildSmokeTree(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -1228,9 +1253,9 @@ func TestScanPipeline(t *testing.T) {
|
||||
// TestScanProgressNamesEachPhase checks the plain progress lines of a
|
||||
// first scan, and of a rescan of one operand holding two of the
|
||||
// database's records. Each phase's first line names it with its place
|
||||
// among the four: load counts the records before reading them, and the
|
||||
// walk estimates its total from the records under its operand, or says
|
||||
// its time remaining is unknown when there are none.
|
||||
// among the four: load gets the number of records before reading them,
|
||||
// and the walk estimates its total from the records under its operand,
|
||||
// or says its time remaining is unknown when there are none.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestScanProgressNamesEachPhase(t *testing.T) {
|
||||
@@ -1416,6 +1441,159 @@ func TestScanStoresRawHashes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanStoresDiskSize scans a sparse file, which takes less space on
|
||||
// disk than its length, once through its directory and once as the
|
||||
// operand itself. Each scan stores the disk_size of the file's lstat.
|
||||
func TestScanStoresDiskSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const length = 1024 * 1024
|
||||
|
||||
p := sparseFile(t, t.TempDir(), "sparse", length)
|
||||
pokeAt(t, p, 0, []byte("x"))
|
||||
|
||||
unix.Sync() // see diskSizeOf
|
||||
|
||||
size := diskSizeOf(t, p)
|
||||
if size == 0 || size >= length {
|
||||
t.Fatalf("the file takes %d bytes on disk, want more than none "+
|
||||
"and fewer than its length %d", size, length)
|
||||
}
|
||||
|
||||
for _, operand := range []string{filepath.Dir(p), p} {
|
||||
db := openTestDB(t)
|
||||
|
||||
// ZFS may count the blocks during the scan; see diskSizeOf.
|
||||
before := diskSizeOf(t, p)
|
||||
|
||||
syncTree(t, db, operand)
|
||||
|
||||
after := diskSizeOf(t, p)
|
||||
|
||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
||||
if got < before || got > after {
|
||||
t.Errorf("scan of %s: disk_size = %d, want %d to %d",
|
||||
operand, got, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncScanRewriteUpdatesDiskSize writes into the hole of a scanned
|
||||
// sparse file and moves its mtime on. The rescan hashes the file again
|
||||
// and rewrites its record with the new disk_size.
|
||||
func TestSyncScanRewriteUpdatesDiskSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
p := sparseFile(t, dir, "sparse", 1024*1024)
|
||||
pokeAt(t, p, 0, []byte("x"))
|
||||
|
||||
syncTree(t, db, dir)
|
||||
|
||||
recorded := recordByPath(t, dbRecords(t, db), p).diskSize
|
||||
|
||||
pokeAt(t, p, 512*1024, pattern(7, 64*1024))
|
||||
|
||||
// The write may leave the mtime the scan recorded; a later one makes
|
||||
// the rescan read the file again.
|
||||
later := time.Now().Add(time.Hour)
|
||||
|
||||
err := os.Chtimes(p, later, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Wait for ZFS to count the write; see diskSizeOf.
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for diskSizeOf(t, p) == recorded {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the file still takes %d bytes on disk 30 seconds "+
|
||||
"after the write into its hole", recorded)
|
||||
}
|
||||
|
||||
unix.Sync()
|
||||
}
|
||||
|
||||
// ZFS may still be counting, so the size may change during a rescan.
|
||||
// Only a rescan with the same size on both sides of it can be compared
|
||||
// exactly. A rescan that keeps the old size still fails, because the
|
||||
// wait made before differ from it.
|
||||
for {
|
||||
before := diskSizeOf(t, p)
|
||||
|
||||
st := syncTree(t, db, dir)
|
||||
|
||||
after := diskSizeOf(t, p)
|
||||
|
||||
if st != (scanStats{walked: 1, updated: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 updated", st)
|
||||
}
|
||||
|
||||
if before == after {
|
||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
||||
if got != after {
|
||||
t.Errorf("disk_size = %d, want %d", got, after)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the size on disk was still changing 30 seconds after "+
|
||||
"the write into its hole: %d, then %d", before, after)
|
||||
}
|
||||
|
||||
// A later mtime makes the next rescan read the file again.
|
||||
later = later.Add(time.Second)
|
||||
|
||||
err = os.Chtimes(p, later, later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncScanUnchangedUpdatesDiskSize gives a file's record a
|
||||
// disk_size the file does not have, as when ZFS had not yet counted the
|
||||
// file's blocks at the last scan. The rescan writes the file's own
|
||||
// disk_size into the record without reading the file again.
|
||||
func TestSyncScanUnchangedUpdatesDiskSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
db := openTestDB(t)
|
||||
p := writeFile(t, dir, "a.bin", pattern(1, 500))
|
||||
|
||||
syncTree(t, db, dir)
|
||||
|
||||
// lstat counts 512-byte units, so no file takes 1 byte on disk.
|
||||
_, err := db.ExecContext(t.Context(),
|
||||
"UPDATE files SET disk_size = 1 WHERE path = ?", []byte(p))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
unix.Sync() // see diskSizeOf
|
||||
|
||||
// ZFS may count the blocks during the rescan; see diskSizeOf.
|
||||
before := diskSizeOf(t, p)
|
||||
|
||||
// A file read again would count as updated.
|
||||
st := syncTree(t, db, dir)
|
||||
|
||||
after := diskSizeOf(t, p)
|
||||
|
||||
if st != (scanStats{walked: 1, unchanged: 1}) {
|
||||
t.Fatalf("rescan stats = %+v, want 1 unchanged", st)
|
||||
}
|
||||
|
||||
got := recordByPath(t, dbRecords(t, db), p).diskSize
|
||||
if got < before || got > after {
|
||||
t.Errorf("disk_size = %d, want %d to %d", got, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncScanSameSecondRewrite rewrites a file in place at the same
|
||||
// size with an mtime later in the same second as the recorded one: the
|
||||
// next scan must notice the change and re-hash the file. The operand is
|
||||
|
||||
@@ -22,6 +22,10 @@ type treeNode struct {
|
||||
digest [sha256.Size]byte
|
||||
fileCount 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,
|
||||
@@ -40,11 +44,11 @@ func treesPhase(name string) string {
|
||||
// database problem, including a missing database, is fatal.
|
||||
//
|
||||
// The work runs in four phases, each under a progress display of its
|
||||
// own on stderr: open (open the database), read (count the records,
|
||||
// then read them into the hierarchy), group (group the directories by
|
||||
// digest) and write (write the groups to stdout). Each display ends
|
||||
// before the next phase starts, and before an error is returned to be
|
||||
// printed.
|
||||
// own on stderr: open (open the database), read (read the number of
|
||||
// records from the totals table, then read the records into the
|
||||
// hierarchy), group (group the directories by digest) and write (write
|
||||
// the groups to stdout). Each display ends before the next phase
|
||||
// starts, and before an error is returned to be printed.
|
||||
func runTrees(ctx context.Context, stdout io.Writer) error {
|
||||
dbPath, source, err := databasePath()
|
||||
if err != nil {
|
||||
@@ -139,15 +143,16 @@ func readTree(ctx context.Context, db *sql.DB,
|
||||
|
||||
// writeTreeGroups writes dupes to stdout as TSV, a header line and then
|
||||
// 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
|
||||
// prog is drawn on, so the lines go through prog's writer. It returns
|
||||
// the number of rows (dupe trees) and the bytes they could reclaim.
|
||||
// group on prog once its rows are written. The disk_size column is the
|
||||
// dupe's own. stdout may be the terminal prog is drawn on, so the lines
|
||||
// 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,
|
||||
prog *progress,
|
||||
) (int, int64, error) {
|
||||
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 {
|
||||
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
||||
}
|
||||
@@ -159,9 +164,9 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
|
||||
for _, g := range dupes {
|
||||
first := g[0]
|
||||
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),
|
||||
first.fileCount, first.totalSize)
|
||||
first.fileCount, first.totalSize, n.diskSize)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("write stdout: %w", err)
|
||||
}
|
||||
@@ -232,6 +237,7 @@ func (b *treeBuilder) add(r scanRec) {
|
||||
dir.entries = append(dir.entries, fileEntry(name, r))
|
||||
dir.fileCount++
|
||||
dir.totalSize += r.size
|
||||
dir.diskSize += r.diskSize
|
||||
}
|
||||
|
||||
// openDir starts the directory called name inside the innermost open
|
||||
@@ -270,6 +276,7 @@ func (b *treeBuilder) closeTo(n int) {
|
||||
"d\x00"+name+"\x00"+string(dir.digest[:]))
|
||||
dir.parent.fileCount += dir.fileCount
|
||||
dir.parent.totalSize += dir.totalSize
|
||||
dir.parent.diskSize += dir.diskSize
|
||||
b.dirs = append(b.dirs, dir)
|
||||
}
|
||||
}
|
||||
|
||||
+42
-13
@@ -15,20 +15,26 @@ import (
|
||||
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
||||
"remain unknown"
|
||||
|
||||
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
||||
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
||||
// smokeTreeRecs mirrors the tree of the test under README "Definition
|
||||
// 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 {
|
||||
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
||||
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
||||
|
||||
return []scanRec{
|
||||
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
|
||||
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t2/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t2/sub/f2"},
|
||||
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t3/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, content: f2Content,
|
||||
path: "/d/t3/sub/f2renamed"},
|
||||
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
|
||||
content: f1Content, path: "/d/t1/f1"},
|
||||
{size: 100, diskSize: 4096, head: f2Head, tail: f2Tail,
|
||||
content: f2Content, path: "/d/t1/sub/f2"},
|
||||
{size: 3000, diskSize: 4096, head: f1Head, tail: f1Tail,
|
||||
content: f1Content, path: "/d/t2/f1"},
|
||||
{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"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,13 +204,33 @@ func TestRunTreesEscapesPaths(t *testing.T) {
|
||||
code, exitOK, stderr.String())
|
||||
}
|
||||
|
||||
want := "first\tdupe\tfiles\tsize\n" +
|
||||
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\n"
|
||||
want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
|
||||
`/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\t4096\n"
|
||||
if got := stdout.String(); got != want {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTreesDiskSize runs trees on two copies of a tree whose files
|
||||
// take different space on disk. The row's disk_size is the sum over the
|
||||
// dupe copy's files, those in its subdirectory included.
|
||||
func TestRunTreesDiskSize(t *testing.T) {
|
||||
f, g := testHash("f"), testHash("g")
|
||||
recs := []scanRec{
|
||||
{size: 10, diskSize: 4096, head: f, tail: f, content: f, path: "/a/f"},
|
||||
{size: 20, diskSize: 4096, head: g, tail: g, content: g, path: "/a/sub/g"},
|
||||
{size: 10, diskSize: 512, head: f, tail: f, content: f, path: "/b/f"},
|
||||
{size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"},
|
||||
}
|
||||
|
||||
t.Setenv(databaseEnv, seedDatabase(t, recs))
|
||||
|
||||
want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
|
||||
if got := runStdout(t, cmdTrees); got != want {
|
||||
t.Errorf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTreesProgressNamesEachPhase runs trees on the smoke-test
|
||||
// records with stderr not a terminal, and checks every line it prints
|
||||
// there. After the line naming the database, each phase prints a line
|
||||
@@ -226,7 +252,8 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
|
||||
code, exitOK, stderr())
|
||||
}
|
||||
|
||||
report := "first\tdupe\tfiles\tsize\n/d/t1\t/d/t2\t2\t3100\n"
|
||||
report := "first\tdupe\tfiles\tsize\tdisk_size\n" +
|
||||
"/d/t1\t/d/t2\t2\t3100\t8192\n"
|
||||
if got := stdout.String(); got != report {
|
||||
t.Errorf("stdout = %q, want %q", got, report)
|
||||
}
|
||||
@@ -291,7 +318,9 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
|
||||
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) {
|
||||
t.Errorf("terminal shows lines %q, want %q first", shown, want)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user