Author SHA1 Message Date
sneak 207bd918c2 Rewrite TODO.md sentence by sentence (closes #135)
check / check (push) Canceled after 0s
Rewrites `TODO.md` from scratch for
#135, by the rules applied to
`README.md` in #140. It is the
project's own task list, so every completed step keeps its date, its
issue or branch and every fact that the old entry recorded, in short
sentences, with lists as bullets. An entry superseded later says so in
one sentence.

`TODO.md` records history, so its entries were not corrected against the
code. The 2026-10-17 release-rule entry is known to be stale. One product
name in the 2026-10-15 entry is replaced by a description.

This file carries the `TODO.md` entry of
#140 word for word, so the
conflict once that merges is resolved by taking this file whole.

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

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

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

Model: opus-5-5
2026-10-26 15:15:37 +01:00
4 changed files with 631 additions and 327 deletions
+20 -11
View File
@@ -461,17 +461,26 @@ exclude, is not on a persistent filesystem (see "Rules for the walk"), or, with
`-x`, is on a different filesystem than that operand, because that operand's `-x`, is on a different filesystem than that operand, because that operand's
walk does not enter such a directory and so does not reach it. walk does not enter such a directory and so does not reach it.
An operand that is a symlink (never followed, not even as an operand), socket, An operand is not scanned when it is:
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
a one-line warning naming the path and what it is, counts it as skipped, and - a symlink, which `scan` never follows, not even as an operand;
drops it from the scanned operands before reading the database. Another operand - a socket, FIFO or device node;
beneath it is still scanned. The records stored beneath it are not deleted: they - at or inside a directory named `.zfs`.
are treated like any other record outside the scanned operands. If it lies under
another operand, they are under that operand instead, and are treated like any An operand reached through a symlink into a `.zfs` directory is not scanned
other record there. This is not an error: a scan whose every operand is dropped either.
walks nothing and exits 0. An operand that is not on a persistent filesystem
(see "Rules for the walk") is dropped the same way. The line `scan` prints for This holds even when the operand lies under another operand. `scan` prints a
it is not a warning, though, and it is not counted as skipped. one-line warning naming the path and what it is, counts it as skipped, and drops
it from the scanned operands before reading the database. An operand beneath a
symlink operand is still scanned. The records stored beneath the dropped operand
are not deleted: they are treated like any other record outside the scanned
operands. If it lies under another operand, they are under that operand instead,
and are treated like any other record there. This is not an error: a scan whose
every operand is dropped walks nothing and exits 0. An operand that is not on a
persistent filesystem (see "Rules for the walk") is dropped the same way. The
line `scan` prints for it is not a warning, though, and it is not counted as
skipped.
`scan` synchronizes the database with the filesystem state under the scanned `scan` synchronizes the database with the filesystem state under the scanned
operands: operands:
+461 -294
View File
@@ -14,130 +14,192 @@
# Status # Status
- Pre-1.0. - Pre-1.0.
- The tracker is authoritative for the backlog. The open issues under the - The tracker is authoritative for the pre-1.0 backlog. The open issues under
`1.0.0` milestone are what remains before the tag. This file records history the `1.0.0` milestone are what remains before the tag. This file records
and process, not the queue. history and process, not the queue.
# Next Step # 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 https://git.eeqj.de/sneak/sfdupes/milestone/17. The issues are not restated
here, because a copy drifts as soon as the tracker moves. here, because a copy drifts as soon as the tracker moves.
# Completed Steps # Completed Steps
Newest first. Each entry ends with its date and its issue or branch. 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 - `TODO.md` rewritten from scratch by the same rules as `README.md`, sentence by
sentences, lists as bullets, nothing a newcomer reads twice. Every completed sentence. Every completed step is kept, with its date and its issue or branch.
step is kept, with its date and issue, in fewer words. The policy-compliance The policy-compliance checklist of 2026-07-22 is folded into its step
checklist of 2026-07-22 is folded into its step (2026-10-26, (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135)
https://git.eeqj.de/sneak/sfdupes/issues/135)
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads - `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 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; covers every setting and every subcommand. Development and testing come last.
development and testing come last. Every statement was checked against the Every statement was checked against the code on `next`. Five comments in
code on `next` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135) `main_test.go` cite "Settings", where their rules now live. One comment each
in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
now says (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135)
- an operand at or inside a directory named `.zfs` is never scanned, whether
given alone or under another operand. `scan` warns about it, counts it as
skipped and does not walk it (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/142)
- A file or directory removed while the scan runs is not an error, in the walk - 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 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 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 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 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, 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 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 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) `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 - 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 bytes of a SHA-256. That is half the size of the hex text they held. The
raw from the hash phase through the database to `report` and `trees`. Reading hashes stay raw from the hash phase through the database to `report` and
a record whose hash is not 32 bytes is an error (2026-10-26, `trees`. Reading a record whose hash is not 32 bytes is an error (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/134) https://git.eeqj.de/sneak/sfdupes/issues/134)
- Every file the walk records gets three hashes in the hash phase. `head` and - The hash phase gives three hashes to every file that the walk records. `head`
`tail` are the SHA-256 of its first and last 64 KiB. `content` is the SHA-256 and `tail` are the SHA-256 of its first and last 64 KiB. `content` is the
of the whole file under 100 MiB, and of 1 MiB at each gigabyte from 100 MiB SHA-256 of the whole file under 100 MiB, and of 1 MiB at each gigabyte from
up. The 10 MiB and 50 MiB thresholds, the size census, the records without 100 MiB up. Gone are:
hashes and the content phase are gone. A scan has four phases, and a rescan - the 10 MiB and 50 MiB thresholds;
reads only new and changed files. `info` no longer counts the records with - the size census, which counted the sizes of every walked file and of every
hashes, since every record has them (2026-10-25, record outside the operands. A file was hashed only when the census showed
https://git.eeqj.de/sneak/sfdupes/issues/132) another file of its size;
- the records without hashes;
- the content phase.
- `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` re-vendored from A scan has four phases, and a rescan reads only new and changed files.
`sneak/prompts` `next` at `0653579`. The module download in the `lint`, `test` `info` no longer counts the records with `head` and `tail` hashes, nor those
and `builder` stages of the `Dockerfile`, and the package install in with a content hash, since every record has all three (2026-10-25,
`builder`, now come before `ARG CACHEBUST`. Docker keeps them between builds. https://git.eeqj.de/sneak/sfdupes/issues/132)
Every step that lints, tests or compiles still comes after it.
`golangci-lint run` also keeps golangci-lint's cache. README says the same
(2026-10-18, https://git.eeqj.de/sneak/sfdupes/issues/109)
- Release rule set: until 1.0, each tip of `main` that passes the build gets the - `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` were copied
next `v0.0.N` tag, so `go install sneak.berlin/go/sfdupes@latest` installs it. again from `sneak/prompts` `next` at `0653579`. Two steps now come before
`go.mod` retracts `v0.0.1`, the early build without a database. When the `ARG CACHEBUST`:
linker set no version, `sfdupes --version` prints the version Go recorded in - the module download in the `lint`, `test` and `builder` stages of the
the binary: the tag, or the pseudo-version of a `go install` build. It prints `Dockerfile`;
`dev` only when neither is known. README "Install" documented the rule, what - the package install in `builder`.
`@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 - `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 directory is the path with its last element cut off as text, so a `..` in it
`..` after a symlinked directory therefore goes through the symlink, as it stays. A `..` after a symlinked directory therefore goes through the symlink,
does when the file is opened. With as it does when the file is opened. With
`SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a symlink to `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 `/data/x`, it now creates `/data/new` and the database in it. It used to
create `/srv/new` and then fail (2026-10-17, create `/srv/new` and then fail (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/119) https://git.eeqj.de/sneak/sfdupes/issues/119)
- `scan` never records its own database. Once the database and its `.lock` file - `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 are open, it notes the device and inode of each of these:
`-shm` files and the `.lock` file. The walk passes over a file with one of - the database file;
them wherever it reaches it, through a symlinked directory included, printing - its `-wal` and `-shm` files;
no line and counting nothing. A record an earlier scan left for one of them is - the `.lock` file.
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: The walk passes over any file with the device and inode of one of them,
`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types. It however it reaches the file. It prints no line for such a file and counts
matches them by device ID, listed as the scan starts from nothing. A record an earlier scan left for one of them is deleted
`/proc/self/mountinfo` on Linux and from the mount list on macOS. No flag (2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/115)
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)
- The rate a progress display shows covers the items completed over the last 5 - `scan` never records or reads anything on a filesystem that is not persistent,
seconds, or since the line before when stderr is not a terminal. It falls to 0 such as `tmpfs` or `proc`. Such a filesystem is one the kernel makes up or
while nothing completes, and a burst after a wait no longer reads as a steady keeps in memory. `scan` matches them by device ID, however the walk or the
rate. The time remaining still comes from the phase's average. Once no item content phase reaches a path. As the scan starts, it lists the mounted
has completed for 10 seconds, the word `stalled` and where the phase waits filesystems of those types, from `/proc/self/mountinfo` on Linux and from the
take the rate's place, as `stalled disk/db`. The places are `disk`, `db`, mount list on macOS. No flag turns that off. Below an operand, the walk does
`out` for stdout and `cpu`, or two of them for a phase that may wait on not enter the default excludes either. So `sfdupes scan /` skips the kernel's
either. A line too wide for the terminal loses its bar first, then its rate or view of the running system and the temporary directories. On Linux those are:
those words, so the phase, progress, elapsed time and time remaining stay
whole. 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 - New subcommand `info` prints the database's details on stdout, one per line as
a name, a tab and the value (2026-10-16, a name, a tab and the value:
https://git.eeqj.de/sneak/sfdupes/issues/117):
- the path and where it came from; - the path and where it came from;
- the sizes of the database file and of its `-wal` file, when there is one; - the sizes of the database file and of its `-wal` file, when there is one;
- when the database file was last changed; - when the database file was last changed;
- the schema version, the record counts and the sizes of all records added - the schema version;
up. - 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, 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 `[1/2] open` and `[2/2] count`. The schema version and the counts come from
one read-only transaction, the counts from one query. 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 - `report` shows its work on stderr in two phases, `[1/2] open` and
`[2/2] read`, with the progress display `scan` and `trees` use. The display `[2/2] read`, with the progress display `scan` and `trees` use. The display
@@ -145,83 +207,96 @@ Newest first. Each entry ends with its date and its issue or branch.
opening the database, also while it waits on a busy one. `read` counts the 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 records, then reads them against that count in signature order. SQLite takes
that order from the `files_signature` index without sorting, so the first that order from the `files_signature` index without sorting, so the first
record comes at once. `report` forms the duplicate groups itself, holding only record comes at once. `report` forms the duplicate groups itself and holds
those of the size it is reading, and writes them ordered by first path before only those of the size it is reading. It writes them ordered by first path
it reads the next size. stdout and the summary line are unchanged, and SQLite before it reads the next size. stdout and the summary line are unchanged, and
no longer needs a temporary file to sort in (2026-10-16, SQLite no longer needs a temporary file to sort in (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/114) https://git.eeqj.de/sneak/sfdupes/issues/114)
- `trees` shows its work on stderr in four phases, `[1/4] open`, `[2/4] read`, - `trees` shows its work on stderr in four phases, from `[1/4] open` to
`[3/4] group` and `[4/4] write`, each with the progress display `scan` uses. `[4/4] write`, each with the progress display `scan` uses. The display runs
The display runs from the moment `trees` starts until the summary line. `open` from the moment `trees` starts until the summary line. `open` says it is
says it is opening the database, also while it waits on a busy one. `read` opening the database, also while it waits on a busy one. `read` counts the
counts the records, then reads them against that count. `group` counts the records, then reads them against that count. `group` counts the directories it
directories it groups, and `write` the groups it writes. A display's rate groups, and `write` the groups it writes. A display's rate names what its
names what its phase counts: files, records, directories or groups. stdout and phase counts:
the summary line are unchanged. When stdout and stderr are the same file, as - files;
one terminal is, no line of stdout shares a line with the display. When they - records;
are not, the display keeps being drawn while stdout waits on a slow reader - directories;
(2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/113) - 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 - Each `scan` phase's progress display names the phase and its place among the
five, as `[2/5] walk`, from the moment it starts, with its progress, elapsed five, as `[2/5] walk`. It runs from the moment that the phase starts. It
time and ETA. `load` counts the records first. The walk's total is estimated shows:
from the records under the operands. The content phase counts the records it - the progress;
checks from SQLite's grouping alone, then says it is searching the database - the elapsed time;
until the first one is returned. On a terminal a phase with an exact total - the ETA, the time remaining.
draws its line followed by the library's bar, and any other a spinner followed
by the line. Every display is drawn again several times a second. No line `load` counts the records first. The walk's total is estimated from the
reaches the terminal's last column, however narrow, also after the terminal is records under the operands. The content phase takes its total from SQLite's
made narrower during a phase. A line too wide leaves out its rate, then its grouping alone. Until the first record is returned, it says it is searching
bar, keeping the phase, progress, elapsed time and ETA whole. It is cut short the database. On a terminal a phase with an exact total draws its line
only when those alone do not fit, as below about 70 columns late in a large followed by the bar of the progress-bar library. Any other phase draws a
scan. In plain mode the line is printed every 5 seconds whether or not an item spinner followed by the line. Every display is drawn again several times a
completed (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/112) 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 - `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 they use, as an absolute path. The line says whether that path is the default
from `SFDUPES_DATABASE`. `scan` says when the database is new. README or comes from `SFDUPES_DATABASE`. `scan` says when the database is new. README
"Database" says when `scan` writes each file (2026-10-16, "Database" says when `scan` writes each file (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/111) https://git.eeqj.de/sneak/sfdupes/issues/111)
- The canonical files and model scripts re-vendored from `sneak/prompts` `next` - The canonical files and model scripts were copied again from `sneak/prompts`
at `e3f79c4` (2026-10-15, https://git.eeqj.de/sneak/sfdupes/issues/95): `next` at `e3f79c4`:
- golangci-lint v2.14.0; - `golangci-lint` v2.14.0;
- lint and test are phases of the `Dockerfile` that write no image, and - lint and test are phases of the `Dockerfile` that write no image, and
`make test` runs the suite under the race detector. `Dockerfile.lint`, `make test` runs the suite under the race detector. Gone are
`script/verify-lint-image-pin` and `make test-race` are gone; `Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race`;
- every `docker build` in `script/` passes a new `CACHEBUST` value, which - every `docker build` in `script/` passes a new `CACHEBUST` value. Every
every stage of the `Dockerfile` declares first, so every `RUN` step runs stage of the `Dockerfile` declares it first, so every `RUN` step runs on
on every build. The steps that compile keep Go's build cache, and the every build. The steps that compile keep Go's build cache, and the tests
tests run with `-count=1`; run with `-count=1`;
- prettier runs on the host, from the node and yarn `script/bootstrap` - `prettier` runs on the host, from the `node` and `yarn` that
installs, so the `prettier` and `markdown` stages are gone and the build `script/bootstrap` installs. The `prettier` and `markdown` stages are
stage installs `git` and `make` itself; 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 - a new push cancels the workflow's older run on the same branch, and a run
stops after 20 minutes; 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 the module download in Since https://git.eeqj.de/sneak/sfdupes/issues/109 two steps come before
`lint`, `test` and the build stage, and the install of `git` and `make` in `ARG CACHEBUST`:
the build stage, come before `ARG CACHEBUST`. Docker keeps them between - the module download in `lint`, `test` and the build stage;
builds, and every step that lints, tests or compiles still runs on every - the install of `git` and `make` in the build stage.
build.
- With `-x`, an operand under another operand is no longer dropped when the Docker keeps them between builds. Every step that checks or compiles the
outer operand's walk cannot reach it. That is the case when a directory from code still runs on every build (2026-10-15,
the inner operand up to, but not including, the outer one is on a different 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. 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 For a regular-file operand the check starts at its directory. The records of
records of files gone from below it are deleted (2026-10-14, files gone from below it are deleted (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/106) https://git.eeqj.de/sneak/sfdupes/issues/106)
- With `-x`, `scan` keeps the records below a directory on another filesystem - 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 under its operand, instead of deleting them. It counts their sizes in the size
deleting them (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99) 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 - 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 is still a regular file. A file that is now a FIFO or device node is warned
statted is warned about and skipped instead of hanging the scan (2026-10-14, about and skipped instead of hanging the scan (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/100) https://git.eeqj.de/sneak/sfdupes/issues/100)
- The scan lock file is named from the database's path with symlinks resolved, - The scan lock file is named from the database's path with symlinks resolved,
@@ -247,29 +322,34 @@ Newest first. Each entry ends with its date and its issue or branch.
- `make test-race` ran the test suite under the race detector in a cgo-enabled - `make test-race` ran the test suite under the race detector in a cgo-enabled
container, outside `make check`. Since container, outside `make check`. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 `make test` itself runs the suite 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, `make test-race` is gone (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/18) https://git.eeqj.de/sneak/sfdupes/issues/18)
- A bare `docker build .` failed, naming `script/cibuild` and `script/docker`, - 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 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 `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, `script/` pass a new `CACHEBUST` value, so theirs always run (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/39) https://git.eeqj.de/sneak/sfdupes/issues/39)
- `make fmt` and `make fmt-check` run prettier over all Markdown, and CI checks - `make fmt` and `make fmt-check` run `prettier` over all Markdown, and the
it. All Markdown was reformatted (2026-10-04, Gitea workflow checks it. All Markdown was reformatted (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/19) https://git.eeqj.de/sneak/sfdupes/issues/19)
- `script/lint` writes no image, so a run no longer leaves an untagged one - `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) behind (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/48)
- Tests cover a missing database, `scan` keeping stdout empty, its skip warning, - Tests cover:
the `report` and `trees` summary lines, and every subcommand going through - a missing database;
`runE` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16) - `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, `gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/26) https://git.eeqj.de/sneak/sfdupes/issues/26)
@@ -288,8 +368,8 @@ Newest first. Each entry ends with its date and its issue or branch.
- A test checks that `scan` refuses a database with another schema version - A test checks that `scan` refuses a database with another schema version
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64)
- Four inaccurate comments in `cancel_test.go` corrected, and - Four inaccurate comments in `cancel_test.go` were corrected, and
`walkCancelInFlightDirs` renamed to `walkCancelInFlightFiles` (2026-10-04, `walkCancelInFlightDirs` was renamed to `walkCancelInFlightFiles` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/33) https://git.eeqj.de/sneak/sfdupes/issues/33)
- The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04, - The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04,
@@ -299,13 +379,13 @@ Newest first. Each entry ends with its date and its issue or branch.
`files` table is refused with a clear schema-version error (2026-10-04, `files` table is refused with a clear schema-version error (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/11) https://git.eeqj.de/sneak/sfdupes/issues/11)
- README documents install, Docker, a daily cron scan and how to read and check - README documents install, Docker, a daily `cron` scan and how to read and
the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54) 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 - 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 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 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, run as `nobody` in the `test` phase (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/43) https://git.eeqj.de/sneak/sfdupes/issues/43)
@@ -320,234 +400,321 @@ Newest first. Each entry ends with its date and its issue or branch.
The schema gains the `files_signature` index (2026-10-04, The schema gains the `files_signature` index (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/14) https://git.eeqj.de/sneak/sfdupes/issues/14)
- Progress prints at once on a non-terminal, uses a real terminal test, and - Progress prints at once on a non-terminal and uses a real terminal test.
prints warnings through a spinner instead of racing its redraw (2026-10-03, Warnings print through a spinner instead of racing its redraw (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/13) https://git.eeqj.de/sneak/sfdupes/issues/13)
- Symlink, socket, FIFO, device and `.zfs` operands are warned about and - These operands are warned about and skipped, and the records beneath them are
skipped, and the records beneath them are kept (2026-10-03, kept:
https://git.eeqj.de/sneak/sfdupes/issues/9) - 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 - `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, second `scan` fails at once with exit 1 (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/53) https://git.eeqj.de/sneak/sfdupes/issues/53)
- Tests cover stdout write failures in `report` and `trees`. README states that - Tests cover stdout write failures in `report` and `trees`. README states that
`| head` ends sfdupes by `SIGPIPE` and that `>&-` writes to `/dev/null` `| head` ends `sfdupes` by `SIGPIPE` and that `>&-` writes to `/dev/null`
(2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/30) (2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/30)
- `report` and `trees` open the database read-only, and `scan` leaves it out of - `report` and `trees` open the database read-only, and `scan` leaves it out of
WAL mode, so reading needs only read access (2026-10-03, WAL mode, so reading needs only read access (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/8) https://git.eeqj.de/sneak/sfdupes/issues/8)
- Tabs, newlines, carriage returns and backslashes are escaped in report, trees - Four characters are escaped in `report`, `trees` and warning paths:
and warning paths. The root directory's path is `/` (2026-10-03, - tab;
https://git.eeqj.de/sneak/sfdupes/issues/7) - newline;
- carriage return;
- backslash.
- A plain `docker build .` stamps the git tag or short commit instead of `dev`. The root directory's path is `/` (2026-10-03,
`.dockerignore` sends `.git` without `.git/config`. The build stage stamps the https://git.eeqj.de/sneak/sfdupes/issues/7)
`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 - A plain `docker build .` stamps the `git` tag or short commit instead of
checks out the full history (`fetch-depth: 0`), so it stamps the same value as `dev`. `.dockerignore` sends `.git` without `.git/config`. The build stage
`make build` (2026-10-02, branch `next`, 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) https://git.eeqj.de/sneak/sfdupes/issues/67)
- The 1 KiB end-window sampling was replaced with the head/tail plus - The 1 KiB end-window sampling was replaced with the hashes chosen by file
content-hash ladder, and README "Duplicate detection" documented every rung. A size, and README "Duplicate detection" documented every size threshold. A file
file under 10 MiB was hashed in full, and its `head`, `tail` and `content` all under 10 MiB was hashed in full, and its `head`, `tail` and `content` all held
held that hash. A larger file got only its 64 KiB `head` and `tail` in the that hash. A larger file got only its 64 KiB `head` and `tail` in the hash
hash phase. The content phase, after the update phase, read it for `content` phase. The content phase ran after the update phase. It read a file for
only when its size, `head` and `tail` matched another record's. `content` was `content` only when its size, `head` and `tail` matched another record's, from
the whole file below 50 MiB, and gigabyte-spaced 1 MiB samples at or above. A this scan or an earlier one. `content` was the whole file below 50 MiB, and
file gone or changed since its record was written was never read. `report` and gigabyte-spaced 1 MiB samples at or above. It never read a file gone or
`trees` left out any record without a `content` hash. The `content` column is changed since its record was written. `report` and `trees` left out any record
part of the version 1 schema. Superseded by without a `content` hash. The `content` column is part of the version 1
https://git.eeqj.de/sneak/sfdupes/issues/132 (2026-09-22, branch `next`, schema. Superseded by https://git.eeqj.de/sneak/sfdupes/issues/132
https://git.eeqj.de/sneak/sfdupes/issues/61) (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`, `.dockerignore` (2026-09-21, branch `next`,
https://git.eeqj.de/sneak/sfdupes/issues/22) https://git.eeqj.de/sneak/sfdupes/issues/22)
- The lint-image pin comments and `FROM` form in `Dockerfile` and - 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:...`, `# image:vX.Y.Z, YYYY-MM-DD` comment over a bare `FROM image@sha256:...`,
without the false `(Debian-based)` note or the tag. Since without the false `(Debian-based)` note or the tag. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 the `Dockerfile`'s `lint` phase 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 holds the only `golangci-lint` pin, in that form. `Dockerfile.lint` is gone,
`script/verify-lint-image-pin`, which compared the two pins, are 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) (2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/25)
- All linting runs in Docker, via `Dockerfile.lint` and `script/lint`. Per the - All linting ran in Docker, via `Dockerfile.lint` and `script/lint`. Per the
owner ruling the linter is never installed on a host, and owner ruling the linter is never installed on a host, and
`golangci-lint config verify` runs before `golangci-lint run`. `golangci-lint config verify` runs before `golangci-lint run`.
`script/bootstrap` stopped installing or pinning the linter, and `script/bootstrap` stopped installing or pinning the linter, and
`script/verify-linter-pin` was retired. Since `script/verify-linter-pin` was retired. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 both commands run in the 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 `Dockerfile`'s `lint` phase. `script/lint` builds that phase alone, and the
stage depends on through `COPY --from=lint /src/go.sum /dev/null`. 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 `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 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) (2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/46)
- The Docker build stage's prerequisites were installed by running - The Docker build stage's prerequisites were installed by running
`script/bootstrap` instead of `apk add --no-cache make` inline. `script/bootstrap` instead of `apk add --no-cache make` inline.
`script/verify-linter-pin` failed the build unless the linter copied from the `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 lint stage was the version that `script/bootstrap` pinned. Builds then took up
5m14s cold, mostly in a `chown -R` of the module cache, filed as to 5m14s from an empty cache, mostly in a `chown -R` of the module cache,
https://git.eeqj.de/sneak/sfdupes/issues/43. Since 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 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`, phases are the gates (2026-08-09, branch `dockerfile-bootstrap`,
https://git.eeqj.de/sneak/sfdupes/issues/42) https://git.eeqj.de/sneak/sfdupes/issues/42)
- The Docker layer cache is busted for the gate steps, so `script/cibuild` and - The Docker layer cache is defeated for the gate steps, so `script/cibuild` and
`script/docker` cannot report a green they did not earn. The `Dockerfile` `script/docker` cannot report a pass they did not earn. The `Dockerfile`
copies the tree before its gates, so on an unchanged tree Docker served them copies the tree before its gates. On an unchanged tree Docker served them from
from cache and the build exited 0 having run nothing. The fix was a cache, and the build exited 0 without running them. The fix was a
`CHECK_EPOCH` build argument. Since `CHECK_EPOCH` build argument. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/` 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` passes a new `CACHEBUST` value instead. Every stage of the `Dockerfile`
declares before every step that lints, tests or compiles. Run as root, the declares it before every step that checks or compiles the code. Run as root,
tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the the tests fail `TestScanHardlinkRunFailsTogether`, because root reads through
`chmod(0)` the test relies on, so the `test` phase runs them as `nobody` the `chmod(0)` the test relies on. So the `test` phase runs them as `nobody`
(2026-08-09, branch `cibuild-cache-bust`, (2026-08-09, branch `cibuild-cache-bust`,
https://git.eeqj.de/sneak/sfdupes/issues/32) https://git.eeqj.de/sneak/sfdupes/issues/32)
- `script/bootstrap` checked the installed golangci-lint version instead of only - `script/bootstrap` checked the installed `golangci-lint` version instead of
its presence. The version lived only in `GOLANGCI_LINT_VERSION`, with the only its presence. The version lived only in `GOLANGCI_LINT_VERSION`, with the
`go install` module ref derived from it, and any installed version that was `go install` module reference derived from it. Any installed version that was
not the pin was reinstalled. `go install` writes into `GOBIN` (or not the pin was reinstalled, whether:
`GOPATH/bin`) while `make lint` ran the first `golangci-lint` on `PATH`, so - older;
bootstrap re-read the effective version after installing. On a mismatch it - newer;
printed both paths and both versions and exited non-zero; it did not reorder - absent;
`PATH` or delete anyone's binary. The `--version` call kept its stderr and was - unparseable.
bounded by `timeout(1)` where that exists. `git`, `make` and `go` kept
presence-only checks. Superseded by `go install` writes into `GOBIN`, or `GOPATH/bin`, while `make lint` ran the
https://git.eeqj.de/sneak/sfdupes/issues/46: the linter is no longer installed first `golangci-lint` on `PATH`. So bootstrap re-read the effective version
on a host (2026-08-09, branch `bootstrap-version-check`, after installing. On a mismatch it printed both paths and both versions and
https://git.eeqj.de/sneak/sfdupes/issues/24) 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, - 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 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` 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 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` goroutine has exited. Draining is what frees a worker parked on a send. `ctx`
is threaded from `cmd.Context()` through `runScan`, `syncScan`, both worker is passed from `cmd.Context()` as the first parameter, through:
pools and the whole database layer, as the first parameter everywhere. The - `runScan`;
walk pool gets the same treatment, plus a `ctx.Err()` guard after the walk. A - `syncScan`;
cancelled walk yields a partial size census, and every file it never reached - both worker pools;
looks vanished to the update phase. The guard is the barrier that still holds - the whole database layer.
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 The walk pool gets the same treatment, plus a `ctx.Err()` guard after the
instead of hanging and that the goroutine count returns to its pre-scan walk. A cancelled walk yields a partial size census, and every file that it
baseline. Other tests cancel a scan part-way through the walk and assert that never reached looks vanished to the update phase. That phase's own `BeginTx`
it stops at the guard with every record intact (2026-08-09, branch also fails on the cancelled context before deleting anything. The guard is
`hash-pool-cleanup`, https://git.eeqj.de/sneak/sfdupes/issues/6) 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)` - 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, are gone. So the deferred `db.Close()` runs when a subcommand fails, and with
runs when a subcommand fails. `runScan`, `runReport`, `runTrees`, it the SQLite WAL checkpoint. These return errors instead:
`loadRecords` and `resolveRoots` return errors instead. The single exit point - `runScan`;
is `run` in `main.go`. It maps a `fatalError` to exit 1 and cobra's own - `runReport`;
argument and flag errors to exit 2, so a runtime failure is never reported as - `runTrees`;
a usage error. `main_test.go` drives the CLI in-process and asserts the exit - `loadRecords`;
codes from README "Error handling" and the stdout/stderr split. That includes - `resolveRoots`.
a fatal error after the database is open leaving no `-wal` or `-shm` file
behind (2026-08-09, branch `db-close-on-fatal`,
https://git.eeqj.de/sneak/sfdupes/issues/4)
- 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 `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 `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`, replaced with the canonical file. Four thresholds now live under
`cyclop`, `dupl` thresholds) now live under `linters.settings`, per the v2 `linters.settings`, as the v2 schema requires, so they are applied:
schema, so they are applied. No new lint findings surfaced (2026-08-09, branch - `lll`;
`golangci-v2.12.2`, https://git.eeqj.de/sneak/sfdupes/issues/3) - `funlen`;
- `cyclop`;
- `dupl`.
- Makefile targets converted to scripts-to-rule-them-all `script/` entrypoints, No new lint findings surfaced (2026-08-09, branch `golangci-v2.12.2`,
like the other managed repos. All 12 entrypoints exist, and every Makefile https://git.eeqj.de/sneak/sfdupes/issues/3)
target is a thin shim over them (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 - `Makefile` targets were converted to scripts-to-rule-them-all `script/`
used to run `check` plus `build`. `make build` remains as an alias 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 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
(2026-07-24, branch `make-default-target`) (2026-07-24, branch `make-default-target`)
- Scan-wide phases, concurrent operands and batched updates. All operands seed - The phases became scan-wide, with concurrent operands and batched updates. All
the shared walk pool, and every pass runs once over the whole scan, so totals operands seed the shared walk pool, and every pass runs once over the whole
and ETAs are scan-global. The per-operand walk, hash and update cycles and scan. So the totals and ETAs cover the whole scan. The per-operand walk, hash
their stderr announcements are gone. The update pass commits in batched and update cycles and their stderr announcements are gone. The update pass
transactions. The filesystem is authoritative and the database an writes in batches, each in its own transaction. The filesystem is
eventually-consistent reflection of it, so scan-level atomicity is not authoritative and the database follows it, so one scan's changes need not land
required (2026-07-24, branch `scan-wide-phases`) 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, walk, stat, hash and update per operand, with parallelism only inside again, per operand, with parallelism only inside each phase:
each phase. The walk enumerates paths with per-directory workers, and the stat - walk;
pass lstats them with per-file workers, restoring the exact total and ETA of - stat;
the stat bar (2026-07-24, branch `parallel-phases`) - hash;
- update.
The walk enumerates paths with per-directory workers, and the stat pass
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, - 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 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 totals that looked like the whole run's (2026-07-24, branch
`scan-operand-progress`) `scan-operand-progress`)
- Parallel walk. The walk pass was a single goroutine and took hours at about 20 - The walk became parallel. The walk pass was a single goroutine and took hours
million files on a busy pool: 22 million files in 4 hours on a ZFS server. It at about 20 million files on a busy pool. One ZFS server took 4 hours for 22
is now a per-directory worker-pool traversal that records size and mtime million files. It is now a walk by a pool of per-directory workers that
during the walk, folding away the separate stat pass and halving metadata I/O. records size and mtime as it goes. That folds away the separate stat pass and
Each `PATH` operand commits in its own transaction, so an interrupted scan halves the metadata reads. Each `PATH` operand commits in its own transaction,
keeps completed operands (2026-07-24, branch `parallel-walk`) so an interrupted scan keeps completed operands (2026-07-24, branch
`parallel-walk`)
- Persistent scan database. `scan` maintains a SQLite database - `scan` gained a persistent database. It maintains an SQLite database that
(`modernc.org/sqlite`, pure Go, cgo stays disabled), keyed by absolute path, survives between runs, with the absolute path as each record's key. The driver
that survives between runs. A rescan hashes only new or changed files, by is `modernc.org/sqlite`, pure Go, so cgo stays disabled. A rescan hashes only
mtime and size. It deletes the records of files vanished from under the new or changed files, by mtime and size. It deletes the records of files
scanned operands and leaves records outside them untouched, so `scan` can be vanished from under the scanned operands and leaves records outside them
cronned daily. `report` and `trees` read the database, with no positional untouched. So `scan` can run daily from `cron`. `report` and `trees` read the
arguments, instead of a scan stream. The database is at database, with no positional arguments, instead of a scan stream. The database
`/var/lib/sfdupes/db.sqlite`, overridable via `SFDUPES_DATABASE`. WAL 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 journaling plus a single-transaction update keep a report run during a scan
safe (2026-07-24, branch `persistent-database`) safe (2026-07-24, branch `persistent-database`)
- The `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`) added, `v0.0.1` - The `origin` remote, `git@git.eeqj.de:sneak/sfdupes.git`, was added. `v0.0.1`
tagged, and `main` plus tags pushed (2026-07-23) was tagged, and `main` and the tags were pushed (2026-07-23)
- `scan` CLI rework. Required `PATH...` operands via cobra replace the `/srv` - The `scan` command line was reworked. Required `PATH...` operands, parsed by
`-root` default. The new `-x`/`--one-file-system` flag (GNU convention) stops cobra, replace the `/srv` `-root` default. The new `-x`, `--one-file-system`
at filesystem boundaries, which are crossed by default (2026-07-23, branch flag follows the GNU convention and stops at filesystem boundaries, which the
`scan-required-paths`) walk crosses by default (2026-07-23, branch `scan-required-paths`)
- The repo brought into compliance with `REPO_POLICIES.md` (2026-07-06), the - The repo was brought into compliance with `REPO_POLICIES.md` (the version of
existing repo checklist and the Go styleguide, audited 2026-07-22 (2026-07-23, 2026-07-06), the existing repo checklist and the Go styleguide. The audit of
branch `repo-policy-compliance`). Added or fixed: 2026-07-22 found these gaps, and the branch closed them:
- `.gitignore`, with OS, editor, Go and secrets patterns; the compiled - `.gitignore`, with the standard patterns for secrets and for OS, editor
`sfdupes` binary and `files.dat` had been sitting untracked in the tree; and Go files. The compiled `sfdupes` binary and `files.dat` had been
sitting untracked in the tree;
- `.editorconfig`; - `.editorconfig`;
- `LICENSE` (MIT) and the README License section; - `LICENSE` and the README License section. MIT was assumed from house
convention, for the owner to confirm;
- `REPO_POLICIES.md` in the repo root; - `REPO_POLICIES.md` in the repo root;
- the canonical `.golangci.yml`, with the 150 findings it raised fixed; - the canonical `.golangci.yml`, with its 150 findings fixed, so that
- the `Makefile` targets `test`, `lint`, `fmt`, `fmt-check`, `docker` and `make lint` is clean;
`hooks`, and a `check` target that no longer depends on `build`; - the `Makefile` targets the policy requires:
- a test suite covering parsing, grouping, digests, suppression, hashing and - `test` and `lint`;
the scan pipeline, at 64% coverage; - `fmt` and `fmt-check`;
- a multistage `Dockerfile` with hash-pinned images, and `.dockerignore`; - `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. 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 - `.gitea/workflows/check.yml`, running `docker build .` on push with the
checkout action pinned by commit SHA; checkout action pinned by commit SHA;
- the README sections the policy requires; - 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 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.
- `git init` with a README-only first commit; the code baseline committed on The code was already `gofmt`-clean, so no standalone formatting commit was
`main` (2026-07-22) needed (2026-07-23, branch `repo-policy-compliance`)
- `scan`, `report` and `trees` subcommands implemented (pre-git history) - `git init` was run, with a README-only first commit. The code baseline was
committed on `main` (2026-07-22)
- The `scan`, `report` and `trees` subcommands were implemented (pre-git
history)
# Future Steps # Future Steps
- Possible later features, out of scope per README: full-content verification of - Possible later features, out of scope per README, are a byte-for-byte check of
candidates, and helper scripts for removal. candidates and helper scripts for removal.
# Accepted Divergences # Accepted Divergences
+122 -10
View File
@@ -928,24 +928,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs") zfs := filepath.Join(t.TempDir(), ".zfs")
snapshot := filepath.Join(zfs, "snapshot", "hourly") f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not // No scan records a file inside a .zfs directory, so the record
// itself named .zfs. // beneath the operand is written straight into the database.
scanOK(t, snapshot) path := seedDatabase(t, []scanRec{{size: 10, path: f}})
t.Setenv(databaseEnv, path)
assertOperandSkipped(t, path, zfs, ".zfs directory", f) assertOperandSkipped(t, path, zfs, ".zfs directory", f)
} }
// assertOperandSkipped scans operand alone and checks that it is skipped // assertOperandSkipped scans operand alone and checks that it is skipped
// as kind: a warning naming it, one skip in the summary, exit 0, and the // as kind: a warning naming it, one skip in the summary, exit 0, and the
// record for kept, which an earlier scan stored beneath operand, still // record for kept, stored beneath operand before this scan, still in the
// in the database at dbPath. // database at dbPath.
func assertOperandSkipped(t *testing.T, dbPath, operand, kind, func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
kept string, kept string,
) { ) {
@@ -974,6 +971,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
recordByPath(t, dbRecords(t, db), kept) recordByPath(t, dbRecords(t, db), kept)
} }
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
// directory, first alone and then under another operand. Each time it
// is warned about and counted as skipped, and nothing in it is walked.
// The other operand is scanned as usual.
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
writeFile(t, hourly, "f", pattern(1, 10))
f := writeFile(t, dir, "f", pattern(2, 10))
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
for _, tc := range []struct {
operands []string
summary string
}{
{
[]string{hourly},
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
{
[]string{dir, hourly},
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
} {
stderr := scanOK(t, tc.operands...)
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, tc.summary) {
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
stderr, warning, tc.summary)
}
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
// by the path given, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := t.TempDir()
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps")
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(snaps, "daily")
stderr := scanOK(t, daily)
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
}
}
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to a directory outside any .zfs directory. The operand is
// scanned as usual, and its file is recorded by the path given.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data")
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(data, "daily")
stderr := scanOK(t, daily)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
}
func TestRunReportSucceeds(t *testing.T) { func TestRunReportSucceeds(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
+28 -12
View File
@@ -383,7 +383,8 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
) error { ) error {
// Types and filesystems are checked before pruning so that an // Types and filesystems are checked before pruning so that an
// operand under a dropped one is still scanned, not dropped as lying // 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, roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
deviceOfPath) deviceOfPath)
@@ -411,15 +412,15 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
} }
// walkableRoots returns the operands the walk can start from: regular // walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs. Every other operand is warned // files, and directories not named .zfs, none of them inside a
// about, counted as skipped, and dropped. A dropped operand is no // directory named .zfs. Every other operand is warned about, counted as
// longer a root, so the records stored beneath it are treated as if it // skipped, and dropped. A dropped operand is no longer a root, so the
// had not been given: those under no other root count as outside the // records stored beneath it are treated as if it had not been given:
// roots and are not deleted as unverified. An operand on a filesystem // those under no other root count as outside the roots and are not
// in rules that is not persistent is dropped the same way, with a line // deleted as unverified. An operand on a filesystem in rules that is not
// saying so that is not a warning, and is not counted as skipped. An // persistent is dropped the same way, with a line saying so that is not
// operand that fails lstat here is kept, and the walk warns about it // a warning, and is not counted as skipped. An operand that fails lstat
// unless it no longer exists. // here is kept, and the walk warns about it unless it no longer exists.
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string { func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
kept := make([]string, 0, len(roots)) kept := make([]string, 0, len(roots))
@@ -874,9 +875,24 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// operandWarning returns the one-line warning for an operand the walk // 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 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 // does: a regular file, or a directory not named .zfs, that is not
// never followed, including as operands. // 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 { 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 var kind string
switch mode := fi.Mode(); { switch mode := fi.Mode(); {