fatalf (main.go:105-108) calls os.Exit(1), which does not run deferred functions. Every defer db.Close() in the codebase is therefore dead on the fatal path:
report.go:40 registers the close, and report.go:44 calls fatalf.
Consequence: on a fatal error the SQLite connection is never closed, so no WAL checkpoint runs and the -wal / -shm sidecar files are left for the next process to recover. It also makes these paths untestable in-process, which blocks the CLI-surface tests (#16) and the interrupt work (#5).
Definition of done
runScan, runReport and runTrees return errors instead of calling fatalf from inside a function that owns an open database; the single exit point lives in main (or in a thin wrapper it calls).
db.Close() is guaranteed to run on every path — success, per-file-error, and fatal.
Exit codes are unchanged and still match README §Error handling: 0 success, 1 fatal, 2 usage.
A test triggers a fatal error after the database is open and asserts the database closes cleanly, with no -wal file left behind.
make check green.
`fatalf` (`main.go:105-108`) calls `os.Exit(1)`, which does not run deferred functions. Every `defer db.Close()` in the codebase is therefore dead on the fatal path:
- `scan.go:66` registers `defer func() { _ = db.Close() }()`, and `scan.go:70` calls `fatalf`.
- `report.go:40` registers the close, and `report.go:44` calls `fatalf`.
Consequence: on a fatal error the SQLite connection is never closed, so no WAL checkpoint runs and the `-wal` / `-shm` sidecar files are left for the next process to recover. It also makes these paths untestable in-process, which blocks the CLI-surface tests (#16) and the interrupt work (#5).
## Definition of done
1. `runScan`, `runReport` and `runTrees` return errors instead of calling `fatalf` from inside a function that owns an open database; the single exit point lives in `main` (or in a thin wrapper it calls).
2. `db.Close()` is guaranteed to run on every path — success, per-file-error, and fatal.
3. Exit codes are unchanged and still match README §Error handling: 0 success, 1 fatal, 2 usage.
4. A test triggers a fatal error after the database is open and asserts the database closes cleanly, with no `-wal` file left behind.
5. `make check` green.
clawbot
added this to the 1.0.0 milestone 2026-08-09 03:43:31 +02:00
Implementation plan (branch db-close-on-fatal, from main at ce6d29d):
1. Single exit point in main.go
main becomes os.Exit(run(os.Args[1:], os.Stderr)).
run(args []string, stderr io.Writer) int builds the command tree
(newRootCommand(stderr)), executes it, and maps the outcome to an
exit code. It is the only place that knows about exit codes, and it
is callable from tests in-process.
fatalf and its os.Exit(1) are deleted.
2. Not turning fatal errors into usage errors
Cobra prints the error and the usage text for anything RunE
returns, and today main.go maps every Execute() error to exit 2.
So the error class has to be carried explicitly:
a fatalError wrapper type marks "the command ran and failed" —
runtime failure, exit 1;
a small runE adapter wraps each subcommand implementation: it sets cmd.SilenceUsage/cmd.SilenceErrors before calling it (so a
runtime failure never dumps usage text) and wraps a non-nil error in fatalError;
run reports a fatalError as sfdupes: <err> on stderr and
returns 1; anything else coming out of Execute is a cobra
argument/flag/unknown-command error, which cobra has already printed
with its usage text, and returns 2. Cobra's own usage output is
therefore byte-for-byte what it is today.
the bare sfdupes (no subcommand) case keeps printing usage and
exiting 2: its RunE silences only the error message and returns a
package-level sentinel, so cobra prints the usage text and run
falls through to the exit-2 branch.
3. Subcommands return errors
runScan(...) error, runReport() error, runTrees() error, loadRecords() ([]scanRec, error), resolveRoots(...) ([]string, error); every fatalf call site becomes a wrapped return, with
the same message text as today (update database %s: %v etc.).
defer func() { _ = db.Close() }() in runScan and loadRecords
then actually runs on the fatal path; nothing between the open and
the return calls os.Exit any more.
4. Tests (new main_test.go)
TestOpenDatabaseCreatesWAL: an open database has a -wal sidecar
and a Close removes it. This gives the assertions below teeth —
without it, "no -wal afterwards" could pass vacuously.
fatal-after-open, one subtest each for scan, report and trees:
a database file that opens cleanly and passes the schema-version
check but has no files table, so the first query fails with the
database open. Asserts exit code 1, no -wal/-shm left beside the
database, sfdupes: on stderr, and no usage text.
a nonexistent PATH operand is fatal (exit 1, no usage text), per
README §Error handling.
usage errors are exit 2 with usage text: no subcommand, scan with
no operand, report/trees with a positional argument, unknown
subcommand.
success is exit 0, including a scan whose per-file warnings skipped
a file (unreadable file, as the existing hard-link test does), and
stdout carries only the TSV data (README design goal 4).
5. Out of scope (tracked separately, deliberately untouched): #5 signals, #6 pool leak on error, #8 read-only database, #16 CLI
surface tests. Exit-code semantics and all message text stay exactly
as README §Error handling specifies.
TODO.md gets its Completed Steps entry in the same commit; make check plus make docker (the digest-pinned v2.12.2 lint stage) before
the PR.
Implementation plan (branch `db-close-on-fatal`, from `main` at `ce6d29d`):
**1. Single exit point in `main.go`**
- `main` becomes `os.Exit(run(os.Args[1:], os.Stderr))`.
- `run(args []string, stderr io.Writer) int` builds the command tree
(`newRootCommand(stderr)`), executes it, and maps the outcome to an
exit code. It is the only place that knows about exit codes, and it
is callable from tests in-process.
- `fatalf` and its `os.Exit(1)` are deleted.
**2. Not turning fatal errors into usage errors**
Cobra prints the error *and* the usage text for anything `RunE`
returns, and today `main.go` maps every `Execute()` error to exit 2.
So the error class has to be carried explicitly:
- a `fatalError` wrapper type marks "the command ran and failed" —
runtime failure, exit 1;
- a small `runE` adapter wraps each subcommand implementation: it sets
`cmd.SilenceUsage`/`cmd.SilenceErrors` before calling it (so a
runtime failure never dumps usage text) and wraps a non-nil error in
`fatalError`;
- `run` reports a `fatalError` as `sfdupes: <err>` on stderr and
returns 1; anything else coming out of `Execute` is a cobra
argument/flag/unknown-command error, which cobra has already printed
with its usage text, and returns 2. Cobra's own usage output is
therefore byte-for-byte what it is today.
- the bare `sfdupes` (no subcommand) case keeps printing usage and
exiting 2: its `RunE` silences only the error message and returns a
package-level sentinel, so cobra prints the usage text and `run`
falls through to the exit-2 branch.
**3. Subcommands return errors**
- `runScan(...) error`, `runReport() error`, `runTrees() error`,
`loadRecords() ([]scanRec, error)`, `resolveRoots(...) ([]string,
error)`; every `fatalf` call site becomes a wrapped `return`, with
the same message text as today (`update database %s: %v` etc.).
- `defer func() { _ = db.Close() }()` in `runScan` and `loadRecords`
then actually runs on the fatal path; nothing between the open and
the return calls `os.Exit` any more.
**4. Tests (new `main_test.go`)**
- `TestOpenDatabaseCreatesWAL`: an open database has a `-wal` sidecar
and a `Close` removes it. This gives the assertions below teeth —
without it, "no `-wal` afterwards" could pass vacuously.
- fatal-after-open, one subtest each for `scan`, `report` and `trees`:
a database file that opens cleanly and passes the schema-version
check but has no `files` table, so the first query fails with the
database open. Asserts exit code 1, no `-wal`/`-shm` left beside the
database, `sfdupes: ` on stderr, and no usage text.
- a nonexistent `PATH` operand is fatal (exit 1, no usage text), per
README §Error handling.
- usage errors are exit 2 with usage text: no subcommand, `scan` with
no operand, `report`/`trees` with a positional argument, unknown
subcommand.
- success is exit 0, including a scan whose per-file warnings skipped
a file (unreadable file, as the existing hard-link test does), and
stdout carries only the TSV data (README design goal 4).
**5. Out of scope** (tracked separately, deliberately untouched):
#5 signals, #6 pool leak on error, #8 read-only database, #16 CLI
surface tests. Exit-code semantics and all message text stay exactly
as README §Error handling specifies.
`TODO.md` gets its Completed Steps entry in the same commit; `make
check` plus `make docker` (the digest-pinned v2.12.2 lint stage) before
the PR.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fatalf(main.go:105-108) callsos.Exit(1), which does not run deferred functions. Everydefer db.Close()in the codebase is therefore dead on the fatal path:scan.go:66registersdefer func() { _ = db.Close() }(), andscan.go:70callsfatalf.report.go:40registers the close, andreport.go:44callsfatalf.Consequence: on a fatal error the SQLite connection is never closed, so no WAL checkpoint runs and the
-wal/-shmsidecar files are left for the next process to recover. It also makes these paths untestable in-process, which blocks the CLI-surface tests (#16) and the interrupt work (#5).Definition of done
runScan,runReportandrunTreesreturn errors instead of callingfatalffrom inside a function that owns an open database; the single exit point lives inmain(or in a thin wrapper it calls).db.Close()is guaranteed to run on every path — success, per-file-error, and fatal.-walfile left behind.make checkgreen.Implementation plan (branch
db-close-on-fatal, frommainatce6d29d):1. Single exit point in
main.gomainbecomesos.Exit(run(os.Args[1:], os.Stderr)).run(args []string, stderr io.Writer) intbuilds the command tree(
newRootCommand(stderr)), executes it, and maps the outcome to anexit code. It is the only place that knows about exit codes, and it
is callable from tests in-process.
fatalfand itsos.Exit(1)are deleted.2. Not turning fatal errors into usage errors
Cobra prints the error and the usage text for anything
RunEreturns, and today
main.gomaps everyExecute()error to exit 2.So the error class has to be carried explicitly:
fatalErrorwrapper type marks "the command ran and failed" —runtime failure, exit 1;
runEadapter wraps each subcommand implementation: it setscmd.SilenceUsage/cmd.SilenceErrorsbefore calling it (so aruntime failure never dumps usage text) and wraps a non-nil error in
fatalError;runreports afatalErrorassfdupes: <err>on stderr andreturns 1; anything else coming out of
Executeis a cobraargument/flag/unknown-command error, which cobra has already printed
with its usage text, and returns 2. Cobra's own usage output is
therefore byte-for-byte what it is today.
sfdupes(no subcommand) case keeps printing usage andexiting 2: its
RunEsilences only the error message and returns apackage-level sentinel, so cobra prints the usage text and
runfalls through to the exit-2 branch.
3. Subcommands return errors
runScan(...) error,runReport() error,runTrees() error,loadRecords() ([]scanRec, error),resolveRoots(...) ([]string, error); everyfatalfcall site becomes a wrappedreturn, withthe same message text as today (
update database %s: %vetc.).defer func() { _ = db.Close() }()inrunScanandloadRecordsthen actually runs on the fatal path; nothing between the open and
the return calls
os.Exitany more.4. Tests (new
main_test.go)TestOpenDatabaseCreatesWAL: an open database has a-walsidecarand a
Closeremoves it. This gives the assertions below teeth —without it, "no
-walafterwards" could pass vacuously.scan,reportandtrees:a database file that opens cleanly and passes the schema-version
check but has no
filestable, so the first query fails with thedatabase open. Asserts exit code 1, no
-wal/-shmleft beside thedatabase,
sfdupes:on stderr, and no usage text.PATHoperand is fatal (exit 1, no usage text), perREADME §Error handling.
scanwithno operand,
report/treeswith a positional argument, unknownsubcommand.
a file (unreadable file, as the existing hard-link test does), and
stdout carries only the TSV data (README design goal 4).
5. Out of scope (tracked separately, deliberately untouched):
#5 signals, #6 pool leak on error, #8 read-only database, #16 CLI
surface tests. Exit-code semantics and all message text stay exactly
as README §Error handling specifies.
TODO.mdgets its Completed Steps entry in the same commit;make checkplusmake docker(the digest-pinned v2.12.2 lint stage) beforethe PR.