Author SHA1 Message Date
sneak 2b46d374b1 Rewrite README.md sentence by sentence for a newcomer
check / check (push) Canceled after 0s
Rewrites `README.md` from scratch for
#135. Every statement was checked
against the code on `next` and against what the built binary prints on
small trees under `/var/tmp`. The headings that the code comments cite
are kept.

Where the old text and the repository disagreed, the new text states
what the code does. #140 lists
every such point. Among them: `@latest` installs the newest tagged
release, ten million files in both "Rationale" and "Design", no Windows
excludes, a relative operand is joined to the working directory as `pwd`
prints it, and an operand reached through a symlinked directory below
another operand is dropped (#144).

Dropped from "Constraints": the permission to add `github.com/spf13/viper`
if configuration-file support is ever needed.

Model: fable-5-1
2026-10-27 00:39:18 +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
7 changed files with 1589 additions and 1291 deletions
+1023 -866
View File
File diff suppressed because it is too large Load Diff
+393 -383
View File
@@ -1,46 +1,48 @@
# Workflow # Workflow
- Take an issue from the `1.0.0` milestone on the tracker. Work that is not on - take an issue from the `1.0.0` milestone on the tracker; work not yet on the
the tracker yet is filed as an issue first. tracker gets filed as an issue first
- Branch from `next`. - branch from `next`
- Do the work, with tests, in small focused commits. - do the work, with tests, in small focused commits
- Record the step at the top of Completed Steps, in the same commit as the work. - record it at the top of Completed Steps (`TODO.md` changes in the same commit
- Push the branch and open a PR against `next`. Its title ends with as the work)
` (closes #N)`. - push the branch and open a PR against `next` whose title ends with
- An independent review gates each merge to `next`. Every finding is addressed ` (closes #N)`
or rebutted on the PR. - an independent review gates each merge to `next`; every finding is addressed
- Only the owner merges `next` into `main`. or explicitly rebutted on the PR
- only the owner merges `next` to `main`
# Status # Status
- Pre-1.0. - pre-1.0
- The tracker is authoritative for the backlog. The open issues under the - the Gitea tracker is authoritative for the pre-1.0 backlog: the open issues
`1.0.0` milestone are what remains before the tag. This file records history under the `1.0.0` milestone are what remains before the tag, and this file
and process, not the queue. records 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 on the tracker:
https://git.eeqj.de/sneak/sfdupes/milestone/17. The issues are not restated https://git.eeqj.de/sneak/sfdupes/milestone/17 — the milestone is the source
here, because a copy drifts as soon as the tracker moves. of truth for what is left before 1.0.0. Individual issues are deliberately not
restated here; a copy in this file drifts out of date the moment the tracker
moves
# Completed Steps # Completed Steps
Newest first. Each entry ends with its date and its issue or branch.
- `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 issue, in fewer words. The policy-compliance
checklist of 2026-07-22 is folded into its step (2026-10-26,
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)
- A file or directory removed while the scan runs is not an error, in the walk - 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 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
@@ -50,222 +52,218 @@ Newest first. Each entry ends with its date and its issue or branch.
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, 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 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, 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 every file the walk records three hashes: `head` and
`tail` are the SHA-256 of its first and last 64 KiB. `content` is the SHA-256 `tail`, the SHA-256 of its first and last 64 KiB, and `content`, the SHA-256
of the whole file under 100 MiB, and of 1 MiB at each gigabyte from 100 MiB of the whole file under 100 MiB and of 1 MiB at each gigabyte from 100 MiB up.
up. The 10 MiB and 50 MiB thresholds, the size census, the records without The 10 MiB and 50 MiB thresholds, the size census, the records stored without
hashes and the content phase are gone. A scan has four phases, and a rescan hashes and the content phase are gone, so a scan has four phases and a rescan
reads only new and changed files. `info` no longer counts the records with reads only new and changed files. `info` no longer counts the records with
hashes, since every record has them (2026-10-25, hashes, since every record has them (2026-10-25,
https://git.eeqj.de/sneak/sfdupes/issues/132) https://git.eeqj.de/sneak/sfdupes/issues/132)
- `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` re-vendored from - re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from
`sneak/prompts` `next` at `0653579`. The module download in the `lint`, `test` `sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder`
and `builder` stages of the `Dockerfile`, and the package install in stages of the `Dockerfile` the module download, and in `builder` the
`builder`, now come before `ARG CACHEBUST`. Docker keeps them between builds. system-package install, now come before `ARG CACHEBUST`, so Docker keeps them
Every step that lints, tests or compiles still comes after it. until the base image, the install line, `go.mod` or `go.sum` changes, and
every step that lints, tests or compiles still comes after it;
`golangci-lint run` also keeps golangci-lint's cache. README says the same `golangci-lint run` also keeps golangci-lint's cache. README says the same
(2026-10-18, https://git.eeqj.de/sneak/sfdupes/issues/109) (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 - releases until 1.0 are `v0.0.N` tags on `main`'s tip, so
next `v0.0.N` tag, so `go install sneak.berlin/go/sfdupes@latest` installs it. `go install sneak.berlin/go/sfdupes@latest` installs `main`'s tip once it has
`go.mod` retracts `v0.0.1`, the early build without a database. When the passed the build; README "Install" says so, says what `@main` and `v0.0.1`
linker set no version, `sfdupes --version` prints the version Go recorded in give, and how to check an installed binary. `go.mod` retracts `v0.0.1`, the
the binary: the tag, or the pseudo-version of a `go install` build. It prints early build without a database. When the linker set no version,
`dev` only when neither is known. README "Install" documented the rule, what `sfdupes --version` prints the main module's version that Go recorded in the
`@main` and `v0.0.1` give, and how to check an installed binary (2026-10-17, binary, the tag or the pseudo-version of a `go install` build, and `dev` only
when neither is known (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/116) 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 path with its last element cut off as text, never cleaned, so a `..` after a
`..` after a symlinked directory therefore goes through the symlink, as it symlinked directory is followed through the symlink, as when the file is
does when the file is opened. With opened. With `SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a
`SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a symlink to symlink to `/data/x`, it creates `/data/new` and the database in it, where it
`/data/x`, it now creates `/data/new` and the database in it. It used to 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 the database file, its `-wal` and
`-shm` files and the `.lock` file. The walk passes over a file with one of `-shm` files and its `.lock` file, and the walk passes over a file with one of
them wherever it reaches it, through a symlinked directory included, printing them wherever the operands reach it, through a symlinked directory included,
no line and counting nothing. A record an earlier scan left for one of them is printing no line and counting nothing. A record an earlier scan left for one
deleted (2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/115) 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: - `scan` never records or reads anything that is not on a persistent filesystem
`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types. It (`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types),
matches them by device ID, listed as the scan starts from matched by the device ID of each such filesystem, listed as the scan starts
`/proc/self/mountinfo` on Linux and from the mount list on macOS. No flag from `/proc/self/mountinfo` on Linux and the mounted-filesystem list on macOS,
turns that off. Below an operand, the walk does not enter the default excludes however the walk or the content phase reaches it; no flag turns that off.
either, so `sfdupes scan /` skips the kernel's view of the running system and Below an operand, its walk does not enter the default excludes either, so
the temporary directories. On Linux those are `/proc`, `/sys`, `/dev`, `/run`, `sfdupes scan /` skips the kernel's view of the running system and the
`/tmp` and `/var/tmp`. On macOS they are `/dev`, `/private/tmp`, temporary directories: on Linux `/proc`, `/sys`, `/dev`, `/run`, `/tmp` and
`/private/var/tmp`, `/private/var/vm`, `/System/Volumes/VM` and `/var/tmp`, on macOS `/dev`, `/private/tmp`, `/private/var/tmp`,
`/System/Volumes/Data`. A Windows list exists in the code but is not used. An `/private/var/vm`, `/System/Volumes/VM` and `/System/Volumes/Data`, and a
operand at or inside a default exclude is still walked. The scan prints a line Windows list, not used yet. An operand at or inside a default exclude is still
for each path it leaves out, without counting it as skipped, and the records walked. The scan prints a line for each path it leaves out, without counting
there count as outside the scanned operands, as for `-x`. it as skipped, and the records there count as outside the scanned operands, as
`--no-default-excludes` turns off the default excludes only, and `scan --help` for `-x`. `--no-default-excludes` turns off the default excludes only, and
lists them (2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/124) `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 - 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 seconds, or since the line before when stderr is not a terminal, so it falls
while nothing completes, and a burst after a wait no longer reads as a steady to 0 while nothing completes, and a burst after a wait no longer reads as a
rate. The time remaining still comes from the phase's average. Once no item steady rate of over 1,000 files a second; the ETA still comes from the phase's
has completed for 10 seconds, the word `stalled` and where the phase waits average. Once no item has completed for 10 seconds, the word `stalled` and
take the rate's place, as `stalled disk/db`. The places are `disk`, `db`, where the phase waits take the rate's place, as `stalled disk/db`: `disk`,
`out` for stdout and `cpu`, or two of them for a phase that may wait on `db`, `out` for stdout or `cpu`, or two of them for a phase that may wait on
either. A line too wide for the terminal loses its bar first, then its rate or either, each phase giving its word where it is defined. A line too wide for
those words, so the phase, progress, elapsed time and time remaining stay the terminal loses its bar first, then its rate or those words, so the phase,
whole. The content phase's last partial batch is written under a display of progress, elapsed time and ETA stay whole. On an 80-column terminal, at counts
its own (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/125) like those of README's example, the content phase's checks and reads lose
those words at most times in minutes, and every other phase keeps them
whenever the elapsed time and the ETA are both in minutes. The content phase's
last partial batch is now written under a display of its own (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/125)
- New subcommand `info` prints the database's details on stdout, one per line as - 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: its path and where it came from, the sizes of the
https://git.eeqj.de/sneak/sfdupes/issues/117): database file and of its `-wal` file when there is one, when the database file
- the path and where it came from; was last changed, the schema version, the number of records, how many have
- the sizes of the database file and of its `-wal` file, when there is one; head and tail hashes and how many a content hash, and the sizes of all records
- when the database file was last changed; added up. It opens the database as `report` does, and shows two phases on
- the schema version, the record counts and the sizes of all records added stderr, `[1/2] open` and `[2/2] count`; the schema version and the counts come
up. from one read-only transaction, the counts from one query (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/117)
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.
- `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, from the
runs from the moment `report` starts until the summary line. `open` says it is moment it starts until the summary line: `open` says it is opening the
opening the database, also while it waits on a busy one. `read` counts the database, also while it waits on a busy one; `read` counts the records, then
records, then reads them against that count in signature order. SQLite takes reads them against that count in signature order, which SQLite takes from the
that order from the `files_signature` index without sorting, so the first `files_signature` index without sorting, so the first record comes at once.
record comes at once. `report` forms the duplicate groups itself, holding only `report` forms the duplicate groups itself, holding only those of the size it
those of the size it is reading, and writes them ordered by first path before is reading, and writes them ordered by first path before it reads the next
it reads the next size. stdout and the summary line are unchanged, and SQLite size. stdout and the summary line are unchanged, and SQLite no longer needs a
no longer needs a temporary file to sort in (2026-10-16, 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, `[1/4] open`, `[2/4] read`,
`[3/4] group` and `[4/4] write`, each with the progress display `scan` uses. `[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` from the moment it starts until the summary line: `open` says it is opening
says it is opening the database, also while it waits on a busy one. `read` the database, also while it waits on a busy one; `read` counts the records,
counts the records, then reads them against that count. `group` counts the then reads them against that count; `group` counts the directories it groups,
directories it groups, and `write` the groups it writes. A display's rate and `write` the groups it writes. A display's rate names what its phase
names what its phase counts: files, records, directories or groups. stdout and counts: files, records, directories or groups. stdout and the summary line are
the summary line are unchanged. When stdout and stderr are the same file, as unchanged. When stdout and stderr are the same file, as one terminal is, none
one terminal is, no line of stdout shares a line with the display. When they of stdout's lines shares a line with the display; when they are not, the
are not, the display keeps being drawn while stdout waits on a slow reader display keeps being drawn while stdout waits on a slow reader (2026-10-16,
(2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/113) 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`, from the moment it starts, with its progress, elapsed
time and ETA. `load` counts the records first. The walk's total is estimated 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 from the records under the operands, and the content phase counts the records
checks from SQLite's grouping alone, then says it is searching the database 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 until the first one is returned. On a terminal a phase with an exact total
draws its line followed by the library's bar, and any other a spinner followed 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 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 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 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 bar, keeping the phase, progress, elapsed time and ETA whole, and is cut short
only when those alone do not fit, as below about 70 columns late in a large 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 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) 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, and whether that path is the default or comes
from `SFDUPES_DATABASE`. `scan` says when the database is new. README 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` - re-vendor the canonical files and model scripts from `sneak/prompts` `next` at
at `e3f79c4` (2026-10-15, https://git.eeqj.de/sneak/sfdupes/issues/95): `e3f79c4`: golangci-lint v2.14.0; lint and test are phases of the `Dockerfile`
- golangci-lint v2.14.0; that write no image, and `make test` runs the suite under the race detector,
- lint and test are phases of the `Dockerfile` that write no image, and so `Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race` are
`make test` runs the suite under the race detector. `Dockerfile.lint`, gone; every `docker build` in `script/` passes a new `CACHEBUST` value, which
`script/verify-lint-image-pin` and `make test-race` are gone; every stage of the `Dockerfile` declares first, so every `RUN` step runs on
- every `docker build` in `script/` passes a new `CACHEBUST` value, which every build, while the steps that compile keep Go's build cache and the tests
every stage of the `Dockerfile` declares first, so every `RUN` step runs run with `-count=1`; prettier runs on the host, from the node and yarn
on every build. The steps that compile keep Go's build cache, and the `script/bootstrap` installs, so the `prettier` and `markdown` stages are gone
tests run with `-count=1`; and the build stage installs `git` and `make` itself; a new push cancels the
- prettier runs on the host, from the node and yarn `script/bootstrap` workflow's older run on the same branch, and a run stops after 20 minutes;
installs, so the `prettier` and `markdown` stages are gone and the build `.claude/settings.json` is deleted (2026-10-15,
stage installs `git` and `make` itself; https://git.eeqj.de/sneak/sfdupes/issues/95). Since
- a new push cancels the workflow's older run on the same branch, and a run https://git.eeqj.de/sneak/sfdupes/issues/109 the module download in `lint`,
stops after 20 minutes; `test` and the build stage, and the install of `git` and `make` in the build
- `.claude/settings.json` is deleted. stage, come before `ARG CACHEBUST`, and Docker keeps them between builds;
every step that lints, tests or compiles still runs on every build
Since https://git.eeqj.de/sneak/sfdupes/issues/109 the module download in - with `-x`, an operand under another operand is walked on its own instead of
`lint`, `test` and the build stage, and the install of `git` and `make` in being dropped when a directory from it (from its directory, for a regular
the build stage, come before `ARG CACHEBUST`. Docker keeps them between file) up to but not including that operand is on a different filesystem than
builds, and every step that lints, tests or compiles still runs on every that operand, whose walk then cannot reach it, and the records of files gone
build. from below it are deleted (2026-10-14,
- 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
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,
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, and counts their sizes in the size census, instead of
deleting them (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99) deleting them (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99)
- The hash and content phases open each file without blocking and check that it - 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, so one replaced by a FIFO or device node after it was
statted is warned about and skipped instead of hanging the scan (2026-10-14, statted is warned 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,
after `scan` creates the database file if it is missing. A scan through a after `scan` creates the database file if it is missing, so a scan through a
symlink to the database therefore fails while another scan holds the lock symlink to the database fails while another scan holds the lock (2026-10-14,
(2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/101) https://git.eeqj.de/sneak/sfdupes/issues/101)
- On a terminal, a warning for the last file of a phase no longer erases the - on a terminal, a warning for the last file of a phase no longer erases the
phase's finished bar. The bar is drawn again below the warning (2026-10-14, phase's finished bar: the bar is drawn again below the warning (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/102) https://git.eeqj.de/sneak/sfdupes/issues/102)
- `scan` records the mtime to the nanosecond, as whole seconds in `mtime` plus - `scan` records mtime to the nanosecond, as whole seconds in `mtime` plus
`mtime_nsec`, and compares it at that resolution. A same-size rewrite within `mtime_nsec`, and compares it at that resolution, so a same-size rewrite
the same second is hashed again (2026-10-07, within the same second is re-hashed (2026-10-07,
https://git.eeqj.de/sneak/sfdupes/issues/12) https://git.eeqj.de/sneak/sfdupes/issues/12)
- The narration was cut from `TODO.md` Completed Steps and from the comments in - cut the narration from `TODO.md` Completed Steps and from the comments in
`script/`, `Dockerfile` and `Dockerfile.lint` (gone since `script/`, `Dockerfile` and `Dockerfile.lint` (gone since
https://git.eeqj.de/sneak/sfdupes/issues/95). The Workflow section now https://git.eeqj.de/sneak/sfdupes/issues/95); §Workflow now branches from and
branches from and merges to `next` (2026-10-04, merges to `next` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/49)
https://git.eeqj.de/sneak/sfdupes/issues/49)
- `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` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/18). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 `make test` itself runs the suite 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, and
`make test-race` is gone (2026-10-04, `make test-race` is gone
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 from cache (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/39). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 a bare build succeeds, as 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
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 CI checks
it. All Markdown was reformatted (2026-10-04, it; all Markdown 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 a missing database, `scan` keeping stdout empty, its skip warning,
the `report` and `trees` summary lines, and every subcommand going through the `report` and `trees` summary lines, and every subcommand going through
`runE` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16) `runE` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16)
@@ -273,284 +271,296 @@ Newest first. Each entry ends with its date and its issue or branch.
`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)
- A test fails when either `hashWorker` cancellation check in `scan.go` is - a test fails when either `hashWorker` cancellation check in `scan.go` is
removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83) removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83)
- A database path holding `?`, `#` or `%` opens exactly the file it names - a database path holding `?`, `#` or `%` opens exactly the file it names
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55)
- `scan` rejects `--workers` below 1 as a usage error instead of running - `scan` rejects `--workers` below 1 as a usage error instead of running
single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10) single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10)
- A test fails when either walk cancellation check in `scan.go` is removed - a test fails when either walk cancellation check in `scan.go` is removed
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81)
- A test checks that `scan` refuses a database with another schema version - test that `scan` refuses a database with another schema version (2026-10-04,
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64) https://git.eeqj.de/sneak/sfdupes/issues/64)
- Four inaccurate comments in `cancel_test.go` corrected, and - correct four inaccurate comments in `cancel_test.go` and rename
`walkCancelInFlightDirs` renamed to `walkCancelInFlightFiles` (2026-10-04, `walkCancelInFlightDirs` 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, - test the `-x` filesystem-boundary rules in `subdirJob` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/17) https://git.eeqj.de/sneak/sfdupes/issues/17)
- `scan` creates the schema in one transaction. A version-0 database with a - `scan` creates the schema in one transaction; a version-0 database with a
`files` table is refused with a clear schema-version error (2026-10-04, `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 check
the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54) 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
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/43). Since
https://git.eeqj.de/sneak/sfdupes/issues/95 there is no `builder` user and 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
https://git.eeqj.de/sneak/sfdupes/issues/43)
- `--version` prints `sfdupes VERSION` to stdout. README documents it and - `--version` prints `sfdupes VERSION` to stdout; README documents it and
`--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15) `--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15)
- `scan` stops cleanly on `SIGINT` or `SIGTERM`: it commits what it has hashed, - `scan` stops cleanly on `SIGINT` or `SIGTERM`: commits what it has hashed,
deletes nothing more, and exits 1 (2026-10-04, deletes nothing more, exits 1 (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/5) https://git.eeqj.de/sneak/sfdupes/issues/5)
- `report` and `trees` stream the records instead of holding them all in memory. - `report` and `trees` stream the records instead of holding them all in memory;
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, uses a real terminal test, and
prints warnings through a spinner instead of racing its redraw (2026-10-03, prints warnings 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 - warn about and skip symlink, socket, FIFO, device and `.zfs` operands, keeping
skipped, and the records beneath them are kept (2026-10-03, the records beneath them (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/9) 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 - test 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 `>&-` 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, closes
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 - escape tabs, newlines, carriage returns and backslashes in report, trees and
and warning paths. The root directory's path is `/` (2026-10-03, warning paths; the root directory's path is `/` (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/7) https://git.eeqj.de/sneak/sfdupes/issues/7)
- A plain `docker build .` stamps the git tag or short commit instead of `dev`. - stamp the git tag or short commit in a plain `docker build .` instead of `dev`
`.dockerignore` sends `.git` without `.git/config`. The build stage stamps the (2026-10-02, branch `next`, closes
`VERSION` build argument, else `git describe --tags --always`. It fails if the https://git.eeqj.de/sneak/sfdupes/issues/67): `.dockerignore` sends `.git`
context carries `.git` and the version is still empty, `dev` or `unknown`. CI without `.git/config`; the build stage stamps the `VERSION` build argument,
checks out the full history (`fetch-depth: 0`), so it stamps the same value as else `git describe --tags --always`, and fails if the context carries `.git`
`make build` (2026-10-02, branch `next`, and the version is still empty, `dev` or `unknown`. CI checks out the full
https://git.eeqj.de/sneak/sfdupes/issues/67) history (`fetch-depth: 0`) so it stamps the same value as `make build`.
- The 1 KiB end-window sampling was replaced with the head/tail plus - replace the 1 KiB end-window sampling with the head/tail plus content-hash
content-hash ladder, and README "Duplicate detection" documented every rung. A ladder (2026-09-22, branch `next`, closes
file under 10 MiB was hashed in full, and its `head`, `tail` and `content` all https://git.eeqj.de/sneak/sfdupes/issues/61); README "Duplicate detection"
held that hash. A larger file got only its 64 KiB `head` and `tail` in the documents every rung. A file under 10 MiB is hashed in full, and its `head`,
hash phase. The content phase, after the update phase, read it for `content` `tail` and `content` all hold that hash. A larger file gets only its 64 KiB
only when its size, `head` and `tail` matched another record's. `content` was `head` and `tail` in the hash phase; the content phase, after the update
the whole file below 50 MiB, and gigabyte-spaced 1 MiB samples at or above. A phase, reads it for `content` (the whole file below 50 MiB, gigabyte-spaced 1
file gone or changed since its record was written was never read. `report` and MiB samples at or above) only when its size, `head` and `tail` match another
`trees` left out any record without a `content` hash. The `content` column is record's from this scan or an earlier one, and never reads a file gone or
part of the version 1 schema. Superseded by changed since its record was written. `report` and `trees` leave out any
https://git.eeqj.de/sneak/sfdupes/issues/132 (2026-09-22, branch `next`, record without a `content` hash. The `content` column is part of the version 1
https://git.eeqj.de/sneak/sfdupes/issues/61) schema.
- The dead `files.dat` references removed from `Makefile`, `.gitignore` and - remove the dead `files.dat` references from `Makefile`, `.gitignore` and
`.dockerignore` (2026-09-21, branch `next`, `.dockerignore` (2026-09-21, branch `next`, closes
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 - fix the lint-image pin comments and `FROM` form in `Dockerfile` and
`Dockerfile.lint` fixed. Both pins became the policy `Dockerfile.lint` (2026-08-10, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/25): both pins became the policy
`# image:vX.Y.Z, YYYY-MM-DD` comment over a bare `FROM image@sha256:...`, `# 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, so `Dockerfile.lint` and
`script/verify-lint-image-pin`, which compared the two pins, are gone `script/verify-lint-image-pin`, which compared the two pins, are gone.
(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 - run all linting in Docker via `Dockerfile.lint` and `script/lint` (2026-08-10,
branch `next`, closes https://git.eeqj.de/sneak/sfdupes/issues/46): per the
owner ruling the linter is never installed on a host, and 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, which `script/lint` builds alone and the build
stage depends on through `COPY --from=lint /src/go.sum /dev/null`. stage depends on 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)
- The Docker build stage's prerequisites were installed by running - install the Docker build stage's prerequisites by running `script/bootstrap`
`script/bootstrap` instead of `apk add --no-cache make` inline. instead of `apk add --no-cache make` inline (2026-08-09, branch
`script/verify-linter-pin` failed the build unless the linter copied from the `dockerfile-bootstrap`, closes https://git.eeqj.de/sneak/sfdupes/issues/42),
lint stage was the version `script/bootstrap` pinned. Builds then took up to with `script/verify-linter-pin` failing the build unless the linter copied
5m14s cold, mostly in a `chown -R` of the module cache, filed as from the lint stage was the version `script/bootstrap` pinned. Builds then
took up to 5m14s cold, mostly in a `chown -R` of the module cache, filed as
https://git.eeqj.de/sneak/sfdupes/issues/43. Since https://git.eeqj.de/sneak/sfdupes/issues/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
https://git.eeqj.de/sneak/sfdupes/issues/42) - bust the Docker layer cache for the gate steps, so `script/cibuild` and
`script/docker` cannot report a green they did not earn (2026-08-09, branch
- The Docker layer cache is busted for the gate steps, so `script/cibuild` and `cibuild-cache-bust`, closes https://git.eeqj.de/sneak/sfdupes/issues/32): the
`script/docker` cannot report a green they did not earn. The `Dockerfile` `Dockerfile` copies the tree before its gates, so on an unchanged tree Docker
copies the tree before its gates, so on an unchanged tree Docker served them served them from cache and the build exited 0 having run nothing. The fix was
from cache and the build exited 0 having run nothing. The fix was a 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, which every stage of the `Dockerfile`
declares before every step that lints, tests or compiles. Run as root, the declares before every step that lints, tests or compiles. Run as root, the
tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the
`chmod(0)` the test relies on, so the `test` phase runs them as `nobody` `chmod(0)` the test relies on, so the `test` phase runs them as `nobody`
(2026-08-09, branch `cibuild-cache-bust`, - check the installed golangci-lint version in `script/bootstrap` instead of
https://git.eeqj.de/sneak/sfdupes/issues/32) only its presence (2026-08-09, branch `bootstrap-version-check`, closes
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
`GOLANGCI_LINT_VERSION`, with the `go install` module ref derived from it, and
any installed version that is not the pin — older, newer, absent or
unparseable — is reinstalled. `go install` writes into `GOBIN` (or
`GOPATH/bin`) while `make lint` runs the first `golangci-lint` on `PATH`, so
bootstrap re-reads the effective version after installing and, on a mismatch,
prints both paths and both versions and exits non-zero; it does not reorder
`PATH` or delete anyone's binary. The `--version` call keeps its stderr and is
bounded by `timeout(1)` where that exists. `git`, `make` and `go` keep
presence-only checks. Verified by bootstrapping this host from v2.10.1 to
v2.12.2 and again to a no-op, and by stub runs of the script under `dash`
covering a thirteen-input version-parse matrix, a shadowed install that must
exit non-zero, an install destination not on `PATH`, `GOBIN` set, and a wedged
binary that must hit the timeout; `make check` and `make lint` are clean at
v2.12.2, so v2.10.1 was not hiding any findings on `main`
- unwind the hash worker pool on the error path (2026-08-09, branch
`hash-pool-cleanup`, closes https://git.eeqj.de/sneak/sfdupes/issues/6): the
pool is now an owned, context-aware `hashPool`: every blocking send in the
feeder and the workers selects on `ctx.Done()`, `jobs` is closed on every path
out, and `hashPhase` defers `pool.stop()`, which cancels and then drains
`results` until the last goroutine has exited — draining is what frees a
worker already parked on a send. `ctx` is threaded from `cmd.Context()`
through `runScan`, `syncScan`, both worker pools and the whole database layer,
as the first parameter everywhere. The walk pool gets the same treatment plus
a `ctx.Err()` guard after the walk: a cancelled walk yields a partial size
census, and every file it never reached looks vanished to the update phase.
That phase's own `BeginTx` also fails on the cancelled context before deleting
anything, but the guard is the barrier that still holds once an interrupted
scan may commit what it has. Tests drive `run(scan)` against a database whose
insert trigger aborts and assert that the scan fails instead of hanging and
that `runtime.NumGoroutine()` polls back to its pre-scan baseline; others
cancel a scan part-way through the walk, deterministically, by counting its
own consultations of `ctx.Done()`, and assert that it stops at the guard
holding a partial census and a still-populated record index, with every record
intact. Direct tests of `sendEvent`, the walk workers, `dispatchDirs`,
`feedHashJobs`, `hashWorker` and `hashPhase` cover the remaining cancellation
branches of both pools
- guarantee the database is closed on every fatal exit path (2026-08-09, branch
`db-close-on-fatal`, closes https://git.eeqj.de/sneak/sfdupes/issues/4):
`fatalf` and its `os.Exit(1)` are gone, so the deferred `db.Close()` — and
with it the SQLite WAL checkpoint — now actually runs when a subcommand fails;
`runScan`, `runReport`, `runTrees`, `loadRecords` and `resolveRoots` return
errors instead. The single exit point is `run` in `main.go`: it maps a
`fatalError` (anything a subcommand returned) to exit 1 and cobra's own
argument and flag errors to exit 2, which keeps a runtime failure from being
reported as a usage error or printing the usage text. New `main_test.go`
drives the CLI in-process and asserts the exit codes from README §Error
handling plus the stdout/stderr split, including that a fatal error raised
after the database is open leaves no `-wal`/`-shm` sidecar behind for `scan`,
`report` or `trees`
- update golangci-lint to v2.12.2 with the canonical config (2026-08-09, branch
`golangci-v2.12.2`, merged as `38a01bd`, closes
https://git.eeqj.de/sneak/sfdupes/issues/3): bumped the pinned linter in the
`Dockerfile` lint stage and `script/bootstrap` from v2.12.1 to v2.12.2, and
replaced `.golangci.yml` with the canonical file — the linter settings (`lll`,
`funlen`, `cyclop`, `dupl` thresholds) now live under `linters.settings` per
the v2 schema, so they are actually applied; no new lint findings surfaced
- convert Makefile targets to scripts-to-rule-them-all `script/` entrypoints
like the other managed repos (2026-07-26, commit `3abeacf`, closes
https://git.eeqj.de/sneak/sfdupes/issues/1): all 12 `script/` entrypoints
exist (`bootstrap`, `setup`, `projectname`, `test`, `lint`, `fmt`,
`fmt-check`, `check`, `docker`, `cibuild`, `precommit`, `install-precommit`)
and every Makefile target is now a thin shim over them
- make the binary the default Make target (2026-07-24, branch
`make-default-target`): plain `make` now builds `sfdupes` (previously it ran
`check` plus `build`); `make build` remains as an alias
- scan-wide phases, concurrent operands, batched updates (2026-07-24, branch
`scan-wide-phases`): all operands seed the shared walk pool and every pass
runs once over the whole scan, so totals and ETAs are scan-global; the
per-operand walk/hash/update cycles and their stderr announcements are gone;
the update pass commits in batched transactions — the filesystem is
authoritative and the database an eventually-consistent reflection, so
scan-level atomicity is not required
- split the stat pass back out of the walk (2026-07-24, branch
`parallel-phases`): phases are strictly sequential again — walk, stat, hash,
update per operand — with parallelism only inside each phase; the walk
enumerates paths with per-directory workers and the stat pass lstats them with
per-file workers, restoring the exact total/ETA stat bar
- announce each operand on stderr before its passes (2026-07-24, branch
`scan-operand-progress`): with per-operand walk/hash/update cycles, a
multi-operand run (e.g. `scan /srv/*`) showed pass totals that looked like the
whole run's
- parallel walk (2026-07-24, branch `parallel-walk`): the walk pass was a single
goroutine and took hours at ~20M files on a busy pool (observed: 22M files in
4h on a ZFS server); it is now a per-directory worker-pool traversal that
records size/mtime during the walk (folding away the separate stat pass,
halving metadata I/O), and each `PATH` operand commits in its own transaction
so an interrupted scan keeps completed operands
- `script/bootstrap` checked the installed golangci-lint version instead of only - persistent scan database (2026-07-24, branch `persistent-database`): `scan`
its presence. The version lived only in `GOLANGCI_LINT_VERSION`, with the now maintains a SQLite database (`modernc.org/sqlite`, pure Go, cgo stays
`go install` module ref derived from it, and any installed version that was disabled) keyed by absolute path that survives between runs — a rescan hashes
not the pin was reinstalled. `go install` writes into `GOBIN` (or only new or changed files (by mtime/size), deletes records for files vanished
`GOPATH/bin`) while `make lint` ran the first `golangci-lint` on `PATH`, so from under the scanned operands, and leaves records outside them untouched, so
bootstrap re-read the effective version after installing. On a mismatch it `scan` can be cronned daily; `report` and `trees` read the database (no
printed both paths and both versions and exited non-zero; it did not reorder positional arguments) instead of a scan stream. Database at
`PATH` or delete anyone's binary. The `--version` call kept its stderr and was `/var/lib/sfdupes/db.sqlite`, overridable via `SFDUPES_DATABASE`; WAL
bounded by `timeout(1)` where that exists. `git`, `make` and `go` kept
presence-only checks. 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)
- 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. 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 (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. `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 (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
`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)
- Makefile targets converted to scripts-to-rule-them-all `script/` entrypoints,
like the other managed repos. All 12 entrypoints exist, and every Makefile
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
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 stat pass split back out of the walk. Phases are strictly sequential
again, walk, stat, hash and update per operand, with parallelism only inside
each phase. The walk enumerates paths with per-directory workers, and the stat
pass lstats them with per-file workers, restoring the exact total 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`)
- 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
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
- add the `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`), tag `v0.0.1`,
- The `origin` remote (`git@git.eeqj.de:sneak/sfdupes.git`) added, `v0.0.1` and push `main` plus tags (2026-07-23)
tagged, and `main` plus tags pushed (2026-07-23) - `scan` CLI rework (2026-07-23, branch `scan-required-paths`): required
`PATH...` operands via cobra flags replacing the `/srv` `-root` default; new
- `scan` CLI rework. Required `PATH...` operands via cobra replace the `/srv` `-x`/`--one-file-system` flag (GNU convention) to stop at filesystem
`-root` default. The new `-x`/`--one-file-system` flag (GNU convention) stops boundaries, which are crossed by default
at filesystem boundaries, which are crossed by default (2026-07-23, branch - bring the repo into full policy compliance (2026-07-23, branch
`scan-required-paths`) `repo-policy-compliance`; checklist below)
- `git init` with README-only first commit; code baseline committed on `main`
- The repo brought into compliance with `REPO_POLICIES.md` (2026-07-06), the (2026-07-22)
existing repo checklist and the Go styleguide, audited 2026-07-22 (2026-07-23, - implement `scan`, `report`, and `trees` subcommands (pre-git history)
branch `repo-policy-compliance`). Added or fixed:
- `.gitignore`, with OS, editor, Go and secrets patterns; the compiled
`sfdupes` binary and `files.dat` had been sitting untracked in the tree;
- `.editorconfig`;
- `LICENSE` (MIT) and the README License section;
- `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`;
- a test suite covering parsing, grouping, digests, suppression, hashing and
the scan pipeline, at 64% coverage;
- a multistage `Dockerfile` with hash-pinned images, and `.dockerignore`;
- `.gitea/workflows/check.yml`, running `docker build .` on push with the
checkout action pinned by commit SHA;
- the README sections the policy requires;
- the stale README non-goal "no git repository setup and no CI" removed;
- the pre-commit hook installed.
- `git init` with a README-only first commit; the code baseline committed on
`main` (2026-07-22)
- `scan`, `report` and `trees` subcommands implemented (pre-git history)
# Future Steps # Future Steps
- Possible later features, out of scope per README: full-content verification of - possible later features (explicitly out of scope per README): full-content
candidates, and helper scripts for removal. verification of candidates, removal-script helpers
# Accepted Divergences # Repo Policy Compliance
- Flat single-package layout, with the `.go` files in the repo root. That is Audited 2026-07-22 against `REPO_POLICIES.md` (2026-07-06), the existing repo
fine for a small single-binary tool per the Go styleguide, and the tracker checklist, and the Go styleguide. Code is already gofmt-clean, so no standalone
audit agrees. formatting commit is needed.
- [x] `.gitignore` missing — the compiled `sfdupes` binary and `files.dat` sit
untracked in the tree; needs OS/editor/Go artifacts plus secrets patterns
- [x] `.editorconfig` missing
- [x] `LICENSE` missing and README has no License section (MIT assumed from
house convention — user to confirm)
- [x] `REPO_POLICIES.md` missing from repo root
- [x] `.golangci.yml` missing (install canonical copy); code must then pass
`make lint` (150 findings fixed; `make lint` is clean)
- [x] `Makefile` lacks required targets `test`, `lint`, `fmt`, `fmt-check`,
`docker`, `hooks`; `check` currently depends on `build`, which writes the
binary (`make check` must not modify files)
- [x] no tests — `go test ./...` has nothing to run; policy requires real tests
with a 30-second timeout and the conditional `-v` rerun pattern (suite
covers parsing, grouping, digests, suppression, hashing, and the scan
pipeline; 64% coverage)
- [x] `Dockerfile` missing — Go multistage with hash-pinned images: fail-fast
lint stage, build stage running `make check`
- [x] `.dockerignore` missing
- [x] `.gitea/workflows/check.yml` missing (`docker build .` on push, checkout
action pinned by commit SHA)
- [x] README lacks required sections: Description first line
(name/purpose/category/license/author), Getting Started, Rationale, TODO,
License, Author
- [x] README non-goal "no git repository setup and no CI" is stale now that the
repo is under git with CI
- [x] pre-commit hook not installed (`make hooks` once the target exists)
Accepted divergences (no action):
- flat single-package layout with `.go` files in the repo root — fine for a
small single-binary tool per the Go styleguide; the tracker audit agrees
+3 -1
View File
@@ -17,7 +17,9 @@ import (
func TestDefaultExcludes(t *testing.T) { func TestDefaultExcludes(t *testing.T) {
t.Parallel() t.Parallel()
// README "Rules for the walk": each system's list. // README "Rules for the walk": the Linux and macOS lists, and none
// on any other system. The Windows list exists in the code only: the
// binary does not build for Windows, so the README leaves it out.
cases := map[string][]string{ cases := map[string][]string{
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"}, goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
goosDarwin: { goosDarwin: {
+136 -25
View File
@@ -393,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
} }
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) { func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
// README §Database: the path names the file exactly. With link a // README §Settings: the path names the file exactly. With link a
// symlink to the directory real/sub, link/../db.sqlite names // symlink to the directory real/sub, link/../db.sqlite names
// real/db.sqlite, not db.sqlite beside link. Every subcommand must // real/db.sqlite, not db.sqlite beside link. Every subcommand must
// use that file, relative or absolute, and its first line must name // use that file, relative or absolute, and its first line must name
@@ -648,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
} }
func TestRunScanRejectsWorkersBelowOne(t *testing.T) { func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
// README §scan mode: --workers below 1 is a usage error reported in // README §Settings: --workers below 1 is a usage error reported in
// one line on stderr, before the scan opens the database. // one line on stderr, before the scan opens the database.
for _, workers := range []string{"0", "-1"} { for _, workers := range []string{"0", "-1"} {
t.Run(workers, func(t *testing.T) { t.Run(workers, func(t *testing.T) {
@@ -686,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
func TestRunHelp(t *testing.T) { func TestRunHelp(t *testing.T) {
t.Parallel() t.Parallel()
// README §Subcommands: help goes to stderr, exits 0, and leaves // README §Settings: help goes to stderr, exits 0, and leaves stdout
// stdout empty. // empty.
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}} cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
for _, args := range cases { for _, args := range cases {
@@ -713,8 +713,8 @@ func TestRunHelp(t *testing.T) {
func TestRunVersion(t *testing.T) { func TestRunVersion(t *testing.T) {
t.Parallel() t.Parallel()
// README §Subcommands: the version is one line on stdout, with // README §Settings: the version is one line on stdout, with nothing
// nothing on stderr, and exits 0. // on stderr, and exits 0.
info, _ := debug.ReadBuildInfo() info, _ := debug.ReadBuildInfo()
for _, arg := range []string{"--version", "-v"} { for _, arg := range []string{"--version", "-v"} {
@@ -773,8 +773,8 @@ func TestBuildVersion(t *testing.T) {
func TestRunVersionWriteFailureIsFatal(t *testing.T) { func TestRunVersionWriteFailureIsFatal(t *testing.T) {
t.Parallel() t.Parallel()
// README §Error handling: a stdout write failure exits 1, reported // README §Error handling: a failed write to stdout is a fatal error,
// in one line on stderr. // exit 1, reported on stderr starting with "sfdupes:".
var stderr bytes.Buffer var stderr bytes.Buffer
code := run([]string{"--version"}, failingWriter{}, &stderr) code := run([]string{"--version"}, failingWriter{}, &stderr)
@@ -928,24 +928,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs") zfs := filepath.Join(t.TempDir(), ".zfs")
snapshot := filepath.Join(zfs, "snapshot", "hourly") f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not // No scan records a file inside a .zfs directory, so the record
// itself named .zfs. // beneath the operand is written straight into the database.
scanOK(t, snapshot) path := seedDatabase(t, []scanRec{{size: 10, path: f}})
t.Setenv(databaseEnv, path)
assertOperandSkipped(t, path, zfs, ".zfs directory", f) assertOperandSkipped(t, path, zfs, ".zfs directory", f)
} }
// assertOperandSkipped scans operand alone and checks that it is skipped // assertOperandSkipped scans operand alone and checks that it is skipped
// as kind: a warning naming it, one skip in the summary, exit 0, and the // as kind: a warning naming it, one skip in the summary, exit 0, and the
// record for kept, which an earlier scan stored beneath operand, still // record for kept, stored beneath operand before this scan, still in the
// in the database at dbPath. // database at dbPath.
func assertOperandSkipped(t *testing.T, dbPath, operand, kind, func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
kept string, kept string,
) { ) {
@@ -974,6 +971,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
recordByPath(t, dbRecords(t, db), kept) recordByPath(t, dbRecords(t, db), kept)
} }
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
// directory, first alone and then under another operand. Each time it
// is warned about and counted as skipped, and nothing in it is walked.
// The other operand is scanned as usual.
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
writeFile(t, hourly, "f", pattern(1, 10))
f := writeFile(t, dir, "f", pattern(2, 10))
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
for _, tc := range []struct {
operands []string
summary string
}{
{
[]string{hourly},
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
{
[]string{dir, hourly},
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n",
},
} {
stderr := scanOK(t, tc.operands...)
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, tc.summary) {
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
stderr, warning, tc.summary)
}
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
// by the path given, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := t.TempDir()
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps")
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(snaps, "daily")
stderr := scanOK(t, daily)
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
if !strings.Contains(stderr, warning) ||
!strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
}
}
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to a directory outside any .zfs directory. The operand is
// scanned as usual, and its file is recorded by the path given.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := t.TempDir()
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data")
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(data, "daily")
stderr := scanOK(t, daily)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
}
func TestRunReportSucceeds(t *testing.T) { func TestRunReportSucceeds(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
@@ -1122,7 +1234,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
} }
func TestRunDatabasePathUsedAsGiven(t *testing.T) { func TestRunDatabasePathUsedAsGiven(t *testing.T) {
// README §Database: the path names the database file exactly. In // README §Settings: the path names the database file exactly. In
// SQLite's connection string an unescaped ? or # would end the file // SQLite's connection string an unescaped ? or # would end the file
// name and % would start an escape, and a path starting with // // name and % would start an escape, and a path starting with //
// could be read as a host name. %25 is a valid escape, so unescaped // could be read as a host name. %25 is a valid escape, so unescaped
@@ -1167,9 +1279,8 @@ func holdScanLock(t *testing.T, path string) {
} }
func TestRunSecondScanFails(t *testing.T) { func TestRunSecondScanFails(t *testing.T) {
// README §Database: while one scan holds the lock, a second scan // README §Database: a second scan that finds the lock held fails
// fails at once, naming the lock file, without opening the // at once, naming the lock file, without opening the database.
// database.
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
@@ -1228,7 +1339,7 @@ func assertSecondScanFails(t *testing.T, path string) {
} }
func TestRunSecondScanThroughSymlinkFails(t *testing.T) { func TestRunSecondScanThroughSymlinkFails(t *testing.T) {
// README §Database: every path to one database names the same lock // README §Database: a path through a symlink names the same lock
// file, so while a scan holds it through one path, a scan through // file, so while a scan holds it through one path, a scan through
// another fails, naming the lock file beside the database's real // another fails, naming the lock file beside the database's real
// path. // path.
@@ -1333,8 +1444,8 @@ func TestRunReportsDuringScan(t *testing.T) {
} }
func TestRunStdoutClosedIsFatal(t *testing.T) { func TestRunStdoutClosedIsFatal(t *testing.T) {
// README §Error handling: a stdout write failure exits 1, reported // README §Error handling: a failed write to stdout is a fatal error,
// in one line on stderr. // exit 1, reported on stderr starting with "sfdupes:".
for _, name := range []string{cmdReport, cmdTrees, cmdInfo} { for _, name := range []string{cmdReport, cmdTrees, cmdInfo} {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t)) t.Setenv(databaseEnv, testDBPath(t))
+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(); {
+3 -2
View File
@@ -1064,8 +1064,9 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
} }
} }
// buildSmokeTree recreates the README smoke-test filesystem layout // buildSmokeTree recreates the tree of the test under README
// with deterministic content and returns the tree root. // "Definition of done" with deterministic content and returns the tree
// root.
func buildSmokeTree(t *testing.T) string { func buildSmokeTree(t *testing.T) string {
t.Helper() t.Helper()
+3 -2
View File
@@ -15,8 +15,9 @@ import (
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " + const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
"remain unknown" "remain unknown"
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and // smokeTreeRecs mirrors the tree of the test under README "Definition
// /d/t2 are identical, /d/t3 differs from them only by one filename. // of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only
// by one filename.
func smokeTreeRecs() []scanRec { func smokeTreeRecs() []scanRec {
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c") f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c") f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")