Leave removed copies out of report and trees (closes #173)
check / check (push) Canceled after 0s

A deleted duplicate stayed in both reports until a scan covered it, and
a missing scan operand is a fatal error. report now runs lstat on each
path of a possible duplicate group and drops paths that are gone; trees
does the same for the directory at the top of each copy, never the
files inside. A group left with one copy is not reported. Only "does not
exist" and "not a directory" count as gone, so a permission error keeps
the copy for an unprivileged user. README says a stale report is fixed
by scanning again.

Model: opus-5-5
This commit is contained in:
clawbot committed 2026-11-11 02:57:43 +00:00
1 parent 6c529fcc6d
commit f9299a7ce3
8 files changed
+354 -94

No files matched your search

+48 -28
View File
@@ -21,9 +21,10 @@ file takes too long. It has five subcommands. `scan` walks the filesystem and
records the files it finds in an SQLite database. SQLite keeps a whole database
in one file. The database is kept between runs. A rescan reads only new and
changed files. That makes `scan` cheap enough to run every day from `cron`, the
system's job scheduler. `report`, `trees` and `info` read the database alone and
can run at any time. `dwim` scans `/` and then prints the `trees` report, for a
first look at a new machine with one command.
system's job scheduler. `report`, `trees` and `info` work from the database and
can run at any time. `report` and `trees` leave out a copy that was deleted
after the scan. `dwim` scans `/` and then prints the `trees` report, for a first
look at a new machine with one command.
## Getting Started
@@ -159,8 +160,10 @@ to keep is your choice.
A row is a candidate, not proof:
- The reports show each file as it was when a scan last recorded it. A file may
have changed or gone since.
- The reports show each file as it was when a scan last recorded it. They check
only that each copy still exists: the file, or the directory at the top of a
tree. A file may have changed since, and a file inside a tree may be gone. Run
`scan` again to bring the reports up to date.
- A file of 100 MiB or more is compared only on samples of its content (see
"Duplicate detection").
- Two paths that are hard links, two names for one file, are listed as
@@ -209,7 +212,8 @@ of reads, at the price of a comparison that is not exact.
The hashes live in a database that is kept between runs. A rescan reads only new
files, and files whose size changed or whose modification time (mtime) is newer
than recorded. All analysis then works from the database alone.
than recorded. All analysis then works from the database. The reports only check
that each copy they name still exists.
The goal is whole duplicated trees, not only single files. A download extracted
twice, or a project copied once too often, is such a tree. It can be removed as
@@ -235,9 +239,10 @@ In order:
the files of the directories it is reading.
3. **Rescan cheaply, analyze from the database.** The scan keeps its database
between runs, and an unchanged file is never read again. `report`, `trees`
and `info` work from the database alone and never touch the scanned
filesystem. `scan` is built to run from `cron`. The reports run at any time,
against what the database holds.
and `info` work from the database and never read a scanned file. `report` and
`trees` check only that each copy they name still exists, and leave out a
copy that is gone. `scan` is built to run from `cron`. The reports run at any
time, against what the database holds.
4. **Keep the streams apart.** Everything on stdout is machine-readable data.
Everything for a human reader, such as progress and warnings, goes to stderr.
@@ -655,21 +660,29 @@ and `scan` leaves it ignored.
`report` reads every record from the database. It takes no positional arguments.
A positional argument is anything on the command line that is not a flag, such
as the paths `scan` takes. `report` never touches the scanned filesystem. It
neither stats nor opens any path in the records. It reads the database and
writes to stdout and stderr, and nothing else. Its output is the same whether or
not the scanned filesystem is still mounted.
as the paths `scan` takes. `report` never opens a scanned file. It checks that
each path in a group still exists, and looks at no other path. The check is
`lstat`, the system call that looks up a path without following a symlink:
- A path is gone when `lstat` finds that it, or a directory above it, does not
exist.
- Any other error, such as a permission error, counts as present. So a user who
may not look inside a directory still sees its copies.
The check does not notice a file that changed after the scan. Run `scan` again
to bring the report up to date.
Processing:
- Records are grouped by signature, the four values
`(size, head, tail, content)`.
- Every group with two or more paths is a duplicate group.
- In a group with two or more paths, a path that is gone is left out. A group
that still has two or more paths is a duplicate group.
- Empty files form no group, because removing one frees nothing.
- Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`.
- Groups are ordered by size, largest first, then by `first` path. The output is
the same for the same database contents.
the same for the same database contents and the same paths on disk.
`report` has two phases, each with its own progress display on stderr (see
"Progress"). `open` opens the database. `read` gets the number of records from
@@ -722,8 +735,10 @@ report: 14 records read, 4 duplicate groups, 7 dupe files, 10.0 KiB reclaimable
`trees` reads the same database as `report` and takes no positional arguments.
It reports whole duplicate directory trees. Two directories are duplicate trees
when they hold the same relative paths, and each path holds a file with the same
signature in both. Like `report`, it never touches the scanned filesystem. It
rebuilds the directory tree from the paths in the records, split on `/`.
signature in both. It rebuilds the directory tree from the paths in the records,
split on `/`. Like `report`, it never opens a scanned file. It checks only that
the directory at the top of each copy still exists, as `report` checks a path.
It looks at no file or directory inside a copy.
Definitions:
@@ -750,10 +765,13 @@ Processing:
directories that all have the same digest. The parents' group, or an
ancestor's, already implies it. A group is always reported when it holds
sibling directories, or when its members' parents have different digests.
- In each group that is not left out, a directory that is gone is left out. A
group that still has two or more directories is reported.
- Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`.
- Groups are ordered by total tree size, largest first, then by `first` path.
The output is the same for the same database contents.
The output is the same for the same database contents and the same paths on
disk.
Two limitations are accepted:
@@ -767,8 +785,9 @@ Two limitations are accepted:
`trees` has four phases, each with its own progress display on stderr (see
"Progress"). `open` opens the database. `read` gets the number of records from
the `totals` table, then reads them in path order. It builds the directory tree
and computes the digests as it goes. `group` groups the directories by digest
and picks the groups to report. `write` writes them to stdout.
and computes the digests as it goes. `group` groups the directories by digest,
picks the groups to report and checks that their directories still exist.
`write` writes the groups to stdout.
#### Trees output format
@@ -826,8 +845,8 @@ The report goes to stdout, exactly as `trees` prints it. On stderr comes what
dwim: run "sfdupes trees" to print this report again
```
The database keeps the scan, so `sfdupes trees` prints the same report later,
even after the terminal is gone.
The database keeps the scan, so `sfdupes trees` prints the report again later,
even after the terminal is gone. It then leaves out a copy deleted in between.
`SIGINT` or `SIGTERM` stops the scan as it stops `scan` (see "Stopping a scan").
`dwim` then prints no report and exits 1. A fatal scan error also ends it before
@@ -1119,9 +1138,10 @@ in, and a phase that may wait in two places names both, as `disk/db`:
- `hash` says `disk/db`, because it reads files and writes their records to the
database between them.
- `load`, `update` and the `read` phase of `trees` say `db`.
- The `read` phase of `report` says `db/out`, because it writes the report to
stdout while it reads.
- `group` of `trees` says `cpu`, for work in memory, and its `write` says `out`.
- The `read` phase of `report` says `db/disk/out`. It checks that the paths of
each group still exist, and writes the report to stdout, while it reads.
- `group` of `trees` says `cpu/disk`, for work in memory and the check that each
copy still exists. Its `write` says `out`.
- A phase still waiting for its total says what it waits for, and nothing more.
On a terminal:
@@ -1361,8 +1381,8 @@ commands of their own:
not a goal. `modernc.org/sqlite` is pure Go too, so the binary builds without
cgo. The `Makefile` exports `CGO_ENABLED=0`. The test build is the one
exception, because the race detector needs cgo (see `script/test`).
- `report` and `trees` give the same output for the same database contents,
whatever order the records were inserted in.
- `report` and `trees` give the same output for the same database contents and
the same paths on disk, whatever order the records were inserted in.
### Definition of done
@@ -1427,7 +1447,7 @@ All of the following, run in this directory, must pass:
The test suite automates this scenario (see
`internal/sfdupes/scan_test.go`). It also checks that `report` and `trees`
never touch the scanned filesystem.
leave out a copy removed after the scan.
## TODO