Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b46d374b1 | ||
|
|
ee09cda355 |
@@ -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
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user