Completed Steps entries keep what landed, the traps, every disclosure and every record that a check ran; the argument and history go, with bare issue numbers turned into full links. Comment blocks in script/, Dockerfile and Dockerfile.lint keep the trap and drop the defence of past decisions. TODO.md Workflow now branches from next, targets next, and leaves merging next to main to the owner. Only comments and Markdown change. Model: opus-5-5
24 KiB
Workflow
- take an issue from the
1.0.0milestone on the tracker; work not yet on the tracker gets filed as an issue first - branch from
next - do the work, with tests, in small focused commits
- record it at the top of Completed Steps (
TODO.mdchanges in the same commit as the work) - push the branch and open a PR against
nextwhose title ends with(closes #N) - an independent review gates each merge to
next; every finding is addressed or explicitly rebutted on the PR - only the owner merges
nexttomain
Status
- pre-1.0
- the Gitea tracker is authoritative for the pre-1.0 backlog: the open issues
under the
1.0.0milestone are what remains before the tag, and this file records history and process, not the queue
Next Step
- take the next issue from the
1.0.0milestone on the tracker: https://git.eeqj.de/sneak/sfdupes/milestone/17 — the milestone is the source 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
-
cut the narration from
TODO.mdCompleted Steps and from the comments inscript/and both Dockerfiles; §Workflow now branches from and merges tonext(2026-10-04, #49) -
make test-raceruns the test suite under the race detector in a cgo-enabled container, outsidemake check(2026-10-04, #18) -
a bare
docker build .fails with a message namingscript/cibuildandscript/dockerinstead of serving the gates from cache (2026-10-04, #39) -
make fmtandmake fmt-checkrun prettier over all Markdown, in Docker, and CI checks it; all Markdown reformatted (2026-10-04, #19) -
script/lintwrites no image, so a run no longer leaves an untagged one behind (2026-10-04, #48) -
tests cover a missing database,
scankeeping stdout empty, its skip warning, thereportandtreessummary lines, and every subcommand going throughrunE(2026-10-04, #16) -
.golangci.ymlreplaced with the current canonical copy, which usesgomodguard_v2, so lint no longer prints a deprecation warning (2026-10-04, #26) -
a test fails when either
hashWorkercancellation check inscan.gois removed (2026-10-04, #83) -
a database path holding
?,#or%opens exactly the file it names (2026-10-04, #55) -
scanrejects--workersbelow 1 as a usage error instead of running single-threaded (2026-10-04, #10) -
a test fails when either walk cancellation check in
scan.gois removed (2026-10-04, #81) -
test that
scanrefuses a database with another schema version (2026-10-04, #64) -
correct four inaccurate comments in
cancel_test.goand renamewalkCancelInFlightDirstowalkCancelInFlightFiles(2026-10-04, #33) -
test the
-xfilesystem-boundary rules insubdirJob(2026-10-04, #17) -
scancreates the schema in one transaction; a version-0 database with afilestable is refused with a clear schema-version error (2026-10-04, #11) -
README documents install, Docker, a daily cron scan and how to read and check the reports (2026-10-04, #54)
-
the
Dockerfilebuild stage keeps the Go module cache out ofbuilder's home and copies the sources with--chown, so nochown -Rwalks them (2026-10-04, #43) -
--versionprintssfdupes VERSIONto stdout; README documents it and--help(2026-10-04, #15) -
scanstops cleanly onSIGINTorSIGTERM: commits what it has hashed, deletes nothing more, exits 1 (2026-10-04, #5) -
reportandtreesstream the records instead of holding them all in memory; the schema gains thefiles_signatureindex (2026-10-04, #14) -
progress prints at once on a non-terminal, uses a real terminal test, and prints warnings through a spinner instead of racing its redraw (2026-10-03, #13)
-
warn about and skip symlink, socket, FIFO, device and
.zfsoperands, keeping the records beneath them (2026-10-03, #9) -
scanholds a lock on a lock file beside the database for its whole run, so a secondscanfails at once with exit 1 (2026-10-03, #53) -
test stdout write failures in
reportandtrees; README states that| headends sfdupes bySIGPIPEand>&-writes to/dev/null(2026-10-03, #30) -
reportandtreesopen the database read-only, andscanleaves it out of WAL mode, so reading needs only read access (2026-10-03, closes #8) -
escape tabs, newlines, carriage returns and backslashes in report, trees and warning paths; the root directory's path is
/(2026-10-03, #7) -
stamp the git tag or short commit in a plain
docker build .instead ofdev(2026-10-02, branchnext, closes #67):.dockerignoresends.gitwithout.git/config; the build stage stamps theVERSIONbuild argument, elsegit describe --tags --always, and fails if the context carries.gitand the version is still empty,devorunknown. CI checks out the full history (fetch-depth: 0) so it stamps the same value asmake build. -
replace the 1 KiB end-window sampling with the head/tail plus content-hash ladder (2026-09-22, branch
next, closes #61); README "Duplicate detection" documents every rung. A file under 10 MiB is hashed in full, and itshead,tailandcontentall hold that hash. A larger file gets only its 64 KiBheadandtailin the hash phase; the content phase, after the update phase, reads it forcontent(the whole file below 50 MiB, gigabyte-spaced 1 MiB samples at or above) only when its size,headandtailmatch another record's from this scan or an earlier one, and never reads a file gone or changed since its record was written.reportandtreesleave out any record without acontenthash. Thecontentcolumn is part of the version 1 schema. -
remove the dead
files.datreferences fromMakefile,.gitignoreand.dockerignore(2026-09-21, branchnext, closes #22) -
fix the lint-image pin comments and
FROMform inDockerfileandDockerfile.lint(2026-08-10, branchnext, closes #25): both pins are now the policy# image:vX.Y.Z, YYYY-MM-DDcomment over a bareFROM image@sha256:..., without the false(Debian-based)note or the tag; digest unchanged.script/verify-lint-image-pinstill matches the tagless form, and a tag on one side only is caught as a plain mismatch. -
run all linting in Docker via
Dockerfile.lintandscript/lint(2026-08-10, branchnext, closes #46): per the owner ruling the linter is never installed on a host.Dockerfile.lintcopies the repo into the digest-pinnedgolangci/golangci-lint:v2.12.2image and runsgolangci-lint config verifyandgolangci-lint runas build steps;script/lintbuilds it.script/bootstrapno longer installs or pins the linter, and warns rather than fails whendockeris absent;ENV PATH=/home/builder/go/bin:$PATHwent with itsgo install.script/verify-linter-pinis retired;script/verify-lint-image-pin, a gate in both files, compares their twoFROMlines and restates neither pin. Traps: an unchanged tree lets a lint build pass in under a second having run no linter, so every gateRUNreferencesARG CHECK_EPOCH(BuildKit hashes the expanded command) andscript/lintpasses"$(date +%s)-$$", the PID because two runs land in the same second easily. Nothing inside an image build may shell out to docker, so theDockerfilelint stage callsgolangci-lintdirectly and the build stage runsmake testandmake fmt-checkinstead ofmake check, throughmakebecause the Makefile'sexport CGO_ENABLED = 0only reaches what it invokes.COPY --from=lint /src/go.sum /dev/nullreplaces the copied linter binary as the only edge making the build stage wait for lint; dropping it would end fail-fast linting under a still-green build.golangci-lint config verify, included per the ruling, validates from an embedded schema with no network call, butgo mod downloadabove the gates still needs the network on a cold cache. Verified:make lintgreen with nogolangci-lintonPATH; two back-to-backscript/lintruns on an untouched tree both ran the linter (27.7s and 28.7s in the lint step,COPY . .CACHEDabove); a planted unused variable failedscript/lint, and failedmake dockerat[lint 9/9]with the build stage stopped at[builder 3/12]; the drift guard fails on a tag-only, a digest-only and an unreadable reference, naming both sides; under--network noneconfig verify passes a valid config and rejects an invalid one;make dockergreen in 5m35s with all six gates run under one epoch (lint 37.6s, test 25.2s reportingok sneak.berlin/go/sfdupes 1.938s coverage: 88.5%, not(cached)); in the builder image with the Go test cache off,--user 0:0still failsTestScanHardlinkRunFailsTogetherwhere the unprivileged user passes. Noted for follow-up, not fixed here:golangci-lintwarns thatgomodguardis deprecated since v2.12.0 in favour ofgomodguard_v2. -
install the Docker build stage's prerequisites by running
script/bootstrapinstead ofapk add --no-cache makeinline (2026-08-09, branchdockerfile-bootstrap, closes #42): the stage copiesscript/plusgo.mod/go.sumand runsscript/bootstrap, which ends ingo mod download, so the separate call to it is gone.COPY --from=lint /usr/bin/golangci-lintstays and moves above the bootstrap layer: it is the only edge making this stage depend on the lint stage, so deleting it would end fail-fast linting silently. A newscript/verify-linter-pin, run in the build stage before bootstrap, fails the build naming both versions unless that copied binary is the versionscript/bootstrappins; a pin it cannot read is a hard failure, not a skip.$GOPATH/binjoinsPATH, where bootstrap'sgo installlands. Everything added sits aboveARG CHECK_EPOCH, and thechownandUSER builderstill precedemake check. Verified: the guard fails the build with both versions named when the lint stage's linter is faked to another version, and passes an unmodified build; bootstrap runs clean under Alpine'sshandapk, finding the copied linter already at the pin; a second build served the bootstrap and dependency layersCACHEDwhile both gates ran with a fresh epoch; a plantedunusedfinding failed the build at the lint gate in 48.9s with the build stage'smake checknever starting; and the suite run in the image as--user 0:0failsTestScanHardlinkRunFailsTogether, so the drop to the unprivileged user is still needed. That last check needs the Go test cache off: as root it first reportedok ... (cached), reusing the build-time result. Build times on a noisy shared host: 2m13s on an unchanged tree, 2m17s and 4m29s after a source change, 5m14s cold, which breaches the policy ceiling;chown -R builder:builder /src /home/builderwalks the module cache and alone varied from 77s to 210s across those builds, andmainmeasured 5m03s cold with a 209schown. Filed as #43 -
bust the Docker layer cache for the gate steps, so
script/cibuildandscript/dockercannot report a green they did not earn (2026-08-09, branchcibuild-cache-bust, closes #32): theDockerfilecopies the tree before its gates, so on an unchanged tree Docker served them from cache and the build exited 0 having run nothing. Both scripts now pass--build-arg CHECK_EPOCH="$(date +%s)".ARGis per stage and the gates span two stages, so it is declared in both; BuildKit hashes the expanded command, so each gateRUNechoes the epoch, which also logs it as evidence the layer ran. It sits below the dependency layers so they stay cached. Verified underBUILDKIT_PROGRESS=plain, each script run twice back to back on an unchanged tree: all three gates ran on all four runs with a fresh epoch (script/cibuild78.8s then 61.1s;script/docker61.1s then 53.4s), and thirteen steps were still servedCACHED. With a plantedunusedfinding the build failed atmake lintin 36.1s and the build-stagemake checknever started. Run as root, the same image failsTestScanHardlinkRunFailsTogether, because root reads through thechmod(0)the test relies on, so the build stage must drop to the unprivilegedbuilderuser. Local fix only; propagating it to the canonical templates is sneak/prompts#26 -
check the installed golangci-lint version in
script/bootstrapinstead of only its presence (2026-08-09, branchbootstrap-version-check, closes #24): the version lives only inGOLANGCI_LINT_VERSION, with thego installmodule ref derived from it, and any installed version that is not the pin — older, newer, absent or unparseable — is reinstalled.go installwrites intoGOBIN(orGOPATH/bin) whilemake lintruns the firstgolangci-lintonPATH, 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 reorderPATHor delete anyone's binary. The--versioncall keeps its stderr and is bounded bytimeout(1)where that exists.git,makeandgokeep 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 underdashcovering a thirteen-input version-parse matrix, a shadowed install that must exit non-zero, an install destination not onPATH,GOBINset, and a wedged binary that must hit the timeout;make checkandmake lintare clean at v2.12.2, so v2.10.1 was not hiding any findings onmain -
unwind the hash worker pool on the error path (2026-08-09, branch
hash-pool-cleanup, closes #6): the pool is now an owned, context-awarehashPool: every blocking send in the feeder and the workers selects onctx.Done(),jobsis closed on every path out, andhashPhasedeferspool.stop(), which cancels and then drainsresultsuntil the last goroutine has exited — draining is what frees a worker already parked on a send.ctxis threaded fromcmd.Context()throughrunScan,syncScan, both worker pools and the whole database layer, as the first parameter everywhere. The walk pool gets the same treatment plus actx.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 ownBeginTxalso 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 driverun(scan)against a database whose insert trigger aborts and assert that the scan fails instead of hanging and thatruntime.NumGoroutine()polls back to its pre-scan baseline; others cancel a scan part-way through the walk, deterministically, by counting its own consultations ofctx.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 ofsendEvent, the walk workers,dispatchDirs,feedHashJobs,hashWorkerandhashPhasecover 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 #4):fatalfand itsos.Exit(1)are gone, so the deferreddb.Close()— and with it the SQLite WAL checkpoint — now actually runs when a subcommand fails;runScan,runReport,runTrees,loadRecordsandresolveRootsreturn errors instead. The single exit point isruninmain.go: it maps afatalError(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. Newmain_test.godrives 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/-shmsidecar behind forscan,reportortrees -
update golangci-lint to v2.12.2 with the canonical config (2026-08-09, branch
golangci-v2.12.2, merged as38a01bd, closes #3): bumped the pinned linter in theDockerfilelint stage andscript/bootstrapfrom v2.12.1 to v2.12.2, and replaced.golangci.ymlwith the canonical file — the linter settings (lll,funlen,cyclop,duplthresholds) now live underlinters.settingsper 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, commit3abeacf, closes #1): all 12script/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): plainmakenow buildssfdupes(previously it rancheckplusbuild);make buildremains 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 eachPATHoperand commits in its own transaction so an interrupted scan keeps completed operands -
persistent scan database (2026-07-24, branch
persistent-database):scannow maintains a SQLite database (modernc.org/sqlite, pure Go, cgo stays disabled) keyed by absolute path that survives between runs — a rescan hashes only new or changed files (by mtime/size), deletes records for files vanished from under the scanned operands, and leaves records outside them untouched, soscancan be cronned daily;reportandtreesread the database (no positional arguments) instead of a scan stream. Database at/var/lib/sfdupes/db.sqlite, overridable viaSFDUPES_DATABASE; WAL journaling plus a single-transaction update keep a report run during a scan safe -
add the
originremote (git@git.eeqj.de:sneak/sfdupes.git), tagv0.0.1, and pushmainplus tags (2026-07-23) -
scanCLI rework (2026-07-23, branchscan-required-paths): requiredPATH...operands via cobra flags replacing the/srv-rootdefault; new-x/--one-file-systemflag (GNU convention) to stop at filesystem boundaries, which are crossed by default -
bring the repo into full policy compliance (2026-07-23, branch
repo-policy-compliance; checklist below) -
git initwith README-only first commit; code baseline committed onmain(2026-07-22) -
implement
scan,report, andtreessubcommands (pre-git history)
Future Steps
- possible later features (explicitly out of scope per README): full-content verification of candidates, removal-script helpers
Repo Policy Compliance
Audited 2026-07-22 against REPO_POLICIES.md (2026-07-06), the existing repo
checklist, and the Go styleguide. Code is already gofmt-clean, so no standalone
formatting commit is needed.
.gitignoremissing — the compiledsfdupesbinary andfiles.datsit untracked in the tree; needs OS/editor/Go artifacts plus secrets patterns.editorconfigmissingLICENSEmissing and README has no License section (MIT assumed from house convention — user to confirm)REPO_POLICIES.mdmissing from repo root.golangci.ymlmissing (install canonical copy); code must then passmake lint(150 findings fixed;make lintis clean)Makefilelacks required targetstest,lint,fmt,fmt-check,docker,hooks;checkcurrently depends onbuild, which writes the binary (make checkmust not modify files)- no tests —
go test ./...has nothing to run; policy requires real tests with a 30-second timeout and the conditional-vrerun pattern (suite covers parsing, grouping, digests, suppression, hashing, and the scan pipeline; 64% coverage) Dockerfilemissing — Go multistage with hash-pinned images: fail-fast lint stage, build stage runningmake check.dockerignoremissing.gitea/workflows/check.ymlmissing (docker build .on push, checkout action pinned by commit SHA)- README lacks required sections: Description first line (name/purpose/category/license/author), Getting Started, Rationale, TODO, License, Author
- README non-goal "no git repository setup and no CI" is stale now that the repo is under git with CI
- pre-commit hook not installed (
make hooksonce the target exists)
Accepted divergences (no action):
- flat single-package layout with
.gofiles in the repo root — fine for a small single-binary tool per the Go styleguide; the tracker audit agrees make testruns without-race— the repo mandatesCGO_ENABLED=0(pure-Go builds) and the race detector requires cgo, so the detector runs in a separate cgo-enabled container,make test-race, which is not part ofmake check