Canonical REPO_POLICIES.md:97 requires Dockerfiles to install development prerequisites by running script/bootstrap rather than duplicating installs inline. The build stage did the opposite: an inline `apk add --no-cache make` and its own `go mod download`, so it maintained a second, independent notion of the toolchain — the local-versus-CI divergence #24 exists to close, reintroduced one layer down. The stage now copies script/ plus go.mod/go.sum and runs script/bootstrap, which ends in `go mod download`. COPY --from=lint /usr/bin/golangci-lint is kept and moved above the bootstrap layer. It is the only edge making this stage depend on the lint stage, so removing it as redundant would silently stop the build gating on lint. Copying it first also puts it on PATH before bootstrap runs, so bootstrap's version check compares the lint stage's linter against the pin on every build: the two stages are now provably one toolchain rather than two that happen to agree, and bootstrap does not pay for a from-source build of its own linter. $GOPATH/bin joins PATH so that if the copied binary ever stops matching the pin, bootstrap's reinstall lands somewhere PATH resolves instead of failing its own verification. All of it sits above ARG CHECK_EPOCH, and the chown and USER builder still precede make check.
18 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
main) - 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 whose title ends with
(closes #N) - an independent review gates the merge; every finding is addressed or explicitly rebutted on the PR
- merge to
mainonce the review passes
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
-
install the Docker build stage's prerequisites by running
script/bootstrapinstead ofapk add --no-cache makeinline (2026-08-09, branchdockerfile-bootstrap, closes #42): canonicalREPO_POLICIES.md:97requires it, and the inline install left the build stage maintaining its own notion of the toolchain — exactly the divergence #24 exists to close, one layer down. The stage now copiesscript/plusgo.mod/go.sumand runsscript/bootstrap, which ends ingo mod download, so the separate invocation of that 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 as redundant would end fail-fast linting silently; putting it first also means bootstrap's version check now compares the lint stage's linter against the pin on every build, which is what makes the two stages provably one toolchain instead of two that happen to agree. Letting bootstrap install its own linter here would have reintroduced the second toolchain and paid for a from-source build of it.$GOPATH/binjoinsPATHso that if the copied binary ever stops matching the pin, bootstrap's reinstall lands somewherePATHresolves rather than failing its own verification. Everything added sits aboveARG CHECK_EPOCH, and thechownandUSER builderstill precedemake check. Verified: bootstrap runs clean under Alpine'sshand itsapkbranch, installinggitandmakeand 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 load-bearing. That last check needs the Go test cache disabled — the first attempt reportedok ... (cached)as root, reusing the result the build-time run had left in the shared cache, which would have read as a pass. Build wall time, on a shared host running many concurrent builds and so noisy: 2m13s on an unchanged tree, 2m17s and 4m29s for two builds after a source change, 5m14s cold. Only the cold one breaches the policy ceiling, and not because of this change —chown -R builder:builder /src /home/builderwalks the module cache and re-runs on every source change, and it alone varied between 77s and 210s across those four builds, which is also the whole spread in the totals. The same cold measurement againstmainis 5m03s 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): both scripts were baredocker buildinvocations with no cache control, and theDockerfilecopies the tree before running its gates, so on an unchanged tree Docker served those layers from cache and the build exited 0 having executed nothing. That is not hypothetical here — every merge this repo has done is a non-fast-forward merge of an undiverged branch, so each merge commit's tree is byte-identical to the branch head's and each merge CI run was almost certainly a full cache hit; and PR #31's reviewer foundmake dockerreturning success as a 17-layer cache hit, catching it only by being suspicious. The fix isARG CHECK_EPOCHwith the scripts passing--build-arg CHECK_EPOCH="$(date +%s)". Two details make or break it.ARGis scoped per stage and thisDockerfilehas three gates across two —make fmt-checkandmake lintin the lint stage,make checkin the build stage — so a single declaration would have left one stage silently cacheable; it is declared in both. And BuildKit hashes the expanded command, not the declaration, so a declared-but-unreferencedARGinvalidates nothing: each gateRUNechoes the epoch, which also puts the value in the build log as evidence the layer really ran. Placement is below the dependency layers on purpose — a build that goes cold every time would be a different bug, not a fix. Verified by running each script twice back to back on an unchanged tree underBUILDKIT_PROGRESS=plain: all three gates executed on all four runs, each with a fresh epoch in the log (script/cibuild78.8s then 61.1s;script/docker61.1s then 53.4s), and twelve steps were still servedCACHEDin the steady state — bothgo mod downloads,apk add,adduser, thechown, everygo.mod/go.sumand source copy, the linter copy out of the lint stage, and the binary copy into the runtime stage. The lint stage still gates the build stage: with a deliberateunusedfinding planted in the tree, the build failed atmake lintin 36.1s and the build-stagemake checknever started. The build stage also still drops to the unprivilegedbuilderuser beforemake check, which the suite depends on rather than merely prefers: forcing the same image to run the tests as root failsTestScanHardlinkRunFailsTogether, because root reads straight through thechmod(0)the test uses to prove hard links are read once. This is the local fix only; propagating it to the canonical templates isprompts#26 -
check the installed golangci-lint version in
script/bootstrapinstead of only its presence (2026-08-09, branchbootstrap-version-check, closes #24):missing golangci-lintmeant any linter already onPATHsatisfied the check, so the pin was never consulted and the v2.12.2 bump from #3 was inert on every host that already had one — this host ran v2.10.1 against a v2.12.2 pin,make checkwent green, andmake dockerthen rejected the same commit with findings the local gate never saw. The version now lives in one place,GOLANGCI_LINT_VERSION, with thego installmodule ref derived from it so a bump cannot half-apply; agolangci_lint_versionhelper parsesgolangci-lint --version(taking the field after the wordversionand tolerating an optional leadingv, which the module ref carries and the binary's output does not), and any version that is not the pin — older, newer, absent or unparseable — is reinstalled. The install is then verified against the binaryPATHactually resolves:go installwrites intoGOBIN(orGOPATH/bin) whilemake lintruns whichevergolangci-lintcomes first onPATH, so a wrong-version one sitting ahead of it — nix, apt, brew, apk, or the/usr/local/bincopy theDockerfilebuilder stage makes — would swallow the install and leave the local gate disagreeing with CI under an affirmativebootstrap complete. Bootstrap now re-reads the effective version after installing and, on a mismatch, prints both paths and both versions to stderr and exits non-zero instead of claiming success; it does not reorder anyone'sPATHor delete their binary. The--versioncall keeps its stderr connected, so a present-but-broken binary says why rather than reinstalling forever in silence, and is bounded bytimeout(1)where that exists, so a wedged binary cannot hang bootstrap.git,makeandgokeep their presence-only checks and now say why in a comment: they are host package-manager tools the repo deliberately does not pin, withgo.modgoverning the language version and the digest-pinned images covering reproducible builds. Verified on this host by bootstrapping from v2.10.1 to v2.12.2 and running it again to a no-op, plus stub runs of the real script underdashcovering a thirteen-input parse matrix (absent, older, newer, host-style, image-style, leading-v, stderr-only, empty, non-zero exit, impostor binary,(devel), trailingversion), a shadowed install that must exit non-zero, an install destination not onPATHat all,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):hashPhaseused to return the momentrecordRunfailed and abandon the pool — the feeder parked forever on a fulljobschannel and every worker on a fullresultschannel. That only stopped being invisible when #4 landed andrunScanbegan unwinding instead of callingos.Exit. 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 (it is the first parameter everywhere), so #5 can hand this path a signal and needs to add nothing else. The walk pool never leaked, becausewalkPhasealways drains its events to close, but it has the same unbounded-send shape and #5 will give it an early return, so it 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 ownBeginTxfails on the same cancelled context before deleting anything, so the guard is defence in depth rather than the only barrier — but it is the one that survives #5 deciding an interrupted scan may commit what it has. Tests driverun(scan)against a database whose insert trigger aborts, and assert both that the scan fails instead of hanging and thatruntime.NumGoroutine()polls back to its pre-scan baseline; a second set cancels a scan part-way through the walk — deterministically, by counting the scan's own consultations ofctx.Done()rather than racing a timer — and asserts that it stops at the guard holding a partial census and a still-populated record index, with every record intact. The remaining cancellation branches of both pools are covered by direct tests ofsendEvent, the walk workers,dispatchDirs,feedHashJobs,hashWorkerandhashPhase -
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, matching the other managed repos -
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 — an operator watching operand 3 of 14 hash 300k files concluded 20M files were being skipped -
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 go testruns without-race— the repo mandatesCGO_ENABLED=0(pure-Go builds) and the race detector requires cgo