Files
sfdupes/TODO.md
clawbot 964fc29ed3
All checks were successful
check / check (push) Successful in 1m50s
Bust the Docker layer cache for the gate steps (closes #32)
script/cibuild and script/docker were bare docker build invocations
with no cache control, and the Dockerfile copies the tree before
running its gates. On an unchanged tree Docker served those layers
from cache, so the gates never executed and the build still exited 0.
A merge commit here has a tree byte-identical to the branch head it
merges, so every merge CI run was almost certainly a full cache hit,
and PR #31's reviewer caught make docker returning success as a
17-layer cache hit that proved nothing.

Declare ARG CHECK_EPOCH in both stages and have the scripts pass
--build-arg CHECK_EPOCH="$(date +%s)". ARG is scoped per stage and
this Dockerfile has three gates across two of them (make fmt-check and
make lint in the lint stage, make check in the build stage), so one
declaration would have left a stage silently cacheable. BuildKit
hashes the expanded command rather than the declaration, so each gate
RUN echoes the epoch: an unreferenced ARG invalidates nothing, and the
echo doubles as evidence in the build log that the layer really ran.

Both declarations sit below the dependency layers, so the pinned base
images, go mod download, apk add and the source copies keep their
cache and only the gates go cold. The build-stage declaration sits
after USER, so the drop to the unprivileged builder user still happens
before make check and the chmod(0) permission tests stay real.
2026-08-09 07:04:35 +00:00

16 KiB

Workflow

  • take an issue from the 1.0.0 milestone 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.md changes 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 main once 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.0 milestone 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.0 milestone 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

  • 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 cibuild-cache-bust, closes #32): both scripts were bare docker build invocations with no cache control, and the Dockerfile copies 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 found make docker returning success as a 17-layer cache hit, catching it only by being suspicious. The fix is ARG CHECK_EPOCH with the scripts passing --build-arg CHECK_EPOCH="$(date +%s)". Two details make or break it. ARG is scoped per stage and this Dockerfile has three gates across two — make fmt-check and make lint in the lint stage, make check in 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-unreferenced ARG invalidates nothing: each gate RUN echoes 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 under BUILDKIT_PROGRESS=plain: all three gates executed on all four runs, each with a fresh epoch in the log (script/cibuild 78.8s then 61.1s; script/docker 61.1s then 53.4s), and twelve steps were still served CACHED in the steady state — both go mod downloads, apk add, adduser, the chown, every go.mod/go.sum and 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 deliberate unused finding planted in the tree, the build failed at make lint in 36.1s and the build-stage make check never started. The build stage also still drops to the unprivileged builder user before make check, which the suite depends on rather than merely prefers: forcing the same image to run the tests as root fails TestScanHardlinkRunFailsTogether, because root reads straight through the chmod(0) the test uses to prove hard links are read once. This is the local fix only; propagating it to the canonical templates is prompts #26

  • check the installed golangci-lint version in script/bootstrap instead of only its presence (2026-08-09, branch bootstrap-version-check, closes #24): missing golangci-lint meant any linter already on PATH satisfied 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 check went green, and make docker then rejected the same commit with findings the local gate never saw. The version now lives in one place, GOLANGCI_LINT_VERSION, with the go install module ref derived from it so a bump cannot half-apply; a golangci_lint_version helper parses golangci-lint --version (taking the field after the word version and tolerating an optional leading v, 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 binary PATH actually resolves: go install writes into GOBIN (or GOPATH/bin) while make lint runs whichever golangci-lint comes first on PATH, so a wrong-version one sitting ahead of it — nix, apt, brew, apk, or the /usr/local/bin copy the Dockerfile builder stage makes — would swallow the install and leave the local gate disagreeing with CI under an affirmative bootstrap 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's PATH or delete their binary. The --version call keeps its stderr connected, so a present-but-broken binary says why rather than reinstalling forever in silence, and is bounded by timeout(1) where that exists, so a wedged binary cannot hang bootstrap. git, make and go keep their presence-only checks and now say why in a comment: they are host package-manager tools the repo deliberately does not pin, with go.mod governing 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 under dash covering a thirteen-input parse matrix (absent, older, newer, host-style, image-style, leading-v, stderr-only, empty, non-zero exit, impostor binary, (devel), trailing version), a shadowed install that must exit non-zero, an install destination not on PATH at all, 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 #6): hashPhase used to return the moment recordRun failed and abandon the pool — the feeder parked forever on a full jobs channel and every worker on a full results channel. That only stopped being invisible when #4 landed and runScan began unwinding instead of calling os.Exit. 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 (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, because walkPhase always 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 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 fails 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 drive run(scan) against a database whose insert trigger aborts, and assert both that the scan fails instead of hanging and that runtime.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 of ctx.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 of sendEvent, the walk workers, dispatchDirs, feedHashJobs, hashWorker and hashPhase

  • guarantee the database is closed on every fatal exit path (2026-08-09, branch db-close-on-fatal, closes #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 #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 #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, matching the other managed repos

  • 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 — 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 each PATH operand commits in its own transaction so an interrupted scan keeps completed operands

  • persistent scan database (2026-07-24, branch persistent-database): scan now 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, so scan can be cronned daily; report and trees read the database (no positional arguments) instead of a scan stream. Database at /var/lib/sfdupes/db.sqlite, overridable via SFDUPES_DATABASE; WAL journaling plus a single-transaction update keep a report run during a scan safe

  • add the origin remote (git@git.eeqj.de:sneak/sfdupes.git), tag v0.0.1, and push main plus tags (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 -x/--one-file-system flag (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 init with README-only first commit; code baseline committed on main (2026-07-22)

  • implement scan, report, and trees subcommands (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.

  • .gitignore missing — the compiled sfdupes binary and files.dat sit untracked in the tree; needs OS/editor/Go artifacts plus secrets patterns
  • .editorconfig missing
  • LICENSE missing and README has no License section (MIT assumed from house convention — user to confirm)
  • REPO_POLICIES.md missing from repo root
  • .golangci.yml missing (install canonical copy); code must then pass make lint (150 findings fixed; make lint is clean)
  • 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)
  • 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)
  • Dockerfile missing — Go multistage with hash-pinned images: fail-fast lint stage, build stage running make check
  • .dockerignore missing
  • .gitea/workflows/check.yml missing (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 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
  • go test runs without -race — the repo mandates CGO_ENABLED=0 (pure-Go builds) and the race detector requires cgo