Compare commits

..
Author SHA1 Message Date
sneak 5f51a4da78 Stop the disk size tests failing at random on ZFS (closes #150)
check / check (push) Canceled after 0s
One ZFS commit can take a file through several sizes, and unix.Sync can
return before the blocks are counted. A test that expected the size on
disk to be settled therefore failed at random. The test that needs the
size to change now calls unix.Sync in a loop until it does, for at most
30 seconds. It then rescans until the size reads the same before and
after a rescan, and compares disk_size exactly, within the same 30
seconds. The other two tests fail only on a disk_size outside their two
reads. They call unix.Sync once first, so a wrong 512 usually fails them.

Model: opus-5-5
2026-11-03 02:55:48 +00:00
27 changed files with 360 additions and 1116 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ export CGO_ENABLED = 0
BINARY := sfdupes BINARY := sfdupes
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X sneak.berlin/go/sfdupes/internal/sfdupes.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
.PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean .PHONY: sfdupes build bootstrap setup test lint fmt fmt-check check docker hooks clean
@@ -14,7 +14,7 @@ LDFLAGS := -X sneak.berlin/go/sfdupes/internal/sfdupes.Version=$(VERSION)
# Default target: build the binary. Phony so go build (which has its # Default target: build the binary. Phony so go build (which has its
# own build cache) always decides what to recompile. # own build cache) always decides what to recompile.
sfdupes: sfdupes:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/sfdupes go build -ldflags "$(LDFLAGS)" -o $(BINARY)
build: sfdupes build: sfdupes
+79 -140
View File
@@ -17,14 +17,12 @@ gigabyte. A large file is never read in full. A match between two large files is
a strong candidate, not proof. a strong candidate, not proof.
`sfdupes` is meant for multi-terabyte servers, where reading every byte of every `sfdupes` is meant for multi-terabyte servers, where reading every byte of every
file takes too long. It has five subcommands. `scan` walks the filesystem and file takes too long. It has four subcommands. `scan` walks the filesystem and
records the files it finds in an SQLite database. SQLite keeps a whole database 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 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 changed files. That makes `scan` cheap enough to run every day from `cron`, the
system's job scheduler. `report`, `trees` and `info` work from the database and system's job scheduler. `report`, `trees` and `info` read the database alone and
can run at any time. `report` and `trees` leave out a copy that was deleted can run at any time.
after the scan. `dwim` scans `/` and then prints the `trees` report, for a first
look at a new machine with one command.
## Getting Started ## Getting Started
@@ -58,25 +56,25 @@ shell shows both on the terminal, and `>` redirects stdout alone.
With Go installed, this builds and installs the newest tagged release: With Go installed, this builds and installs the newest tagged release:
```sh ```sh
go install sneak.berlin/go/sfdupes/cmd/sfdupes@latest go install sneak.berlin/go/sfdupes@latest
``` ```
`@main` installs the tip of the `main` branch instead: `@main` installs the tip of the `main` branch instead:
```sh ```sh
go install sneak.berlin/go/sfdupes/cmd/sfdupes@main go install sneak.berlin/go/sfdupes@main
``` ```
The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set. The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set.
`sfdupes --version` prints `sfdupes VERSION` and exits. For a binary from `sfdupes --version` prints `sfdupes VERSION` and exits. For a binary from
`go install`, `VERSION` is the version Go recorded in it. For `@latest` that is `go install`, `VERSION` is the version Go recorded in it. For `@latest` that is
a release tag. For `@main` it is the commit's tag when it has one. Otherwise it a release tag such as `v0.0.3`. For `@main` it is the commit's tag when it has
is a pseudo-version, Go's version string for an untagged commit. That string one. Otherwise it is a pseudo-version, Go's version string for an untagged
ends in the commit's hash. A binary built from a clone carries the output of commit. That string ends in the commit's hash. A binary built from a clone
`git describe --tags --always --dirty` in that clone, such as `v0.0.3` on a carries the output of `git describe --tags --always --dirty` in that clone, such
tagged commit. `VERSION` is `dev` when no version is known. To see everything Go as `v0.0.3` on a tagged commit. `VERSION` is `dev` when no version is known. To
recorded in an installed binary: see everything Go recorded in an installed binary:
```sh ```sh
go version -m "$(command -v sfdupes)" go version -m "$(command -v sfdupes)"
@@ -160,10 +158,8 @@ to keep is your choice.
A row is a candidate, not proof: A row is a candidate, not proof:
- The reports show each file as it was when a scan last recorded it. They check - The reports show each file as it was when a scan last recorded it. A file may
only that each copy still exists: the file, or the directory at the top of a have changed or gone since.
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 - A file of 100 MiB or more is compared only on samples of its content (see
"Duplicate detection"). "Duplicate detection").
- Two paths that are hard links, two names for one file, are listed as - Two paths that are hard links, two names for one file, are listed as
@@ -212,8 +208,7 @@ 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 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 files, and files whose size changed or whose modification time (mtime) is newer
than recorded. All analysis then works from the database. The reports only check than recorded. All analysis then works from the database alone.
that each copy they name still exists.
The goal is whole duplicated trees, not only single files. A download extracted 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 twice, or a project copied once too often, is such a tree. It can be removed as
@@ -239,10 +234,9 @@ In order:
the files of the directories it is reading. the files of the directories it is reading.
3. **Rescan cheaply, analyze from the database.** The scan keeps its database 3. **Rescan cheaply, analyze from the database.** The scan keeps its database
between runs, and an unchanged file is never read again. `report`, `trees` between runs, and an unchanged file is never read again. `report`, `trees`
and `info` work from the database and never read a scanned file. `report` and and `info` work from the database alone and never touch the scanned
`trees` check only that each copy they name still exists, and leave out a filesystem. `scan` is built to run from `cron`. The reports run at any time,
copy that is gone. `scan` is built to run from `cron`. The reports run at any against what the database holds.
time, against what the database holds.
4. **Keep the streams apart.** Everything on stdout is machine-readable data. 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. Everything for a human reader, such as progress and warnings, goes to stderr.
@@ -253,15 +247,12 @@ In order:
- `report` prints the file-level duplicate report from the database. - `report` prints the file-level duplicate report from the database.
- `trees` prints the duplicate-tree report from the database. - `trees` prints the duplicate-tree report from the database.
- `info` prints the database's details. - `info` prints the database's details.
- `dwim` scans `/` with the default excludes, then prints the duplicate-tree
report.
``` ```
sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH... sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
sfdupes report > dupes.tsv sfdupes report > dupes.tsv
sfdupes trees > dupetrees.tsv sfdupes trees > dupetrees.tsv
sfdupes info sfdupes info
sfdupes dwim
sfdupes --version sfdupes --version
sfdupes [command] --help sfdupes [command] --help
``` ```
@@ -357,26 +348,17 @@ default. An operand may be a directory or a regular file.
- `scan` with no operand is a usage error. The usage text goes to stderr, and - `scan` with no operand is a usage error. The usage text goes to stderr, and
the exit code is 2. the exit code is 2.
- An operand that does not exist is a fatal error, and so is a symlink whose - An operand that does not exist is a fatal error. `scan` then stops before it
target does not exist. `scan` then stops before it walks anything, and exits walks anything, and exits 1.
with code 1. - Each operand is made absolute before the walk. A relative operand is joined to
- Each operand is resolved to its real path before the walk, as `realpath` the working directory as `pwd` prints it, with its symlinks kept. A `.` or
prints it. A relative operand is joined to the working directory as `pwd -P` `..` in it is resolved as text, and symlinks are not followed. Each record is
prints it. Every symlink in the path is then followed. A `..` after a symlink stored under that absolute path.
counts from the directory the symlink leads to. From then on the operand is
that real path. Its files are recorded under it, and warnings name it.
- An operand that is a symlink is scanned at its target's real path. After a
`cd` through a symlink, `scan .` records its files under the real path of the
working directory.
- All operands belong to one scan, and one walk covers them all at once (see - All operands belong to one scan, and one walk covers them all at once (see
"Concurrency"). "Concurrency").
- An operand that repeats another is dropped before the walk. Two operands - An operand that repeats another is dropped before the walk.
repeat each other when their real paths are the same.
- An operand under another operand is dropped the same way, unless one of the - An operand under another operand is dropped the same way, unless one of the
cases below applies. No path is then walked twice. Whether one operand is cases below applies. No path is then walked twice.
under another is judged on their real paths. With `/srv/link` a symlink to a
directory outside `/srv`, `scan /srv /srv/link/sub` walks `sub` on its own,
because the real path of `sub` is not under `/srv`.
- An operand under another is kept, and walked on its own, when the inner - An operand under another is kept, and walked on its own, when the inner
operand or a directory between the two is one of these: operand or a directory between the two is one of these:
- a default exclude (see "Rules for the walk"); - a default exclude (see "Rules for the walk");
@@ -387,19 +369,25 @@ default. An operand may be a directory or a regular file.
memory (see "Rules for the walk"). The outer operand's walk does not enter memory (see "Rules for the walk"). The outer operand's walk does not enter
such a directory, so the inner operand is walked on its own. such a directory, so the inner operand is walked on its own.
- No other case keeps an operand under another. - No other case keeps an operand under another. An operand reached through a
- An operand that is neither a regular file nor a directory is not scanned. Such symlinked directory below the outer operand is dropped too. The outer
an operand is, for example, a symlink to a device node. `scan` prints a operand's walk does not follow the symlink, so no file under the inner operand
warning naming the real path and what it is. It counts the operand as skipped is scanned. The records that an earlier scan left under it are deleted. The
and drops it. The scan keeps the records beneath a dropped operand, unless `-x` case above also covers an operand reached that way.
another operand covers them. - An operand that is neither a regular file nor a directory is not scanned.
`scan` prints a warning naming the path and what it is. It counts the operand
as skipped and drops it. An operand beneath a symlink operand is scanned all
the same. The scan keeps the records beneath a dropped operand, unless another
operand covers them. After a `cd` through a symlink, `scan .` names the
symlink and is skipped as a symlink operand. `scan sub` in that directory
records its files under the symlink's path. `report` can then list each as a
duplicate of the same file recorded under its real path.
- An operand at or inside a directory named `.zfs` is never scanned, whether - An operand at or inside a directory named `.zfs` is never scanned, whether
given alone or under another operand. The check looks at the real path, so a given alone or under another operand. The check looks at the path as given and
symlink into a `.zfs` directory is not scanned either. `scan` prints a warning at the path with its symlinks resolved. `scan` prints a warning naming the
naming the real path, counts the operand as skipped and drops it. The records path, counts the operand as skipped and drops it. The records beneath it are
beneath it are kept, unless it lies under another operand. Then they are kept, unless it lies under another operand. Then they are deleted, because
deleted, because that operand's walk never enters `.zfs` (see "Rules for the that operand's walk never enters `.zfs` (see "Rules for the walk").
walk").
- An operand on a filesystem that is not persistent is dropped too. `scan` - An operand on a filesystem that is not persistent is dropped too. `scan`
prints a line naming it and its filesystem type, and does not count it as prints a line naming it and its filesystem type, and does not count it as
skipped. skipped.
@@ -419,9 +407,9 @@ operands:
is what makes a daily rescan cheap. is what makes a daily rescan cheap.
- When such a file's size on disk differs from its recorded `disk_size`, `scan` - When such a file's size on disk differs from its recorded `disk_size`, `scan`
updates `disk_size` alone. It still does not read the file, and counts it as updates `disk_size` alone. It still does not read the file, and counts it as
unchanged. ZFS, for example, counts a new file's blocks up to seconds after unchanged. ZFS, for example, counts a new file's blocks only seconds after the
the write. A block is the unit in which a filesystem allocates disk space. A write. A block is the unit in which a filesystem allocates disk space. A file
file scanned within those seconds gets its real `disk_size` at the next scan. scanned within those seconds gets its real `disk_size` at the next scan.
- `scan` hashes a file again when its size differs or its mtime is newer. The - `scan` hashes a file again when its size differs or its mtime is newer. The
mtime is compared to the nanosecond, as finely as the filesystem records it. A mtime is compared to the nanosecond, as finely as the filesystem records it. A
same-size rewrite counts as a change whenever the filesystem gives it a later same-size rewrite counts as a change whenever the filesystem gives it a later
@@ -496,12 +484,12 @@ walk practical on large or busy storage pools.
The walk knows them by device ID and inode number. The device ID is the The walk knows them by device ID and inode number. The device ID is the
number by which the kernel tells filesystems apart. The inode number tells number by which the kernel tells filesystems apart. The inode number tells
the files of one filesystem apart. So the walk passes over them whatever the files of one filesystem apart. So the walk passes over them whatever
path leads to them. The walk follows no symlink it finds. It opens each path leads to them. The walk follows no symlink it finds. It does open each
operand at its real path, not the path given. An operand given through a operand at the path given, even when that path runs through a symlinked
symlink can lead to the database files, and the walk passes over them there directory. Such an operand can lead to the database files, and the walk
as well. The walk prints no line for the database files and does not count passes over them there as well. The walk prints no line for the database
them as skipped. A record that an earlier scan left for one of them is files and does not count them as skipped. A record that an earlier scan left
deleted. for one of them is deleted.
- By default the walk enters directories on other filesystems than its - By default the walk enters directories on other filesystems than its
operand's. With `-x`, it never enters a directory on a different filesystem operand's. With `-x`, it never enters a directory on a different filesystem
@@ -660,29 +648,20 @@ and `scan` leaves it ignored.
`report` reads every record from the database. It takes no positional arguments. `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 A positional argument is anything on the command line that is not a flag, such
as the paths `scan` takes. `report` never opens a scanned file. It checks that as the paths `scan` takes. `report` never touches the scanned filesystem. It
each path in a group still exists, and looks at no other path. The check is neither stats nor opens any path in the records. It reads the database and
`lstat`, the system call that looks up a path without following a symlink: writes to stdout and stderr, and nothing else. Its output is the same whether or
not the scanned filesystem is still mounted.
- 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: Processing:
- Records are grouped by signature, the four values - Records are grouped by signature, the four values
`(size, head, tail, content)`. `(size, head, tail, content)`.
- In a group with two or more paths, a path that is gone is left out. A group - Every group with two or more paths is a duplicate 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 - Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`. every other path is a `dupe`.
- Groups are ordered by size, largest first, then by `first` path. The output is - Groups are ordered by size, largest first, then by `first` path. The output is
the same for the same database contents and the same paths on disk. the same for the same database contents.
`report` has two phases, each with its own progress display on stderr (see `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 "Progress"). `open` opens the database. `read` gets the number of records from
@@ -727,7 +706,7 @@ The summary line on stderr gives:
- the reclaimable bytes, the sum of `size` over all rows. - the reclaimable bytes, the sum of `size` over all rows.
``` ```
report: 14 records read, 4 duplicate groups, 7 dupe files, 10.0 KiB reclaimable report: 14 records read, 5 duplicate groups, 8 dupe files, 10.0 KiB reclaimable
``` ```
### `trees` mode ### `trees` mode
@@ -735,10 +714,8 @@ 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. `trees` reads the same database as `report` and takes no positional arguments.
It reports whole duplicate directory trees. Two directories are duplicate trees 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 when they hold the same relative paths, and each path holds a file with the same
signature in both. It rebuilds the directory tree from the paths in the records, signature in both. Like `report`, it never touches the scanned filesystem. It
split on `/`. Like `report`, it never opens a scanned file. It checks only that rebuilds the directory tree from the paths in the records, split on `/`.
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: Definitions:
@@ -757,21 +734,15 @@ Processing:
- `trees` builds the directory tree and computes every directory's digest. It - `trees` builds the directory tree and computes every directory's digest. It
then groups the directories by digest. Every group with two or more then groups the directories by digest. Every group with two or more
directories is a duplicate-tree group. directories is a duplicate-tree group.
- `trees` leaves out a group whose trees hold 0 bytes, because removing one
frees nothing. An empty file still counts toward the digest of a tree that
holds other files.
- `trees` leaves out a group that a group of its members' parents already - `trees` leaves out a group that a group of its members' parents already
implies. A group is left out when its members' parents are distinct implies. A group is left out when its members' parents are distinct
directories that all have the same digest. The parents' group, or an 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 ancestor's, already implies it. A group is always reported when it holds
sibling directories, or when its members' parents have different digests. 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 - Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`. every other path is a `dupe`.
- Groups are ordered by total tree size, largest first, then by `first` path. - Groups are ordered by total tree size, largest first, then by `first` path.
The output is the same for the same database contents and the same paths on The output is the same for the same database contents.
disk.
Two limitations are accepted: Two limitations are accepted:
@@ -785,9 +756,8 @@ Two limitations are accepted:
`trees` has four phases, each with its own progress display on stderr (see `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 "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 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 computes the digests as it goes. `group` groups the directories by digest
picks the groups to report and checks that their directories still exist. and picks the groups to report. `write` writes them to stdout.
`write` writes the groups to stdout.
#### Trees output format #### Trees output format
@@ -827,33 +797,6 @@ The summary line on stderr gives:
trees: 14 records read, 1 duplicate tree groups, 1 dupe trees, 3.0 KiB reclaimable trees: 14 records read, 1 duplicate tree groups, 1 dupe trees, 3.0 KiB reclaimable
``` ```
### `dwim` mode
`dwim`, short for "do what I mean", scans a new machine and reports its
duplicate trees in one command. It takes no positional arguments and no flags of
its own. Like `scan`, it should run as root, so that it can read every file.
`dwim` runs what `sfdupes scan /` runs with no flags. The walk skips the default
excludes and crosses into other filesystems. `--workers` keeps its default. The
database, its creation and the scan lock are those of `scan`. Then `dwim` runs
what `sfdupes trees` runs. The largest trees come first.
The report goes to stdout, exactly as `trees` prints it. On stderr comes what
`scan` prints, then what `trees` prints, then one last line:
```
dwim: run "sfdupes trees" to print this report again
```
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
the report, with the same message and exit code as `scan`. Warnings for skipped
files do not stop it. `dwim` stops catching signals when the scan ends, so a
signal during the report ends it at once, as it ends `trees`.
### `info` mode ### `info` mode
`info` takes no positional arguments and prints the database's details on `info` takes no positional arguments and prints the database's details on
@@ -894,7 +837,7 @@ scan is writing. `info` writes its lines after the `count` display has ended.
### Database ### Database
All five subcommands work on one SQLite database file. All four subcommands work on one SQLite database file.
#### Location #### Location
@@ -910,8 +853,6 @@ scan: new database /var/lib/sfdupes/db.sqlite (default)
trees: database /srv/tmp/sfdupes/db.db (from SFDUPES_DATABASE) trees: database /srv/tmp/sfdupes/db.db (from SFDUPES_DATABASE)
``` ```
`dwim` prints the `scan` line first, and the `trees` line after the scan.
A relative path is printed with the working directory in front of it. The A relative path is printed with the working directory in front of it. The
working directory is printed as `pwd -P` prints it, with its symlinks resolved. working directory is printed as `pwd -P` prints it, with its symlinks resolved.
The rest of the path is printed as set, so a `..` in it stays. The line names The rest of the path is printed as set, so a `..` in it stays. The line names
@@ -1138,10 +1079,9 @@ 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 - `hash` says `disk/db`, because it reads files and writes their records to the
database between them. database between them.
- `load`, `update` and the `read` phase of `trees` say `db`. - `load`, `update` and the `read` phase of `trees` say `db`.
- The `read` phase of `report` says `db/disk/out`. It checks that the paths of - The `read` phase of `report` says `db/out`, because it writes the report to
each group still exist, and writes the report to stdout, while it reads. stdout while it reads.
- `group` of `trees` says `cpu/disk`, for work in memory and the check that each - `group` of `trees` says `cpu`, for work in memory, and its `write` says `out`.
copy still exists. Its `write` says `out`.
- A phase still waiting for its total says what it waits for, and nothing more. - A phase still waiting for its total says what it waits for, and nothing more.
On a terminal: On a terminal:
@@ -1196,9 +1136,9 @@ showing the count it stopped at.
### Error handling and exit codes ### Error handling and exit codes
- `0`: success, even when files were skipped with warnings. - `0`: success, even when files were skipped with warnings.
- `1`: a fatal error, a `scan` stopped by `SIGINT` or `SIGTERM`, or a `dwim` - `1`: a fatal error, or a `scan` stopped by `SIGINT` or `SIGTERM`. A fatal
stopped by either during its scan. A fatal error is one of these: error is one of these:
- an operand that does not exist, or whose real path `scan` cannot find; - an operand that does not exist, or whose details `scan` cannot read;
- a list of mounted filesystems that `scan` cannot read; - a list of mounted filesystems that `scan` cannot read;
- another `scan` holding the lock (see "The scan lock"); - another `scan` holding the lock (see "The scan lock");
- a database, its directory or its lock file that cannot be created or - a database, its directory or its lock file that cannot be created or
@@ -1218,7 +1158,7 @@ showing the count it stopped at.
`--workers abc`; `--workers abc`;
- `scan` with no operand; - `scan` with no operand;
- `--workers` below 1; - `--workers` below 1;
- `report`, `trees`, `info` or `dwim` with a positional argument. - `report`, `trees` or `info` with a positional argument.
A fatal error is reported on stderr, starting with `sfdupes:`. A failed write to A fatal error is reported on stderr, starting with `sfdupes:`. A failed write to
stdout, such as a full disk, is reported the same way. Two cases never reach stdout, such as a full disk, is reported the same way. Two cases never reach
@@ -1381,8 +1321,8 @@ commands of their own:
not a goal. `modernc.org/sqlite` is pure Go too, so the binary builds without 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 cgo. The `Makefile` exports `CGO_ENABLED=0`. The test build is the one
exception, because the race detector needs cgo (see `script/test`). exception, because the race detector needs cgo (see `script/test`).
- `report` and `trees` give the same output for the same database contents and - `report` and `trees` give the same output for the same database contents,
the same paths on disk, whatever order the records were inserted in. whatever order the records were inserted in.
### Definition of done ### Definition of done
@@ -1406,7 +1346,7 @@ All of the following, run in this directory, must pass:
head -c 2000 /dev/urandom > "$d/a/unique.bin" # same size, different content head -c 2000 /dev/urandom > "$d/a/unique.bin" # same size, different content
printf 'x' > "$d/tiny1"; printf 'x' > "$d/tiny2" # 1-byte duplicates printf 'x' > "$d/tiny1"; printf 'x' > "$d/tiny2" # 1-byte duplicates
printf 'y' > "$d/tiny3" # 1-byte non-duplicate printf 'y' > "$d/tiny3" # 1-byte non-duplicate
: > "$d/empty1"; : > "$d/empty2" # empty files : > "$d/empty1"; : > "$d/empty2" # empty duplicates
# duplicate trees: t1 and t2 are identical; t3 differs by one filename # duplicate trees: t1 and t2 are identical; t3 differs by one filename
mkdir -p "$d/t1/sub" "$d/t2/sub" "$d/t3/sub" mkdir -p "$d/t1/sub" "$d/t2/sub" "$d/t3/sub"
head -c 3000 /dev/urandom > "$d/t1/f1" head -c 3000 /dev/urandom > "$d/t1/f1"
@@ -1431,8 +1371,8 @@ All of the following, run in this directory, must pass:
is the path that sorts first. is the path that sorts first.
- `t1/f1`, `t2/f1` and `t3/f1` form one group. - `t1/f1`, `t2/f1` and `t3/f1` form one group.
- `t1/sub/f2`, `t2/sub/f2` and `t3/sub/f2renamed` form one group. - `t1/sub/f2`, `t2/sub/f2` and `t3/sub/f2renamed` form one group.
- `tiny1` and `tiny2` form a pair. - `tiny1` and `tiny2` form a pair, and so do `empty1` and `empty2`.
- `unique.bin`, `tiny3`, `empty1` and `empty2` appear nowhere. - `unique.bin` and `tiny3` appear nowhere.
- The groups come in order of size, largest first. - The groups come in order of size, largest first.
`trees` prints exactly one row, `$d/t1` as `first` and `$d/t2` as `dupe`. `trees` prints exactly one row, `$d/t1` as `first` and `$d/t2` as `dupe`.
@@ -1445,9 +1385,8 @@ All of the following, run in this directory, must pass:
because its content changed. `copy.bin` and `copy2.bin` remain as one pair. because its content changed. `copy.bin` and `copy2.bin` remain as one pair.
`unique.bin` is gone from the database. `unique.bin` is gone from the database.
The test suite automates this scenario (see The test suite automates this scenario (see `scan_test.go`). It also checks
`internal/sfdupes/scan_test.go`). It also checks that `report` and `trees` that `report` and `trees` never touch the scanned filesystem.
leave out a copy removed after the scan.
## TODO ## TODO
+35 -72
View File
@@ -29,47 +29,13 @@
Newest first. An entry ends with its date and its issue or branch, where those Newest first. An entry ends with its date and its issue or branch, where those
are known. are known.
- `report` and `trees` leave out a copy that was deleted after the scan. Each - The three disk size tests in `scan_test.go` no longer fail at random on ZFS.
checks only the copy itself with `lstat`: the file, or the directory at the `unix.Sync` can return before ZFS has counted a file's blocks, and one ZFS
top of a tree. A group left with one copy is not reported. A missing `scan` commit can take a file through several sizes. The test that needs the size to
operand is still a fatal error (2026-11-11, change waits for it, for at most 30 seconds. Each test reads the size before
https://git.eeqj.de/sneak/sfdupes/issues/173) and after its scan. The rewrite test rescans until the two reads match, then
compares exactly. The other two fail only on a `disk_size` outside the two
- `report` and `trees` leave out empty files. `report` writes no group of size reads (2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/150)
0, and `trees` writes no group whose trees hold 0 bytes. An empty file still
counts toward a tree's digest (2026-11-11,
https://git.eeqj.de/sneak/sfdupes/issues/178)
- The Go code moved out of the repository root. `cmd/sfdupes/main.go` is the
command, and it calls `Main` in the package `internal/sfdupes`. `main.go`
became `internal/sfdupes/cli.go`. Every other file kept its name. The install
path is now `sneak.berlin/go/sfdupes/cmd/sfdupes` (2026-11-10,
https://git.eeqj.de/sneak/sfdupes/issues/157)
- New subcommand `dwim`. It runs what `sfdupes scan /` runs with no flags, then
what `sfdupes trees` runs. Its last line on stderr says that `sfdupes trees`
prints the report again (2026-11-06,
https://git.eeqj.de/sneak/sfdupes/issues/154)
- `scan` resolves each operand to its real path, symlinks followed, before the
walk. A symlink operand is scanned at its target instead of skipped. With
`/srv/link` a symlink to a directory outside `/srv`, `scan /srv /srv/link/sub`
walks `sub` on its own instead of dropping it and deleting its records
(2026-11-06, https://git.eeqj.de/sneak/sfdupes/issues/144)
- `README.md` and a comment in `internal/sfdupes/scan.go` no longer say ZFS
counts a new file's blocks only seconds after the write. The count can land in
under a second. Both now say up to seconds after the write, as the comment on
`diskSizeOf` in `internal/sfdupes/scan_test.go` does (2026-11-03,
https://git.eeqj.de/sneak/sfdupes/issues/152)
- The three disk size tests in `internal/sfdupes/scan_test.go` no longer fail at
random on ZFS. `unix.Sync` can return before ZFS has counted a file's blocks,
and one ZFS commit can take a file through several sizes. The test that needs
the size to change waits for it, for at most 30 seconds. Each test reads the
size before and after its scan. The rewrite test rescans until the two reads
match, then compares exactly. The other two fail only on a `disk_size` outside
the two reads (2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/150)
- `TODO.md` rewritten from scratch by the same rules as `README.md`, sentence by - `TODO.md` rewritten from scratch by the same rules as `README.md`, sentence by
sentence. Every completed step is kept, with its date and its issue or branch. sentence. Every completed step is kept, with its date and its issue or branch.
@@ -80,13 +46,9 @@ are known.
it. It leads with what the program is and how to use it. A reference section it. It leads with what the program is and how to use it. A reference section
covers every setting and every subcommand. Development and testing come last. covers every setting and every subcommand. Development and testing come last.
Every statement was checked against the code on `next`. Five comments in Every statement was checked against the code on `next`. Five comments in
`internal/sfdupes/cli_test.go` cite "Settings", where their rules now live. `main_test.go` cite "Settings", where their rules now live. One comment each
One comment each in these files cites what the README now says: in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
- `internal/sfdupes/scan_test.go`; now says (2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/135)
- `internal/sfdupes/trees_test.go`;
- `internal/sfdupes/exclude_test.go`.
(2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/135)
- Each record holds `disk_size`, the bytes the file takes on disk. That is its - Each record holds `disk_size`, the bytes the file takes on disk. That is its
allocated blocks, `st_blocks` × 512, from the `lstat` call the walk already allocated blocks, `st_blocks` × 512, from the `lstat` call the walk already
@@ -159,12 +121,12 @@ are known.
https://git.eeqj.de/sneak/sfdupes/issues/109) https://git.eeqj.de/sneak/sfdupes/issues/109)
- The release rule was set. Until 1.0, each tip of `main` that passes the build - The release rule was set. Until 1.0, each tip of `main` that passes the build
gets the next `v0.0.N` tag. gets the next `v0.0.N` tag. `go install sneak.berlin/go/sfdupes@latest` then
`go install sneak.berlin/go/sfdupes/cmd/sfdupes@latest` then installs it. installs it. `go.mod` retracts `v0.0.1`, the early build without a database.
`go.mod` retracts `v0.0.1`, the early build without a database. When the When the linker set no version, `sfdupes --version` prints the version Go
linker set no version, `sfdupes --version` prints the version Go recorded in recorded in the binary. That is the tag, or the pseudo-version of a
the binary. That is the tag, or the pseudo-version of a `go install` build. It `go install` build. It prints `dev` only when neither is known. README
prints `dev` only when neither is known. README "Install" documented: "Install" documented:
- the rule; - the rule;
- what `@main` and `v0.0.1` give; - what `@main` and `v0.0.1` give;
- how to check an installed binary. - how to check an installed binary.
@@ -415,9 +377,8 @@ are known.
`gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04, `gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/26) https://git.eeqj.de/sneak/sfdupes/issues/26)
- A test fails when either `hashWorker` cancellation check in - A test fails when either `hashWorker` cancellation check in `scan.go` is
`internal/sfdupes/scan.go` is removed (2026-10-04, removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83)
https://git.eeqj.de/sneak/sfdupes/issues/83)
- A database path holding `?`, `#` or `%` opens exactly the file it names - A database path holding `?`, `#` or `%` opens exactly the file it names
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55)
@@ -425,15 +386,15 @@ are known.
- `scan` rejects `--workers` below 1 as a usage error instead of running - `scan` rejects `--workers` below 1 as a usage error instead of running
single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10) single-threaded (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/10)
- A test fails when either walk cancellation check in `internal/sfdupes/scan.go` - A test fails when either walk cancellation check in `scan.go` is removed
is removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81)
- A test checks that `scan` refuses a database with another schema version - A test checks that `scan` refuses a database with another schema version
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64) (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64)
- Four inaccurate comments in `internal/sfdupes/cancel_test.go` were corrected, - Four inaccurate comments in `cancel_test.go` were corrected, and
and `walkCancelInFlightDirs` was renamed to `walkCancelInFlightFiles` `walkCancelInFlightDirs` was renamed to `walkCancelInFlightFiles` (2026-10-04,
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/33) https://git.eeqj.de/sneak/sfdupes/issues/33)
- The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04, - The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/17) https://git.eeqj.de/sneak/sfdupes/issues/17)
@@ -640,15 +601,15 @@ are known.
- `loadRecords`; - `loadRecords`;
- `resolveRoots`. - `resolveRoots`.
The single exit point is `run` in `internal/sfdupes/cli.go`. It maps a The single exit point is `run` in `main.go`. It maps a `fatalError`,
`fatalError`, anything a subcommand returned, to exit 1, and cobra's own anything a subcommand returned, to exit 1, and cobra's own argument and flag
argument and flag errors to exit 2. So a runtime failure is never reported errors to exit 2. So a runtime failure is never reported as a usage error,
as a usage error, nor printed with the usage text. nor printed with the usage text. `main_test.go` drives the CLI in-process
`internal/sfdupes/cli_test.go` drives the CLI in-process and asserts the and asserts the exit codes from README "Error handling" and the
exit codes from README "Error handling" and the stdout/stderr split. It also stdout/stderr split. It also checks that a fatal error after the database is
checks that a fatal error after the database is open leaves no `-wal` or open leaves no `-wal` or `-shm` file behind, for `scan`, `report` or `trees`
`-shm` file behind, for `scan`, `report` or `trees` (2026-08-09, branch (2026-08-09, branch `db-close-on-fatal`,
`db-close-on-fatal`, https://git.eeqj.de/sneak/sfdupes/issues/4) https://git.eeqj.de/sneak/sfdupes/issues/4)
- `golangci-lint` was updated to v2.12.2 with the canonical config, merged as - `golangci-lint` was updated to v2.12.2 with the canonical config, merged as
`38a01bd`. The pinned linter in the `Dockerfile` lint stage and `38a01bd`. The pinned linter in the `Dockerfile` lint stage and
@@ -781,4 +742,6 @@ are known.
# Accepted Divergences # Accepted Divergences
None. - Flat single-package layout, with the `.go` files in the repo root. That is
fine for a small single-binary tool per the Go styleguide, and the tracker
audit agrees.
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // cancels the unexported runScan and hashWorker package main
import ( import (
"context" "context"
-29
View File
@@ -1,29 +0,0 @@
// Command sfdupes quickly identifies candidate duplicate files across
// very large filesystems without reading every byte of every file.
// Files are considered duplicates when their sizes are equal and they
// agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
// KiB, and of the content. The content hash covers the whole file under
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// file is never read in full. scan maintains a persistent SQLite
// database of file signatures (SFDUPES_DATABASE, default
// /var/lib/sfdupes/db.sqlite) that the other subcommands read.
//
// Usage:
//
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
// sfdupes report > dupes.tsv
// sfdupes trees > dupetrees.tsv
// sfdupes info
// sfdupes dwim
// sfdupes --version
//
// See README.md for the complete specification.
package main
import (
"os"
"sneak.berlin/go/sfdupes/internal/sfdupes"
)
func main() { os.Exit(sfdupes.Main()) }
+17 -28
View File
@@ -1,4 +1,4 @@
package sfdupes package main
import ( import (
"context" "context"
@@ -146,44 +146,33 @@ var errHashSize = errors.New("hash is not 32 bytes")
// databasePath resolves the database location: SFDUPES_DATABASE when // databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, the compiled-in default otherwise. It returns the // set and non-empty, the compiled-in default otherwise. It returns the
// path, made absolute by absolutePath, and where the path came from, // path, with the working directory, its symlinks resolved, put in front
// "default" or "from SFDUPES_DATABASE", for the line each subcommand // of it when it is relative, and where the path came from, "default" or
// prints first on stderr. // "from SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr.
func databasePath() (string, string, error) { func databasePath() (string, string, error) {
path, source := defaultDatabasePath, "default" path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" { if p := os.Getenv(databaseEnv); p != "" {
path, source = p, "from "+databaseEnv path, source = p, "from "+databaseEnv
} }
abs, err := absolutePath(path) if filepath.IsAbs(path) {
return path, source, nil
}
// The working directory as the kernel reports it, with no symlink
// in it, as README "Database" says. os.Getwd may return PWD
// instead, which can name it through a symlink.
wd, err := unix.Getwd()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err) return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
} }
return abs, source, nil // Not filepath.Abs or filepath.Join: they clean the path, which
} // drops each ".." together with the directory name before it. When
// that directory is a symlink, the cleaned path names another file.
// absolutePath returns path unchanged when it is absolute, and
// otherwise the working directory, a "/" and path. The working
// directory is the one the kernel reports, with no symlink in it;
// os.Getwd may return PWD instead, which can name it through a symlink.
// The result is not cleaned, as filepath.Abs and filepath.Join would
// clean it. Cleaning drops each ".." together with the directory name
// before it. When that name is a symlink, the cleaned path names
// another file than the one the system reaches by following the symlink
// first.
func absolutePath(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
wd, err := unix.Getwd()
if err != nil {
return "", err
}
// The working directory ends in "/" only when it is the root. // The working directory ends in "/" only when it is the root.
return strings.TrimSuffix(wd, "/") + "/" + path, nil return strings.TrimSuffix(wd, "/") + "/" + path, source, nil
} }
// createDatabaseDirectory creates the directory the database at path // createDatabaseDirectory creates the directory the database at path
+1 -1
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // calls the unexported database openers package main
import ( import (
"context" "context"
+1 -1
View File
@@ -1,4 +1,4 @@
package sfdupes package main
// This file holds two rules for the walk (README "Rules for the walk"): // This file holds two rules for the walk (README "Rules for the walk"):
// the default excludes, the paths a scan's walk does not enter below an // the default excludes, the paths a scan's walk does not enter below an
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package sfdupes package main
import ( import (
"slices" "slices"
@@ -1,6 +1,6 @@
//go:build !darwin //go:build !darwin
package sfdupes package main
import ( import (
"fmt" "fmt"
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // checks the unexported defaultExcludes package main
import ( import (
"bytes" "bytes"
@@ -286,7 +286,7 @@ func TestRunScanTempDirOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) { if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
t.Skip(dir + " is not inside a default exclude") t.Skip(dir + " is not inside a default exclude")
} }
+1 -1
View File
@@ -1,4 +1,4 @@
package sfdupes package main
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // seeds the database with unexported scanRec package main
import ( import (
"fmt" "fmt"
-37
View File
@@ -1,37 +0,0 @@
package sfdupes
import (
"context"
"fmt"
"io"
"os"
"runtime"
)
// runDwim implements the dwim subcommand: it runs what "sfdupes scan
// root" runs with no flags, then what "sfdupes trees" runs, and ends
// with a line on stderr saying how to print the report again. The dwim
// command passes "/" as root. It catches SIGINT and SIGTERM as scan
// does, but only during the scan. An error from the scan, an interrupt
// included, ends it before the report. During the report, a signal
// ends it at once, as it ends trees.
func runDwim(ctx context.Context, root string, stdout io.Writer) error {
scanCtx, stop := interruptContext(ctx)
err := runScan(scanCtx, []string{root}, runtime.NumCPU(), false, false)
stop()
if err != nil {
return err
}
err = runTrees(ctx, stdout)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr,
`dwim: run "sfdupes trees" to print this report again`)
return nil
}
-83
View File
@@ -1,83 +0,0 @@
package sfdupes //nolint:testpackage // calls the unexported runDwim
import (
"bytes"
"path/filepath"
"strings"
"testing"
)
func TestRunDwimPrintsLargestTreesFirst(t *testing.T) {
// dwim scans its root, then prints the trees report, the larger of
// two duplicate-tree pairs first, and ends by saying how to print
// the report again.
t.Setenv(databaseEnv, testDBPath(t))
root := realTempDir(t)
small := []string{
filepath.Dir(writeFile(t, root, "a1/f.bin", pattern(1, 300))),
filepath.Dir(writeFile(t, root, "a2/f.bin", pattern(1, 300))),
}
large := []string{
filepath.Dir(writeFile(t, root, "b1/f.bin", pattern(2, 5000))),
filepath.Dir(writeFile(t, root, "b2/f.bin", pattern(2, 5000))),
}
var stdout bytes.Buffer
stderr := captureStderr(t)
err := runDwim(t.Context(), root, &stdout)
if err != nil {
t.Fatalf("runDwim: %v; stderr: %s", err, stderr())
}
lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
if len(lines) != 3 {
t.Fatalf("stdout = %q, want a header and two groups",
stdout.String())
}
wantFirst := large[0] + "\t" + large[1] + "\t1\t5000\t"
if !strings.HasPrefix(lines[1], wantFirst) {
t.Errorf("first group = %q, want it to start %q", lines[1], wantFirst)
}
wantSecond := small[0] + "\t" + small[1] + "\t1\t300\t"
if !strings.HasPrefix(lines[2], wantSecond) {
t.Errorf("second group = %q, want it to start %q",
lines[2], wantSecond)
}
last := "\ndwim: run \"sfdupes trees\" to print this report again\n"
if got := stderr(); !strings.HasSuffix(got, last) {
t.Errorf("stderr = %q, want it to end %q", got, last)
}
}
func TestRunDwimScanErrorPrintsNoReport(t *testing.T) {
// A fatal scan error ends dwim before the report: a root that does
// not exist fails the scan as it fails scan itself.
t.Setenv(databaseEnv, testDBPath(t))
missing := filepath.Join(realTempDir(t), "missing")
var stdout bytes.Buffer
stderr := captureStderr(t)
err := runDwim(t.Context(), missing, &stdout)
if err == nil {
t.Fatal("runDwim on a missing root: error = nil, want it fatal")
}
if got := stdout.String(); got != "" {
t.Errorf("stdout = %q, want no report", got)
}
if got := stderr(); strings.Contains(got, "trees:") ||
strings.Contains(got, "dwim:") {
t.Errorf("stderr = %q, want nothing from trees or dwim", got)
}
}
+25 -20
View File
@@ -1,6 +1,23 @@
// Package sfdupes implements the sfdupes command and the database its // Command sfdupes quickly identifies candidate duplicate files across
// subcommands share. // very large filesystems without reading every byte of every file.
package sfdupes // Files are considered duplicates when their sizes are equal and they
// agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
// KiB, and of the content. The content hash covers the whole file under
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
// file is never read in full. scan maintains a persistent SQLite
// database of file signatures (SFDUPES_DATABASE, default
// /var/lib/sfdupes/db.sqlite) that the other subcommands read.
//
// Usage:
//
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
// sfdupes report > dupes.tsv
// sfdupes trees > dupetrees.tsv
// sfdupes info
// sfdupes --version
//
// See README.md for the complete specification.
package main
import ( import (
"context" "context"
@@ -28,7 +45,6 @@ const (
cmdReport = "report" cmdReport = "report"
cmdTrees = "trees" cmdTrees = "trees"
cmdInfo = "info" cmdInfo = "info"
cmdDwim = "dwim"
) )
// errNoSubcommand is returned by the root command when it is invoked // errNoSubcommand is returned by the root command when it is invoked
@@ -68,22 +84,20 @@ func buildVersion(linkerVersion string, info *debug.BuildInfo) string {
return info.Main.Version return info.Main.Version
} }
// Main runs the command line in os.Args and returns the process exit func main() {
// code.
func Main() int {
// Once the reader of a stdout pipe has gone, as in "sfdupes report | // Once the reader of a stdout pipe has gone, as in "sfdupes report |
// head", the Go runtime ends the process with SIGPIPE on the next // head", the Go runtime ends the process with SIGPIPE on the next
// write instead of returning an error (README "Error handling"). // write instead of returning an error (README "Error handling").
// Registering for SIGPIPE with os/signal would change that. // Registering for SIGPIPE with os/signal would change that.
return run(os.Args[1:], os.Stdout, os.Stderr) os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
} }
// run executes args against the command tree and returns the process // run executes args against the command tree and returns the process
// exit code. It is the program's single exit point: the subcommands // exit code. It is the program's single exit point: the subcommands
// return their errors instead of exiting, so every deferred cleanup — // return their errors instead of exiting, so every deferred cleanup —
// above all closing the database, which checkpoints the SQLite WAL — // above all closing the database, which checkpoints the SQLite WAL —
// runs before the process ends. The report, trees, info and dwim // runs before the process ends. The report, trees and info subcommands
// subcommands write their data to stdout. // write their data to stdout.
func run(args []string, stdout, stderr io.Writer) int { func run(args []string, stdout, stderr io.Writer) int {
// A nil slice makes cobra fall back to os.Args, which would let a // A nil slice makes cobra fall back to os.Args, which would let a
// test binary's own flags reach the command tree. // test binary's own flags reach the command tree.
@@ -187,16 +201,7 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
}), }),
} }
dwimCmd := &cobra.Command{ root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd)
Use: cmdDwim,
Short: "Scan / with the default excludes, then print the duplicate-tree report",
Args: cobra.NoArgs,
RunE: runE(func(ctx context.Context, _ []string) error {
return runDwim(ctx, "/", stdout)
}),
}
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd, dwimCmd)
return root return root
} }
+53 -262
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported run package main
import ( import (
"bytes" "bytes"
@@ -14,8 +14,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"golang.org/x/sys/unix"
) )
// usageMarker is the first line of cobra's usage text, which a usage // usageMarker is the first line of cobra's usage text, which a usage
@@ -622,7 +620,6 @@ func TestRunUsageErrors(t *testing.T) {
"report with args": {[]string{cmdReport, "x"}, usageMarker}, "report with args": {[]string{cmdReport, "x"}, usageMarker},
"trees with args": {[]string{cmdTrees, "x"}, usageMarker}, "trees with args": {[]string{cmdTrees, "x"}, usageMarker},
"info with args": {[]string{cmdInfo, "x"}, usageMarker}, "info with args": {[]string{cmdInfo, "x"}, usageMarker},
"dwim with args": {[]string{cmdDwim, "x"}, usageMarker},
"unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker}, "unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker},
"unknown subcommand": {[]string{"nope"}, "unknown command"}, "unknown subcommand": {[]string{"nope"}, "unknown command"},
} }
@@ -800,7 +797,7 @@ func TestRunVersionWriteFailureIsFatal(t *testing.T) {
func scanFixture(t *testing.T) []string { func scanFixture(t *testing.T) []string {
t.Helper() t.Helper()
dir := realTempDir(t) dir := t.TempDir()
dupes := []string{ dupes := []string{
writeFile(t, dir, "one/a.bin", pattern(1, 300)), writeFile(t, dir, "one/a.bin", pattern(1, 300)),
@@ -870,7 +867,7 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
writeFile(t, dir, "a.bin", pattern(1, 300)) writeFile(t, dir, "a.bin", pattern(1, 300))
// Same size as a.bin, so the scan reads it, and the read fails. // Same size as a.bin, so the scan reads it, and the read fails.
@@ -898,278 +895,58 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
assertNoSidecars(t, path) assertNoSidecars(t, path)
} }
// realTempDir returns a new temporary directory by its real path, so func TestRunScanSkipsSymlinkOperand(t *testing.T) {
// tests hold where the temporary directory is reached through a
// symlink.
func realTempDir(t *testing.T) string {
t.Helper()
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
return dir
}
// symlink creates link pointing at target.
func symlink(t *testing.T, target, link string) {
t.Helper()
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
}
// TestRunScanFollowsSymlinkOperand scans link, a symlink to target.
// The operand is scanned at its real path, and its file is recorded
// there.
func TestRunScanFollowsSymlinkOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
f := writeFile(t, dir, "target/sub/f", pattern(1, 10)) writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link") link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
stderr := scanOK(t, link) err := os.Symlink(filepath.Join(dir, "target"), link)
if err != nil {
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " + t.Fatal(err)
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
} }
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) { // Scanning a directory through the symlink stores a record beneath
t.Errorf("records = %q, want %q", got, []string{f}) // the symlink's own path for a file beneath its target.
} scanOK(t, filepath.Join(link, "sub"))
assertOperandSkipped(t, path, link, "symlink",
filepath.Join(link, "sub", "f"))
} }
// TestRunScanWalksOperandUnderSymlinkOperand scans link and link/sub,
// with link a symlink to target. Both resolve under target, so
// target/sub is walked once, as part of target.
func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) { func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
f := writeFile(t, dir, "target/sub/f", pattern(1, 10)) writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link") link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
stderr := scanOK(t, link, filepath.Join(link, "sub")) err := os.Symlink(filepath.Join(dir, "target"), link)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanWalksSymlinkedOperandBelowOperand is the case of
// https://git.eeqj.de/sneak/sfdupes/issues/144: scan dir dir/link/sub,
// with dir/link a symlink to a directory outside dir. The walk of dir
// does not follow link, so dir/link/sub is walked on its own at its
// real path, and the record an earlier scan stored there survives.
func TestRunScanWalksSymlinkedOperandBelowOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
a := writeFile(t, dir, "a", pattern(1, 10))
outside := realTempDir(t)
f := writeFile(t, outside, "sub/f", pattern(2, 10))
symlink(t, outside, filepath.Join(dir, "link"))
scanOK(t, filepath.Join(outside, "sub"))
stderr := scanOK(t, dir, filepath.Join(dir, "link", "sub"))
summary := "scan: 2 files seen (1 added, 0 updated, 0 removed, " +
"1 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
want := []string{a, f}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
}
// TestRunScanDropsSymlinkOperandInsideOperand scans dir and link, with
// link, outside dir, a symlink to dir/real. link resolves inside dir,
// so it is dropped as covered by dir, and its file is recorded once.
func TestRunScanDropsSymlinkOperandInsideOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "real/f", pattern(1, 10))
link := filepath.Join(realTempDir(t), "link")
symlink(t, filepath.Join(dir, "real"), link)
stderr := scanOK(t, dir, link)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanDanglingSymlinkOperandIsFatal scans a valid directory and
// a symlink whose target does not exist. The scan fails with exit 1 and
// an error naming the symlink as given, before it walks anything, so no
// database is created.
func TestRunScanDanglingSymlinkOperandIsFatal(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
writeFile(t, dir, "f", pattern(1, 10))
dangling := filepath.Join(realTempDir(t), "dangling")
symlink(t, filepath.Join(dir, "missing"), dangling)
stdout := captureStdout(t)
var stderr bytes.Buffer
code := run([]string{cmdScan, dir, dangling}, os.Stdout, &stderr)
if code != exitFatal {
t.Errorf("run(scan %s %s) = %d, want %d", dir, dangling, code,
exitFatal)
}
assertFatalOutput(t, stderr.String(), stdout())
want := "sfdupes: resolve " + dangling + ": "
if !strings.Contains(stderr.String(), want) {
t.Errorf("stderr = %q, want %q", stderr.String(), want)
}
_, err := os.Stat(path)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("database: stat error %v, want it missing", err)
}
}
// TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath runs scan .
// after a cd through home/proj, a symlink to data/proj. The file is
// recorded under data/proj, its real path.
func TestRunScanFromSymlinkedWorkingDirectoryRecordsRealPath(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
err := os.Mkdir(filepath.Join(dir, "home"), 0o750)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
symlink(t, filepath.Join(dir, "data", "proj"), // link is dropped as a symlink, but link/sub must still be scanned,
filepath.Join(dir, "home", "proj")) // not dropped as lying under link.
scanOK(t, link, filepath.Join(link, "sub"))
// t.Chdir also sets PWD to the path through the symlink, as a db, err := openDB(path, reportParams)
// shell does after cd.
t.Chdir(filepath.Join(dir, "home", "proj"))
scanOK(t, ".")
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
}
// TestRunScanFollowsSymlinkBeforeDotDot scans a path with a ".." after
// home/proj, a symlink to data/proj: home/proj/.. from dir, and .. after
// a cd through home/proj. As with realpath, the ".." counts from
// data/proj, so data is scanned and home is not.
func TestRunScanFollowsSymlinkBeforeDotDot(t *testing.T) {
for _, tc := range []struct{ wd, operand string }{
{"", "home/proj/.."},
{"home/proj", ".."},
} {
t.Run(tc.operand, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "data/proj/f", pattern(1, 10))
g := writeFile(t, dir, "data/g", pattern(2, 10))
writeFile(t, dir, "home/h", pattern(3, 10))
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
// t.Chdir also sets PWD to the path given, as a shell does
// after cd.
t.Chdir(filepath.Join(dir, tc.wd))
scanOK(t, tc.operand)
want := []string{f, g}
slices.Sort(want)
if got := storedPaths(t, path); !slices.Equal(got, want) {
t.Errorf("records = %q, want %q", got, want)
}
})
}
}
// TestRunScanSkipsSymlinkToFIFOOperand scans link, a symlink to a FIFO.
// The scan warns about the FIFO by its real path, counts it as skipped,
// and exits 0.
func TestRunScanSkipsSymlinkToFIFOOperand(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t)
fifo := filepath.Join(dir, "fifo")
err := unix.Mkfifo(fifo, 0o600)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
link := filepath.Join(dir, "link") t.Cleanup(func() { _ = db.Close() })
symlink(t, fifo, link)
stderr := scanOK(t, link) recordByPath(t, dbRecords(t, db), filepath.Join(link, "sub", "f"))
warning := "walk " + fifo + ": skipping FIFO operand\n"
if !strings.Contains(stderr, warning) {
t.Errorf("stderr = %q, want %q", stderr, warning)
}
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
if !strings.Contains(stderr, summary) {
t.Errorf("stderr = %q, want %q", stderr, summary)
}
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
zfs := filepath.Join(realTempDir(t), ".zfs") zfs := filepath.Join(t.TempDir(), ".zfs")
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10)) f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
// No scan records a file inside a .zfs directory, so the record // No scan records a file inside a .zfs directory, so the record
@@ -1220,7 +997,7 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly") hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
writeFile(t, hourly, "f", pattern(1, 10)) writeFile(t, hourly, "f", pattern(1, 10))
f := writeFile(t, dir, "f", pattern(2, 10)) f := writeFile(t, dir, "f", pattern(2, 10))
@@ -1264,19 +1041,23 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where // TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about // snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
// by its real path, counted as skipped, and not walked. // by the path given, counted as skipped, and not walked.
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) { func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t)) t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t) dir := t.TempDir()
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10)) writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
snaps := filepath.Join(dir, "snaps") snaps := filepath.Join(dir, "snaps")
symlink(t, filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
stderr := scanOK(t, filepath.Join(snaps, "daily")) err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(snaps, "daily")
stderr := scanOK(t, daily)
daily := filepath.Join(dir, "pool", ".zfs", "snapshot", "daily")
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n" warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " + summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n" "0 unchanged), 1 skipped\n"
@@ -1288,19 +1069,24 @@ func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
} }
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is // TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to pool/data. The operand is scanned as usual, and its file // a symlink to a directory outside any .zfs directory. The operand is
// is recorded by its real path. // scanned as usual, and its file is recorded by the path given.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) { func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
dir := realTempDir(t) dir := t.TempDir()
f := writeFile(t, dir, "pool/data/daily/f", pattern(1, 10)) writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
data := filepath.Join(dir, "data") data := filepath.Join(dir, "data")
symlink(t, filepath.Join(dir, "pool", "data"), data)
stderr := scanOK(t, filepath.Join(data, "daily")) err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
if err != nil {
t.Fatal(err)
}
daily := filepath.Join(data, "daily")
stderr := scanOK(t, daily)
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " + summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
"0 unchanged), 0 skipped\n" "0 unchanged), 0 skipped\n"
@@ -1308,9 +1094,14 @@ func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
t.Errorf("stderr = %q, want %q", stderr, summary) t.Errorf("stderr = %q, want %q", stderr, summary)
} }
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) { db, err := openDB(path, reportParams)
t.Errorf("records = %q, want %q", got, []string{f}) if err != nil {
t.Fatal(err)
} }
t.Cleanup(func() { _ = db.Close() })
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
} }
func TestRunReportSucceeds(t *testing.T) { func TestRunReportSucceeds(t *testing.T) {
+1 -1
View File
@@ -1,4 +1,4 @@
package sfdupes package main
import ( import (
"fmt" "fmt"
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported startProgress package main
import ( import (
"fmt" "fmt"
@@ -642,9 +642,9 @@ func TestTerminalStalledLines(t *testing.T) {
{label: scanPhase("hash"), stalledOn: hashStalledOn}, {label: scanPhase("hash"), stalledOn: hashStalledOn},
{label: scanPhase("update"), stalledOn: "db"}, {label: scanPhase("update"), stalledOn: "db"},
{label: treesPhase("read"), stalledOn: "db"}, {label: treesPhase("read"), stalledOn: "db"},
{label: treesPhase("group"), stalledOn: "cpu/disk"}, {label: treesPhase("group"), stalledOn: "cpu"},
{label: treesPhase("write"), stalledOn: "out"}, {label: treesPhase("write"), stalledOn: "out"},
{label: reportPhase("read"), stalledOn: "db/disk/out"}, {label: reportPhase("read"), stalledOn: "db/out"},
} }
for _, params := range phases { for _, params := range phases {
+18 -46
View File
@@ -1,17 +1,14 @@
package sfdupes package main
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"slices" "slices"
"strings" "strings"
"syscall"
"time" "time"
) )
@@ -44,10 +41,10 @@ func reportPhase(name string) string {
} }
// runReport implements the report subcommand: it prints the file-level // runReport implements the report subcommand: it prints the file-level
// duplicates report as TSV on stdout. Of the scanned filesystem it // duplicates report as TSV on stdout. It never touches the scanned
// only checks, with pathExists, that each path in a duplicate group is // filesystem; its only I/O is the database, stdout, and stderr. It
// still there. It names the database on stderr before it opens it. Any // names the database on stderr before it opens it. Any database
// database problem, including a missing database, is fatal. // problem, including a missing database, is fatal.
// //
// The work runs in two phases, each under a progress display of its // The work runs in two phases, each under a progress display of its
// own on stderr: open (open the database) and read (read the number of // own on stderr: open (open the database) and read (read the number of
@@ -80,14 +77,13 @@ func runReport(ctx context.Context, stdout io.Writer) error {
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
// The read phase checks the paths of each duplicate group on disk // The read phase writes the report to stdout while it reads the
// and writes the report to stdout while it reads the records, so it // records, so it may wait on either.
// may wait on any of the three.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: reportPhase("read"), unit: "records", total: -1, label: reportPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db/disk/out", waitingFor: countingRecords, stalledOn: "db/out",
}) })
report, err := readReport(ctx, db, dbPath, stdout, pathExists, prog) report, err := readReport(ctx, db, dbPath, stdout, prog)
prog.finish() prog.finish()
@@ -108,15 +104,14 @@ func runReport(ctx context.Context, stdout io.Writer) error {
// dbPath, as its total, then reads every record in signature order, // dbPath, as its total, then reads every record in signature order,
// counting each on prog, and writes the report to stdout: the header // counting each on prog, and writes the report to stdout: the header
// line, then the rows of each size's duplicate groups once that size is // line, then the rows of each size's duplicate groups once that size is
// read. A path for which exists returns false is left out of its group. // read. stdout may be the terminal prog is drawn on, so the lines go
// stdout may be the terminal prog is drawn on, so the lines go through // through prog's writer. It returns what it wrote, with the totals for
// prog's writer. It returns what it wrote, with the totals for the // the summary line.
// summary line.
func readReport(ctx context.Context, db *sql.DB, dbPath string, func readReport(ctx context.Context, db *sql.DB, dbPath string,
stdout io.Writer, exists func(path string) bool, prog *progress, stdout io.Writer, prog *progress,
) (*reportWriter, error) { ) (*reportWriter, error) {
out := prog.writer(stdout) out := prog.writer(stdout)
report := &reportWriter{out: out, exists: exists} report := &reportWriter{out: out}
_, err := fmt.Fprintln(out, "first\tdupe\tsize") _, err := fmt.Fprintln(out, "first\tdupe\tsize")
if err != nil { if err != nil {
@@ -163,10 +158,6 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
type reportWriter struct { type reportWriter struct {
out io.Writer out io.Writer
// exists reports whether a path is still on disk. A path that is
// gone is left out of its group.
exists func(path string) bool
sig scanRec // the signature being read; its path is not used sig scanRec // the signature being read; its path is not used
paths []string // the paths read so far with sig paths []string // the paths read so far with sig
groups [][]string // the duplicate groups of sig.size, each sorted groups [][]string // the duplicate groups of sig.size, each sorted
@@ -203,20 +194,11 @@ func (w *reportWriter) add(r scanRec) error {
return nil return nil
} }
// endGroup ends the paths read with w.sig: two or more that are still // endGroup ends the paths read with w.sig: two or more are a duplicate
// on disk are a duplicate group, held with its paths sorted until // group, held with its paths sorted until writeGroups writes it.
// writeGroups writes it. Empty files form no group, since removing one
// frees nothing. Only the paths of a possible group are checked, never
// a path with no duplicate or an empty file.
func (w *reportWriter) endGroup() { func (w *reportWriter) endGroup() {
if len(w.paths) >= minGroupSize && w.sig.size > 0 { if len(w.paths) < minGroupSize {
w.paths = slices.DeleteFunc(w.paths, func(p string) bool { // A path with no duplicate is dropped, and its slice reused.
return !w.exists(p)
})
}
if len(w.paths) < minGroupSize || w.sig.size == 0 {
// The paths are dropped, and their slice reused.
w.paths = w.paths[:0] w.paths = w.paths[:0]
return return
@@ -263,16 +245,6 @@ func (w *reportWriter) finish() error {
return w.writeGroups() return w.writeGroups()
} }
// pathExists reports whether path is still on disk, for report and
// trees. It is false only when lstat finds that the path, or a
// directory above it, is gone. Any other error counts as present, so
// a user who may not look inside a directory still sees its copies.
func pathExists(path string) bool {
_, err := os.Lstat(path)
return !errors.Is(err, fs.ErrNotExist) && !errors.Is(err, syscall.ENOTDIR)
}
// escapePath returns a path as it is written in a report column (README // escapePath returns a path as it is written in a report column (README
// "Report output format"): a backslash, tab, newline or carriage return // "Report output format"): a backslash, tab, newline or carriage return
// becomes \\, \t, \n or \r, and every other byte is kept as it is. // becomes \\, \t, \n or \r, and every other byte is kept as it is.
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // checks the unexported dupeGroup results package main
import ( import (
"bytes" "bytes"
@@ -72,37 +72,15 @@ type dupeGroup struct {
paths []string paths []string
} }
// everyPathExists stands in for pathExists where a test's records name
// made-up paths that are not on disk.
func everyPathExists(string) bool { return true }
// recsOnDisk returns a new directory and recs with each path moved
// under it, after creating an empty file at each moved path: report and
// trees then find every path still on disk.
func recsOnDisk(t *testing.T, recs []scanRec) (string, []scanRec) {
t.Helper()
dir := realTempDir(t)
moved := make([]scanRec, len(recs))
for i, r := range recs {
r.path = writeFile(t, dir, r.path, nil)
moved[i] = r
}
return dir, moved
}
// dupeGroups returns the duplicate groups report reads from db, in // dupeGroups returns the duplicate groups report reads from db, in
// report order, from the rows it writes: a group's rows come together, // report order, from the rows it writes: a group's rows come together,
// each naming the group's first path. Every path counts as still on // each naming the group's first path.
// disk.
func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup { func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup {
t.Helper() t.Helper()
var out bytes.Buffer var out bytes.Buffer
_, err := readReport(t.Context(), db, "", &out, everyPathExists, nil) _, err := readReport(t.Context(), db, "", &out, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -149,8 +127,7 @@ func dupeGroupsOf(t *testing.T, recs []scanRec) []dupeGroup {
} }
func TestRunReportEscapesPaths(t *testing.T) { func TestRunReportEscapesPaths(t *testing.T) {
dir, recs := recsOnDisk(t, awkwardPairRecs()) t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -161,7 +138,7 @@ func TestRunReportEscapesPaths(t *testing.T) {
} }
want := "first\tdupe\tsize\n" + want := "first\tdupe\tsize\n" +
dir + `/d/\tone\ntwo\rthree\\four/f` + "\t" + dir + "/d/A/f\t5\n" `/d/\tone\ntwo\rthree\\four/f` + "\t/d/A/f\t5\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
@@ -195,8 +172,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// Counts the records read, and draws nothing. // Counts the records read, and draws nothing.
prog := &progress{now: time.Now} prog := &progress{now: time.Now}
_, err = readReport(t.Context(), db, "", failingWriter{}, _, err = readReport(t.Context(), db, "", failingWriter{}, prog)
everyPathExists, prog)
if !errors.Is(err, errWriteFailed) || if !errors.Is(err, errWriteFailed) ||
!strings.HasPrefix(err.Error(), "write stdout: ") { !strings.HasPrefix(err.Error(), "write stdout: ") {
t.Errorf("error = %v, want write stdout: %v", err, errWriteFailed) t.Errorf("error = %v, want write stdout: %v", err, errWriteFailed)
@@ -227,8 +203,7 @@ const smokeReport = "first\tdupe\tsize\n" +
// it ends; read counts the 6 records before it reads them. The summary // it ends; read counts the 6 records before it reads them. The summary
// comes last, and stdout holds the report alone. // comes last, and stdout holds the report alone.
func TestRunReportProgressNamesEachPhase(t *testing.T) { func TestRunReportProgressNamesEachPhase(t *testing.T) {
dir, recs := recsOnDisk(t, smokeTreeRecs()) path := seedDatabase(t, smokeTreeRecs())
path := seedDatabase(t, recs)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
stderr := captureStderr(t) stderr := captureStderr(t)
@@ -241,9 +216,8 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
code, exitOK, stderr()) code, exitOK, stderr())
} }
report := strings.ReplaceAll(smokeReport, "/d/", dir+"/d/") if got := stdout.String(); got != smokeReport {
if got := stdout.String(); got != report { t.Errorf("stdout = %q, want %q", got, smokeReport)
t.Errorf("stdout = %q, want %q", got, report)
} }
// How each line starts; the rate at the end of a phase varies. // How each line starts; the rate at the end of a phase varies.
@@ -292,7 +266,7 @@ func TestReadReportOnTerminal(t *testing.T) {
waitingFor: countingRecords, waitingFor: countingRecords,
}, terminalWidth, time.Hour, time.Now) }, terminalWidth, time.Hour, time.Now)
_, err = readReport(t.Context(), db, "", os.Stderr, everyPathExists, p) _, err = readReport(t.Context(), db, "", os.Stderr, p)
p.finish() p.finish()
@@ -327,8 +301,6 @@ func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"}, scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"},
) )
_, recs = recsOnDisk(t, recs)
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
slices.Reverse(reversed) slices.Reverse(reversed)
@@ -462,71 +434,6 @@ func TestDupeGroups(t *testing.T) {
} }
} }
// TestDupeGroupsLeavesOutEmptyFiles checks that empty files form no
// duplicate group, while 1-byte files still do.
func TestDupeGroupsLeavesOutEmptyFiles(t *testing.T) {
t.Parallel()
e, x := sha256.Sum256(nil), testHash("x")
recs := []scanRec{
{size: 0, head: e, tail: e, content: e, path: "/a/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/b/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/c/__init__.py"},
{size: 1, head: x, tail: x, content: x, path: "/tiny1"},
{size: 1, head: x, tail: x, content: x, path: "/tiny2"},
}
groups := dupeGroupsOf(t, recs)
if len(groups) != 1 || groups[0].size != 1 ||
!slices.Equal(groups[0].paths, []string{"/tiny1", "/tiny2"}) {
t.Fatalf("groups = %+v, want only /tiny1 /tiny2 of size 1", groups)
}
}
// TestReadReportChecksOnlyPossibleDuplicates checks that report asks
// whether a path is on disk only when its signature has two or more
// records, and never for an empty file.
func TestReadReportChecksOnlyPossibleDuplicates(t *testing.T) {
t.Parallel()
a, u, e := testHash("a"), testHash("u"), sha256.Sum256(nil)
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/x/1"},
{size: 9, head: a, tail: a, content: a, path: "/x/2"},
{size: 9, head: a, tail: a, content: a, path: "/x/3"},
{size: 9, head: u, tail: u, content: u, path: "/lonely"},
{size: 0, head: e, tail: e, content: e, path: "/empty1"},
{size: 0, head: e, tail: e, content: e, path: "/empty2"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
var checked []string
exists := func(path string) bool {
checked = append(checked, path)
return true
}
_, err = readReport(t.Context(), db, "", io.Discard, exists, nil)
if err != nil {
t.Fatal(err)
}
slices.Sort(checked)
want := []string{"/x/1", "/x/2", "/x/3"}
if !slices.Equal(checked, want) {
t.Errorf("paths checked on disk = %q, want %q", checked, want)
}
}
func TestDupeGroupsContentSeparates(t *testing.T) { func TestDupeGroupsContentSeparates(t *testing.T) {
t.Parallel() t.Parallel()
+32 -29
View File
@@ -1,4 +1,4 @@
package sfdupes package main
import ( import (
"cmp" "cmp"
@@ -209,30 +209,29 @@ func interrupted(walked int) error {
return errInterrupted return errInterrupted
} }
// resolveRoots converts each PATH operand to its real path: absolute, // resolveRoots converts each PATH operand to an absolute, lexically
// with every symlink in it followed. Database records are keyed by // cleaned path (symlinks are not resolved) and verifies that it
// that path, so scan results depend neither on the working directory // exists. Database records are keyed by absolute path, so scan results
// nor on the symlinks an operand was given through. An operand that // must not depend on the working directory.
// cannot be resolved, because it or a symlink's target does not exist
// or cannot be read, is a fatal error before any scanning.
func resolveRoots(roots []string) ([]string, error) { func resolveRoots(roots []string) ([]string, error) {
resolved := make([]string, 0, len(roots)) abs := make([]string, 0, len(roots))
for _, root := range roots { for _, root := range roots {
a, err := absolutePath(root) a, err := filepath.Abs(root)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err) return nil, fmt.Errorf("resolve %s: %w", root, err)
} }
r, err := filepath.EvalSymlinks(a) // A nonexistent operand is a fatal error before any scanning.
_, err = os.Lstat(a)
if err != nil { if err != nil {
return nil, fmt.Errorf("resolve %s: %w", root, err) return nil, err
} }
resolved = append(resolved, r) abs = append(abs, a)
} }
return resolved, nil return abs, nil
} }
// pruneRoots drops operands already covered by another operand: // pruneRoots drops operands already covered by another operand:
@@ -418,16 +417,14 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
// walkableRoots returns the operands the walk can start from: regular // walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs, none of them inside a // files, and directories not named .zfs, none of them inside a
// directory named .zfs. The operands are real paths from resolveRoots, // directory named .zfs. Every other operand is warned about, counted as
// so these checks apply to what a symlink operand points at. Every // skipped, and dropped. A dropped operand is no longer a root, so the
// other operand is warned about, counted as skipped, and dropped. A // records stored beneath it are treated as if it had not been given:
// dropped operand is no longer a root, so the records stored beneath it // those under no other root count as outside the roots and are not
// are treated as if it had not been given: those under no other root // deleted as unverified. An operand on a filesystem in rules that is not
// count as outside the roots and are not deleted as unverified. An // persistent is dropped the same way, with a line saying so that is not
// operand on a filesystem in rules that is not persistent is dropped // a warning, and is not counted as skipped. An operand that fails lstat
// the same way, with a line saying so that is not a warning, and is not // here is kept, and the walk warns about it unless it no longer exists.
// counted as skipped. An operand that fails lstat here is kept, and the
// walk warns about it unless it no longer exists.
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string { func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
kept := make([]string, 0, len(roots)) kept := make([]string, 0, len(roots))
@@ -557,7 +554,7 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
// The space a file takes on disk can change while its size and // The space a file takes on disk can change while its size and
// mtime stay the same. ZFS, for one, counts a new file's blocks // mtime stay the same. ZFS, for one, counts a new file's blocks
// up to seconds after the write. // only seconds after the write.
if old.diskSize != ev.rec.diskSize { if old.diskSize != ev.rec.diskSize {
s.diskSizes = append(s.diskSizes, ev.rec) s.diskSizes = append(s.diskSizes, ev.rec)
} }
@@ -897,13 +894,19 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// operandWarning returns the one-line warning for an operand the walk // operandWarning returns the one-line warning for an operand the walk
// does not start from, naming the path and what it is, or "" for one it // does not start from, naming the path and what it is, or "" for one it
// does: a regular file, or a directory not named .zfs, that is not // does: a regular file, or a directory not named .zfs, that is not
// inside a directory named .zfs. resolveRoots has already followed the // inside a directory named .zfs, neither as given nor with its symlinks
// operand's symlinks, so a symlink here is an operand replaced by one // resolved. Symlinks are never followed, including as operands.
// after it was resolved, and is not followed.
func operandWarning(root string, fi fs.FileInfo) string { func operandWarning(root string, fi fs.FileInfo) string {
// The walk never enters a .zfs directory, so it starts from nothing // The walk never enters a .zfs directory, so it starts from nothing
// inside one either. // inside one either, not even through a symlink. An operand whose
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") { // symlinks cannot be resolved is checked as given.
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
resolved = root
}
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory", return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
root) root)
} }
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
package sfdupes package main
import "syscall" import "syscall"
@@ -1,6 +1,6 @@
//go:build !darwin //go:build !darwin
package sfdupes package main
import "syscall" import "syscall"
+45 -116
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported syncTree package main
import ( import (
"bytes" "bytes"
@@ -1094,7 +1094,7 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
func buildSmokeTree(t *testing.T) string { func buildSmokeTree(t *testing.T) string {
t.Helper() t.Helper()
dir := realTempDir(t) dir := t.TempDir()
one := pattern(10, 2000) one := pattern(10, 2000)
f1 := pattern(30, 3000) f1 := pattern(30, 3000)
f2 := pattern(40, 100) f2 := pattern(40, 100)
@@ -1185,12 +1185,11 @@ func assertSmokeDupeGroups(t *testing.T, dir string, db *sql.DB) {
t.Helper() t.Helper()
groups := dupeGroups(t, db) groups := dupeGroups(t, db)
if len(groups) != 4 { if len(groups) != 5 {
t.Fatalf("len(groups) = %d, want 4", len(groups)) t.Fatalf("len(groups) = %d, want 5", len(groups))
} }
// The empty files empty1 and empty2 form no group. wantSizes := []int64{3000, 2000, 100, 1, 0}
wantSizes := []int64{3000, 2000, 100, 1}
for i, g := range groups { for i, g := range groups {
if g.size != wantSizes[i] { if g.size != wantSizes[i] {
t.Errorf("groups[%d].size = %d, want %d", t.Errorf("groups[%d].size = %d, want %d",
@@ -1215,7 +1214,7 @@ func assertSmokeTreeGroups(t *testing.T, dir string, db *sql.DB) {
super, dirs := dbTree(t, db) super, dirs := dbTree(t, db)
tg := collectTreeGroups(dirs, super, everyPathExists, nil) tg := collectTreeGroups(dirs, super, nil)
if len(tg) != 1 { if len(tg) != 1 {
t.Fatalf("len(tree groups) = %d, want 1", len(tg)) t.Fatalf("len(tree groups) = %d, want 1", len(tg))
} }
@@ -2266,86 +2265,23 @@ func TestPruneRoots(t *testing.T) {
} }
} }
// TestRunReportsLeaveOutRemovedCopies scans the smoke tree, then func TestReportsNeverTouchFilesystem(t *testing.T) {
// removes copies the reports name. report leaves out each removed file,
// and a group left with one path. trees checks only the root of each
// duplicate tree: with a file under t2 removed it still names t2, and
// with t2 removed it names nothing.
func TestRunReportsLeaveOutRemovedCopies(t *testing.T) {
t.Setenv(databaseEnv, testDBPath(t))
dir := buildSmokeTree(t)
p := func(name string) string { return filepath.Join(dir, name) }
scanOK(t, dir)
for _, name := range []string{"b/copy2.bin", "tiny2", "t2/sub/f2"} {
err := os.Remove(p(name))
if err != nil {
t.Fatal(err)
}
}
want := "first\tdupe\tsize\n" +
p("t1/f1") + "\t" + p("t2/f1") + "\t3000\n" +
p("t1/f1") + "\t" + p("t3/f1") + "\t3000\n" +
p("a/one.bin") + "\t" + p("b/copy.bin") + "\t2000\n" +
p("t1/sub/f2") + "\t" + p("t3/sub/f2renamed") + "\t100\n"
if got := runStdout(t, cmdReport); got != want {
t.Errorf("report stdout = %q, want %q", got, want)
}
// disk_size, the last column, depends on the filesystem.
header := "first\tdupe\tfiles\tsize\tdisk_size\n"
want = header + p("t1") + "\t" + p("t2") + "\t2\t3100\t"
if got := runStdout(t, cmdTrees); !strings.HasPrefix(got, want) ||
strings.Count(got, "\n") != 2 {
t.Errorf("trees stdout = %q, want one row starting %q", got, want)
}
err := os.RemoveAll(p("t2"))
if err != nil {
t.Fatal(err)
}
if got := runStdout(t, cmdTrees); got != header {
t.Errorf("trees stdout after removing t2 = %q, want %q", got, header)
}
}
func TestPathExists(t *testing.T) {
t.Parallel() t.Parallel()
dir := t.TempDir() dir := buildSmokeTree(t)
file := writeFile(t, dir, "file", nil) db := openTestDB(t)
locked := writeFile(t, dir, "locked/file", nil)
// Without search permission on its directory, a file cannot be syncTree(t, db, dir)
// looked at by anyone but root.
err := os.Chmod(filepath.Dir(locked), 0) // Remove the scanned tree entirely; the analysis must be
// unaffected because it reads the database alone.
err := os.RemoveAll(dir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// Runs before t.TempDir's own cleanup, which must delete the files. assertSmokeDupeGroups(t, dir, db)
t.Cleanup(func() { assertSmokeTreeGroups(t, dir, db)
//nolint:gosec // removing the directory needs its search bit back
_ = os.Chmod(filepath.Dir(locked), 0o700)
})
cases := map[string]bool{
dir: true,
file: true,
locked: true,
filepath.Join(dir, "gone"): false,
filepath.Join(file, "under-a-file"): false,
}
for path, want := range cases {
if got := pathExists(path); got != want {
t.Errorf("pathExists(%q) = %v, want %v", path, got, want)
}
}
} }
func TestUnderRoot(t *testing.T) { func TestUnderRoot(t *testing.T) {
@@ -2374,24 +2310,33 @@ func TestUnderRoot(t *testing.T) {
} }
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own // TestRunScanPassesOverDatabase scans a tree that holds the scan's own
// database, and in one case also a hard link to the database file. The // database, reached directly and through a symlinked directory. The
// walk knows the database's files by device ID and inode number, so it // walk passes over the database file, its -wal and -shm files and its
// passes over the database file, its -wal and -shm files, its .lock // .lock file with no line and no count, and the record an earlier scan
// file and the hard link with no line and no count. The record an // left for the .lock file is deleted. A rescan of the unchanged tree
// earlier scan left for the .lock file is deleted. A rescan of the // updates nothing, and report lists none of the database's files.
// unchanged tree updates nothing, and report lists none of the
// database's files.
func TestRunScanPassesOverDatabase(t *testing.T) { func TestRunScanPassesOverDatabase(t *testing.T) {
for _, viaHardLink := range []bool{false, true} { for _, viaSymlink := range []bool{false, true} {
t.Run(fmt.Sprintf("via hard link %v", viaHardLink), func(t *testing.T) { t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
tree := realTempDir(t) base := t.TempDir()
hardLink := filepath.Join(tree, "hardlink") tree := filepath.Join(base, "real", "tree")
operand := tree
writeFile(t, tree, "a", pattern(1, 100)) writeFile(t, tree, "a", pattern(1, 100))
writeFile(t, tree, "b", pattern(1, 100)) writeFile(t, tree, "b", pattern(1, 100))
writeFile(t, tree, "empty1", nil) writeFile(t, tree, "empty1", nil)
writeFile(t, tree, "empty2", nil) writeFile(t, tree, "empty2", nil)
if viaSymlink {
err := os.Symlink(filepath.Join(base, "real"),
filepath.Join(base, "link"))
if err != nil {
t.Fatal(err)
}
operand = filepath.Join(base, "link", "tree")
}
dbPath := filepath.Join(tree, "db.sqlite") dbPath := filepath.Join(tree, "db.sqlite")
t.Setenv(databaseEnv, dbPath) t.Setenv(databaseEnv, dbPath)
@@ -2401,7 +2346,7 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
} }
err = applyChanges(t.Context(), db, []scanRec{ err = applyChanges(t.Context(), db, []scanRec{
{path: filepath.Join(tree, "db.sqlite.lock")}, {path: filepath.Join(operand, "db.sqlite.lock")},
}, nil, nil) }, nil, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -2409,30 +2354,17 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
closeScanDatabase(t.Context(), db, dbPath) closeScanDatabase(t.Context(), db, dbPath)
if viaHardLink { assertScanSummary(t,
err = os.Link(dbPath, hardLink)
if err != nil {
t.Fatal(err)
}
}
stderr := assertScanSummary(t,
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)", "4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
tree) operand)
stderr += assertScanSummary(t, assertScanSummary(t,
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)", "4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
tree) operand)
if strings.Contains(stderr, hardLink) {
t.Errorf("scan stderr = %q, want no line naming %s",
stderr, hardLink)
}
report := runStdout(t, cmdReport) report := runStdout(t, cmdReport)
if strings.Contains(report, "db.sqlite") || if strings.Contains(report, "db.sqlite") ||
strings.Contains(report, hardLink) || !strings.Contains(report, filepath.Join(operand, "empty2")) {
!strings.Contains(report, filepath.Join(tree, "b")) { t.Errorf("report = %q, want the empty files and none of "+
t.Errorf("report = %q, want a and b and none of "+
"the database's files", report) "the database's files", report)
} }
}) })
@@ -2456,9 +2388,8 @@ func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
// assertScanSummary scans operands and fails the test unless the // assertScanSummary scans operands and fails the test unless the
// summary line gives counts with nothing skipped, and the line naming // summary line gives counts with nothing skipped, and the line naming
// the database is the only one that names one of its files. It returns // the database is the only one that names one of its files.
// everything the scan printed to stderr. func assertScanSummary(t *testing.T, counts string, operands ...string) {
func assertScanSummary(t *testing.T, counts string, operands ...string) string {
t.Helper() t.Helper()
stderr := scanOK(t, operands...) stderr := scanOK(t, operands...)
@@ -2472,6 +2403,4 @@ func assertScanSummary(t *testing.T, counts string, operands ...string) string {
t.Errorf("scan %q: stderr = %q, want only the line naming the "+ t.Errorf("scan %q: stderr = %q, want only the line naming the "+
"database to name one of its files", operands, stderr) "database to name one of its files", operands, stderr)
} }
return stderr
} }
+13 -30
View File
@@ -1,4 +1,4 @@
package sfdupes package main
import ( import (
"context" "context"
@@ -38,11 +38,10 @@ func treesPhase(name string) string {
// runTrees implements the trees subcommand: it reads every record from // runTrees implements the trees subcommand: it reads every record from
// the database in path order, reconstructs the directory hierarchy from // the database in path order, reconstructs the directory hierarchy from
// the record paths, computes a Merkle-style digest per directory, and // the record paths, computes a Merkle-style digest per directory, and
// prints maximal duplicate-tree groups as TSV on stdout. Of the scanned // prints maximal duplicate-tree groups as TSV on stdout. It never
// filesystem it only checks, with pathExists, that the root of each // touches the scanned filesystem; its only I/O is the database, stdout,
// duplicate tree is still there, never the files below it. It names the // and stderr. It names the database on stderr before it opens it. Any
// database on stderr before it opens it. Any database problem, // database problem, including a missing database, is fatal.
// including a missing database, is fatal.
// //
// The work runs in four phases, each under a progress display of its // The work runs in four phases, each under a progress display of its
// own on stderr: open (open the database), read (read the number of // own on stderr: open (open the database), read (read the number of
@@ -87,13 +86,13 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
return fmt.Errorf("database %s: %w", dbPath, err) return fmt.Errorf("database %s: %w", dbPath, err)
} }
// Grouping works in memory, then checks the root of each duplicate // Grouping works in memory, so it waits on nothing but the
// tree on disk, so it waits on the processor or the disk. // processor.
prog = newProgress(progressParams{ prog = newProgress(progressParams{
label: treesPhase("group"), unit: "directories", label: treesPhase("group"), unit: "directories",
total: int64(len(allDirs)), stalledOn: "cpu/disk", total: int64(len(allDirs)), stalledOn: "cpu",
}) })
dupes := collectTreeGroups(allDirs, super, pathExists, prog) dupes := collectTreeGroups(allDirs, super, prog)
prog.finish() prog.finish()
@@ -301,14 +300,10 @@ func fileEntry(name string, r scanRec) string {
// collectTreeGroups groups directories by digest, counting each one on // collectTreeGroups groups directories by digest, counting each one on
// prog as it is grouped, and returns every maximal group with two or // prog as it is grouped, and returns every maximal group with two or
// more members for which exists returns true, each group's members // more members, each group's members sorted by path, groups ordered by
// sorted by path, groups ordered by tree size descending then by first // tree size descending then by first path ascending.
// path ascending. A group whose trees hold 0 bytes is left out, since
// removing one frees nothing. The groups it implies hold 0 bytes too,
// so leaving it out hides nothing. exists is called only on the members
// of maximal groups.
func collectTreeGroups(allDirs []*treeNode, super *treeNode, func collectTreeGroups(allDirs []*treeNode, super *treeNode,
exists func(path string) bool, prog *progress, prog *progress,
) [][]*treeNode { ) [][]*treeNode {
groups := make(map[[sha256.Size]byte][]*treeNode) groups := make(map[[sha256.Size]byte][]*treeNode)
for _, d := range allDirs { for _, d := range allDirs {
@@ -320,19 +315,7 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
var dupes [][]*treeNode var dupes [][]*treeNode
for _, g := range groups { for _, g := range groups {
// The members share a digest, so they share a total size. if len(g) < minGroupSize || suppressed(g, super) {
if len(g) < minGroupSize || g[0].totalSize == 0 ||
suppressed(g, super) {
continue
}
// A member that is gone leaves the group. A suppressed group is
// still implied: its members still on disk have parents still
// on disk, and those parents' group is checked the same way.
g = slices.DeleteFunc(g, func(n *treeNode) bool {
return !exists(n.path)
})
if len(g) < minGroupSize {
continue continue
} }
+16 -104
View File
@@ -1,12 +1,10 @@
package sfdupes //nolint:testpackage // checks the unexported treeNode results package main
import ( import (
"bytes" "bytes"
"crypto/sha256"
"database/sql" "database/sql"
"os" "os"
"slices" "slices"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -180,7 +178,7 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
t.Fatalf("directory paths = %q, want %q", got, want) t.Fatalf("directory paths = %q, want %q", got, want)
} }
groups := collectTreeGroups(dirs, super, everyPathExists, nil) groups := collectTreeGroups(dirs, super, nil)
gotGroups := groupPaths(groups) gotGroups := groupPaths(groups)
wantGroups := [][]string{{"/a", "/c"}} wantGroups := [][]string{{"/a", "/c"}}
@@ -196,8 +194,7 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
} }
func TestRunTreesEscapesPaths(t *testing.T) { func TestRunTreesEscapesPaths(t *testing.T) {
dir, recs := recsOnDisk(t, awkwardPairRecs()) t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -208,7 +205,7 @@ func TestRunTreesEscapesPaths(t *testing.T) {
} }
want := "first\tdupe\tfiles\tsize\tdisk_size\n" + want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
dir + `/d/\tone\ntwo\rthree\\four` + "\t" + dir + "/d/A\t1\t5\t4096\n" `/d/\tone\ntwo\rthree\\four` + "\t/d/A\t1\t5\t4096\n"
if got := stdout.String(); got != want { if got := stdout.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
@@ -226,11 +223,9 @@ func TestRunTreesDiskSize(t *testing.T) {
{size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"}, {size: 20, diskSize: 1024, head: g, tail: g, content: g, path: "/b/sub/g"},
} }
dir, recs := recsOnDisk(t, recs)
t.Setenv(databaseEnv, seedDatabase(t, recs)) t.Setenv(databaseEnv, seedDatabase(t, recs))
want := "first\tdupe\tfiles\tsize\tdisk_size\n" + want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
dir + "/a\t" + dir + "/b\t2\t30\t1536\n"
if got := runStdout(t, cmdTrees); got != want { if got := runStdout(t, cmdTrees); got != want {
t.Errorf("stdout = %q, want %q", got, want) t.Errorf("stdout = %q, want %q", got, want)
} }
@@ -241,11 +236,10 @@ func TestRunTreesDiskSize(t *testing.T) {
// there. After the line naming the database, each phase prints a line // there. After the line naming the database, each phase prints a line
// naming it with its place among the four as it starts and another as // naming it with its place among the four as it starts and another as
// it ends. read counts the 6 records before it reads them, group counts // it ends. read counts the 6 records before it reads them, group counts
// the directories, and write the 1 group. The summary comes last, and // the 8 directories, and write the 1 group. The summary comes last, and
// stdout holds the report alone. // stdout holds the report alone.
func TestRunTreesProgressNamesEachPhase(t *testing.T) { func TestRunTreesProgressNamesEachPhase(t *testing.T) {
dir, recs := recsOnDisk(t, smokeTreeRecs()) path := seedDatabase(t, smokeTreeRecs())
path := seedDatabase(t, recs)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
stderr := captureStderr(t) stderr := captureStderr(t)
@@ -259,15 +253,11 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
} }
report := "first\tdupe\tfiles\tsize\tdisk_size\n" + report := "first\tdupe\tfiles\tsize\tdisk_size\n" +
dir + "/d/t1\t" + dir + "/d/t2\t2\t3100\t8192\n" "/d/t1\t/d/t2\t2\t3100\t8192\n"
if got := stdout.String(); got != report { if got := stdout.String(); got != report {
t.Errorf("stdout = %q, want %q", got, report) t.Errorf("stdout = %q, want %q", got, report)
} }
// The smoke-test records name 8 directories, / included. Moved under
// dir, they name one more for each slash in dir.
dirs := strconv.Itoa(8 + strings.Count(dir, "/"))
// How each line starts; the rate at the end of a phase varies. // How each line starts; the rate at the end of a phase varies.
want := []string{ want := []string{
"trees: database " + path + " (from " + databaseEnv + ")", "trees: database " + path + " (from " + databaseEnv + ")",
@@ -275,9 +265,8 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
treesOpenLine, treesOpenLine,
"[2/4] read: counting the records, elapsed 0s, remain unknown", "[2/4] read: counting the records, elapsed 0s, remain unknown",
"[2/4] read: [6/6] 100%, ", "[2/4] read: [6/6] 100%, ",
"[3/4] group: [0/" + dirs + "] 0%, 0 directories/s, elapsed 0s, " + "[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown",
"remain unknown", "[3/4] group: [8/8] 100%, ",
"[3/4] group: [" + dirs + "/" + dirs + "] 100%, ",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown", "[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown",
"[4/4] write: [1/1] 100%, ", "[4/4] write: [1/1] 100%, ",
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ", "trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
@@ -306,7 +295,7 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
func TestWriteTreeGroupsOnTerminal(t *testing.T) { func TestWriteTreeGroupsOnTerminal(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
super, dirs := treeOf(t, smokeTreeRecs()) super, dirs := treeOf(t, smokeTreeRecs())
dupes := collectTreeGroups(dirs, super, everyPathExists, nil) dupes := collectTreeGroups(dirs, super, nil)
// No timed draws: the display is drawn as it starts and as it ends. // No timed draws: the display is drawn as it starts and as it ends.
p := startProgress(progressParams{ p := startProgress(progressParams{
@@ -387,7 +376,7 @@ func TestCollectTreeGroupsMaximal(t *testing.T) {
super, dirs := treeOf(t, smokeTreeRecs()) super, dirs := treeOf(t, smokeTreeRecs())
groups := collectTreeGroups(dirs, super, everyPathExists, nil) groups := collectTreeGroups(dirs, super, nil)
want := [][]string{{"/d/t1", "/d/t2"}} want := [][]string{{"/d/t1", "/d/t2"}}
if got := groupPaths(groups); !slices.EqualFunc(got, want, if got := groupPaths(groups); !slices.EqualFunc(got, want,
@@ -411,16 +400,14 @@ func TestCollectTreeGroupsDeterministic(t *testing.T) {
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
forward := groupPaths(collectTreeGroups(dirs, super, everyPathExists, forward := groupPaths(collectTreeGroups(dirs, super, nil))
nil))
reversed := slices.Clone(recs) reversed := slices.Clone(recs)
slices.Reverse(reversed) slices.Reverse(reversed)
superR, dirsR := treeOf(t, reversed) superR, dirsR := treeOf(t, reversed)
backward := groupPaths(collectTreeGroups(dirsR, superR, everyPathExists, backward := groupPaths(collectTreeGroups(dirsR, superR, nil))
nil))
if !slices.EqualFunc(forward, backward, slices.Equal) { if !slices.EqualFunc(forward, backward, slices.Equal) {
t.Fatalf("output depends on record order: %v vs %v", t.Fatalf("output depends on record order: %v vs %v",
forward, backward) forward, backward)
@@ -440,36 +427,7 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil)) got := groupPaths(collectTreeGroups(dirs, super, nil))
want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
}
// TestCollectTreeGroupsLeavesOutEmptyTrees checks that directories
// holding only empty files form no group, and that an empty file still
// counts toward the digest of a tree that holds other files.
func TestCollectTreeGroupsLeavesOutEmptyTrees(t *testing.T) {
t.Parallel()
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"),
sha256.Sum256(nil)
recs := []scanRec{
{size: 0, head: e, tail: e, content: e, path: "/e/a/.gitkeep"},
{size: 0, head: e, tail: e, content: e, path: "/e/b/.gitkeep"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
{size: 0, head: e, tail: e, content: e, path: "/p/x1/.gitkeep"},
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
{size: 0, head: e, tail: e, content: e, path: "/p/x2/.gitkeep"},
// Without the empty file, /p/x3 is no copy of /p/x1.
{size: 10, head: h, tail: tl, content: c, path: "/p/x3/f"},
}
super, dirs := treeOf(t, recs)
got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil))
want := [][]string{{"/p/x1", "/p/x2"}} want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) { if !slices.EqualFunc(got, want, slices.Equal) {
@@ -492,56 +450,10 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
super, dirs := treeOf(t, recs) super, dirs := treeOf(t, recs)
got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, nil)) got := groupPaths(collectTreeGroups(dirs, super, nil))
want := [][]string{{"/p/a/x", "/q/b/x"}} want := [][]string{{"/p/a/x", "/q/b/x"}}
if !slices.EqualFunc(got, want, slices.Equal) { if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want) t.Fatalf("groups = %v, want %v", got, want)
} }
} }
// TestCollectTreeGroupsChecksOnlyReportedCopies checks that trees asks
// whether a directory is on disk only for the members of the groups it
// returns. It never asks about /a/proj/sub, which lies inside a copy
// and whose group the group of /a/proj implies, nor about /a/empty,
// which holds 0 bytes.
func TestCollectTreeGroupsChecksOnlyReportedCopies(t *testing.T) {
t.Parallel()
f, g, u, e := testHash("f"), testHash("g"), testHash("u"),
sha256.Sum256(nil)
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/a/proj/f"},
{size: 20, head: g, tail: g, content: g, path: "/a/proj/sub/g"},
{size: 0, head: e, tail: e, content: e, path: "/a/empty/.gitkeep"},
{size: 10, head: f, tail: f, content: f, path: "/b/proj/f"},
{size: 20, head: g, tail: g, content: g, path: "/b/proj/sub/g"},
{size: 0, head: e, tail: e, content: e, path: "/b/empty/.gitkeep"},
// /a lacks this file, so /a and /b are no copies of each other.
{size: 30, head: u, tail: u, content: u, path: "/b/lonely"},
}
super, dirs := treeOf(t, recs)
var checked []string
exists := func(path string) bool {
checked = append(checked, path)
return true
}
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
want := [][]string{{"/a/proj", "/b/proj"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Fatalf("groups = %v, want %v", got, want)
}
slices.Sort(checked)
if !slices.Equal(checked, want[0]) {
t.Errorf("directories checked on disk = %q, want %q",
checked, want[0])
}
}