Compare commits

..
Author SHA1 Message Date
sneak a00b97e299 Say ZFS counts a new file's blocks up to seconds after the write (closes #152)
check / check (push) Canceled after 0s
`README.md` and a comment in `scan.go` said "only seconds after the write". That is false, because the count can land in under a second. Both now agree with the comment on `diskSizeOf` in `scan_test.go`.

Model: opus-5-5
2026-11-03 03:30:33 +00:00
30 changed files with 1510 additions and 3343 deletions

No files matched your search

+2 -2
View File
@@ -4,7 +4,7 @@ export CGO_ENABLED = 0
BINARY := sfdupes
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
@@ -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
# own build cache) always decides what to recompile.
sfdupes:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/sfdupes
go build -ldflags "$(LDFLAGS)" -o $(BINARY)
build: sfdupes
+130 -299
View File
@@ -17,18 +17,18 @@ gigabyte. A large file is never read in full. A match between two large files is
a strong candidate, not proof.
`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
in one file. The database is kept between runs. A rescan reads only new and
changed files. That makes `scan` cheap enough to run every day from `cron`, the
system's job scheduler. `report`, `trees` and `info` work from the database and
can run at any time. `dwim` scans `/` and then prints the `trees` report, for a
first look at a new machine with one command.
system's job scheduler. `report`, `trees` and `info` read the database alone and
can run at any time.
## Getting Started
```sh
make build
export SFDUPES_DATABASE="$HOME/.local/share/sfdupes/db.sqlite"
./sfdupes scan /srv
./sfdupes report > dupes.tsv
./sfdupes trees > dupetrees.tsv
@@ -42,14 +42,10 @@ as opposed to a directory, a symlink or a device node. A symlink is a file that
holds the path of another file and stands in for it. A device node is a file
that stands for a piece of hardware, such as a disk or a terminal. `report`
prints the file-level duplicates. `trees` prints the duplicate directory trees.
Both cover the whole database, or only the paths given after them, as in
`sfdupes report /srv/photos`. `info` prints the database's path, size and record
count.
`info` prints the database's path, size and record count.
Root keeps the database in `/var/lib/sfdupes/db.sqlite`. Any other user keeps it
in `sfdupes/db.sqlite` in the XDG state directory, `~/.local/state` by default
on Linux. `SFDUPES_DATABASE` names another file (see "Location"). The first
`scan` creates the database.
The database is `/var/lib/sfdupes/db.sqlite` unless `SFDUPES_DATABASE` names
another file. The first `scan` creates it.
`report`, `trees` and `info` write their data to stdout. Progress, warnings and
errors go to stderr. stdout and stderr are a program's two output streams. The
@@ -60,25 +56,25 @@ shell shows both on the terminal, and `>` redirects stdout alone.
With Go installed, this builds and installs the newest tagged release:
```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:
```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.
`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
a release tag. For `@main` it is the commit's tag when it has one. Otherwise it
is a pseudo-version, Go's version string for an untagged commit. That string
ends in the commit's hash. A binary built from a clone carries the output of
`git describe --tags --always --dirty` in that clone, such as `v0.0.3` on a
tagged commit. `VERSION` is `dev` when no version is known. To see everything Go
recorded in an installed binary:
a release tag such as `v0.0.3`. For `@main` it is the commit's tag when it has
one. Otherwise it is a pseudo-version, Go's version string for an untagged
commit. That string ends in the commit's hash. A binary built from a clone
carries the output of `git describe --tags --always --dirty` in that clone, such
as `v0.0.3` on a tagged commit. `VERSION` is `dev` when no version is known. To
see everything Go recorded in an installed binary:
```sh
go version -m "$(command -v sfdupes)"
@@ -101,15 +97,13 @@ Each `-v` flag below makes a bind mount, which shows a host directory at a path
inside the container. Bind-mount `/var/lib/sfdupes` to keep the database between
runs. Mount the tree to scan at the same path inside the container as on the
host. Read-only access is enough. The database then records the host's paths,
and so do the reports. `report` and `trees` need that mount too. Without it,
every recorded path counts as gone, and each report holds only its header line.
and so do the reports.
```sh
make docker
docker run --rm -v /srv:/srv:ro -v /var/lib/sfdupes:/var/lib/sfdupes \
sfdupes scan /srv
docker run --rm -v /srv:/srv:ro -v /var/lib/sfdupes:/var/lib/sfdupes \
sfdupes report > dupes.tsv
docker run --rm -v /var/lib/sfdupes:/var/lib/sfdupes sfdupes report > dupes.tsv
```
### Daily scan from `cron`
@@ -117,20 +111,15 @@ docker run --rm -v /srv:/srv:ro -v /var/lib/sfdupes:/var/lib/sfdupes \
Run `scan` as root, so that it can read every file. `scan` warns about a
directory it cannot read, and about a new or changed file it cannot read. It
skips the path and deletes the records at or below it (see "Rules for the
walk").
An unmounted disk leaves its mount point empty, so `scan` deletes every record
under it, and the next scan reads every file on the disk again. Test the mount
point of each disk at or below `/srv` with its own `mountpoint -q`, joined with
`&&`, and test `/srv` only when it is one. As the file `/etc/cron.d/sfdupes`:
walk"). As the file `/etc/cron.d/sfdupes`:
```
30 3 * * * root if mountpoint -q /srv; then /usr/local/bin/sfdupes scan /srv 2>>/var/log/sfdupes.log || tail -n 3 /var/log/sfdupes.log; else echo "a disk is not mounted; scan of /srv skipped"; fi
30 3 * * * root /usr/local/bin/sfdupes scan /srv 2>>/var/log/sfdupes.log || tail -n 3 /var/log/sfdupes.log
```
- The first scan creates `/var/lib/sfdupes/db.sqlite` and its directory. To keep
the database elsewhere, put `SFDUPES_DATABASE=/path/to/db.sqlite` directly
before `/usr/local/bin/sfdupes`.
the database elsewhere, put `SFDUPES_DATABASE=/path/to/db.sqlite` before the
command on the same line.
- `scan` writes nothing to stdout. Its stderr, appended here to
`/var/log/sfdupes.log`, holds:
- one line naming the database;
@@ -156,9 +145,8 @@ point of each disk at or below `/srv` with its own `mountpoint -q`, joined with
- `report`, `trees` and `info` need only read access to the database. The umask
is the setting that removes permissions from the files a process creates.
Under the usual umask of `022`, the first scan creates the database readable
by every user. Another user can then run those three commands against root's
database, with `SFDUPES_DATABASE=/var/lib/sfdupes/db.sqlite` before the
command.
by every user. An unprivileged user can then run those three commands against
root's database.
### Reading the reports
@@ -170,10 +158,8 @@ to keep is your choice.
A row is a candidate, not proof:
- The reports show each file as it was when a scan last recorded it. They check
only that each copy still exists: the file, or the directory at the top of a
tree. A file may have changed since, and a file inside a tree may be gone. Run
`scan` again to bring the reports up to date.
- The reports show each file as it was when a scan last recorded it. A file may
have changed or gone since.
- A file of 100 MiB or more is compared only on samples of its content (see
"Duplicate detection").
- Two paths that are hard links, two names for one file, are listed as
@@ -221,9 +207,8 @@ makes the comparison exact. Reading samples cuts a 150 GiB file to about 150 MiB
of reads, at the price of a comparison that is not exact.
The hashes live in a database that is kept between runs. A rescan reads only new
files, and files whose size or modification time (mtime) differs from their
record. All analysis then works from the database. The reports only check that
each copy they name still exists.
files, and files whose size changed or whose modification time (mtime) is newer
than recorded. All analysis then works from the database alone.
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
@@ -249,10 +234,9 @@ In order:
the files of the directories it is reading.
3. **Rescan cheaply, analyze from the database.** The scan keeps its database
between runs, and an unchanged file is never read again. `report`, `trees`
and `info` work from the database and never read a scanned file. `report` and
`trees` check only that each copy they name still exists, and leave out a
copy that is gone. `scan` is built to run from `cron`. The reports run at any
time, against what the database holds.
and `info` work from the database alone and never touch the scanned
filesystem. `scan` is built to run from `cron`. The reports run at any time,
against what the database holds.
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.
@@ -263,14 +247,12 @@ In order:
- `report` prints the file-level duplicate report from the database.
- `trees` prints the duplicate-tree report from the database.
- `info` prints the database's details.
- `dwim` scans `/`, then prints the duplicate-tree report.
```
sfdupes scan [--workers N] [-x] [--no-default-excludes] [--exclude PATH]... PATH...
sfdupes report [PATH...] > dupes.tsv
sfdupes trees [PATH...] > dupetrees.tsv
sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
sfdupes report > dupes.tsv
sfdupes trees > dupetrees.tsv
sfdupes info
sfdupes dwim [--workers N] [-x] [--no-default-excludes] [--exclude PATH]...
sfdupes --version
sfdupes [command] --help
```
@@ -297,37 +279,25 @@ The reference below is also the program's specification.
### Settings
- `SFDUPES_DATABASE` names the database file. When it is unset or empty, the
database is at its default path (see "Location"). There is no command-line
flag for it. The path names the file exactly, whatever characters it holds. A
relative path is relative to the working directory.
- `--workers N` (`scan`, `dwim`) sets how many directories `scan` reads at once
while it lists the trees. It also sets how many files `scan` reads at once
while it hashes them. The default is the number of CPUs. `N` must be at
least 1. A smaller value is a usage error, reported in one line on stderr with
exit 2 before anything is scanned.
- `-x`, `--one-file-system` (`scan`, `dwim`) keeps the walk of each operand out
of directories on other filesystems, as the same flag does for `du` and
`rsync`. By default the walk also enters directories on other filesystems.
- `--no-default-excludes` (`scan`, `dwim`) lets the walk enter the default
excludes. Those are the system and temporary directories the walk otherwise
leaves out, such as `/var/tmp` on Linux (see "Rules for the walk").
- `--exclude PATH` (`scan`, `dwim`) keeps the walk out of `PATH` and everything
below it. `PATH` may be a directory or a file. The flag may be given more than
once. The walk knows `PATH` by its device ID and inode number, as it knows the
database files (see "Rules for the walk"). So the walk leaves `PATH` out
whatever path leads to it. That includes a hard link to a file given as
`PATH`. A symlink given as `PATH` excludes its target. An empty `PATH`, as in
`--exclude ""`, is a usage error, reported in one line on stderr with exit 2
before anything is scanned. A `PATH` that does not exist, or whose real path
`scan` cannot find, is a fatal error. So is an operand at or below one (see
"Operands").
database is `/var/lib/sfdupes/db.sqlite`. There is no command-line flag for
it. The path names the file exactly, whatever characters it holds. A relative
path is relative to the working directory.
- `--workers N` (`scan`) sets how many directories `scan` reads at once while it
lists the trees. It also sets how many files `scan` reads at once while it
hashes them. The default is the number of CPUs. `N` must be at least 1. A
smaller value is a usage error, reported in one line on stderr with exit 2
before anything is scanned.
- `-x`, `--one-file-system` (`scan`) keeps the walk of each operand out of
directories on other filesystems, as the same flag does for `du` and `rsync`.
By default the walk also enters directories on other filesystems.
- `--no-default-excludes` (`scan`) lets the walk enter the default excludes.
Those are the system and temporary directories the walk otherwise leaves out,
such as `/var/tmp` on Linux (see "Rules for the walk").
- `--version`, `-v` prints `sfdupes VERSION` (see "Install") to stdout and
exits 0. It writes nothing to stderr.
- `--help`, `-h`, alone or after a subcommand, prints the help text to stderr
and exits 0. It writes nothing to stdout. The help of `sfdupes` and of `scan`
names the database. The help of `report`, `trees`, `info` and `dwim` says what
each prints. `sfdupes scan --help` and `sfdupes dwim --help` also list the
default excludes of the system they run on, and the filesystem types the walk
and exits 0. It writes nothing to stdout. `sfdupes scan --help` also lists the
default excludes of the system it runs on, and the filesystem types the walk
never records anything on.
### Duplicate detection
@@ -378,30 +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
the exit code is 2.
- An operand that does not exist is a fatal error, and so is a symlink whose
target does not exist. `scan` then stops before it walks anything, and exits
with code 1.
- An operand at or below an `--exclude` path is a fatal error too. The message
names the operand and the `--exclude` path. `scan` compares the operand and
each directory above it with the `--exclude` paths by device ID and inode
number, not by name.
- Each operand is resolved to its real path before the walk, as `realpath`
prints it. A relative operand is joined to the working directory as `pwd -P`
prints it. Every symlink in the path is then followed. A `..` after a symlink
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.
- An operand that does not exist is a fatal error. `scan` then stops before it
walks anything, and exits 1.
- Each operand is made absolute before the walk. A relative operand is joined to
the working directory as `pwd` prints it, with its symlinks kept. A `.` or
`..` in it is resolved as text, and symlinks are not followed. Each record is
stored under that absolute path.
- All operands belong to one scan, and one walk covers them all at once (see
"Concurrency").
- An operand that repeats another is dropped before the walk. Two operands
repeat each other when their real paths are the same.
- An operand that repeats another is dropped before the walk.
- 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
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`.
cases below applies. No path is then walked twice.
- 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:
- a default exclude (see "Rules for the walk");
@@ -412,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
such a directory, so the inner operand is walked on its own.
- No other case keeps an operand under another.
- An operand that is neither a regular file nor a directory is not scanned. Such
an operand is, for example, a symlink to a device node. `scan` prints a
warning naming the real path and what it is. It counts the operand as skipped
and drops it. The scan keeps the records beneath a dropped operand, unless
another operand covers them.
- No other case keeps an operand under another. An operand reached through a
symlinked directory below the outer operand is dropped too. The outer
operand's walk does not follow the symlink, so no file under the inner operand
is scanned. The records that an earlier scan left under it are deleted. The
`-x` case above also covers an operand reached that way.
- 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
given alone or under another operand. The check looks at the real path, so a
symlink into a `.zfs` directory is not scanned either. `scan` prints a warning
naming the real path, counts the operand as skipped and drops it. The records
beneath it are kept, unless it lies under another operand. Then they are
deleted, because that operand's walk never enters `.zfs` (see "Rules for the
walk").
given alone or under another operand. The check looks at the path as given and
at the path with its symlinks resolved. `scan` prints a warning naming the
path, counts the operand as skipped and drops it. The records beneath it are
kept, unless it lies under another operand. Then they are deleted, because
that operand's walk never enters `.zfs` (see "Rules for the walk").
- 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
skipped.
@@ -440,16 +403,17 @@ operands:
- A file not yet in the database is hashed and inserted.
- A file already in the database is not read again when its size and mtime match
its record. This is what makes a daily rescan cheap.
its record. The mtime matches when it is not newer than the recorded one. This
is what makes a daily rescan cheap.
- 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
unchanged. ZFS, for example, counts a new file's blocks up to seconds after
the write. A block is the unit in which a filesystem allocates disk space. A
file scanned within those seconds gets its real `disk_size` at the next scan.
- `scan` hashes a file again when its size or its mtime differs from its record.
An older mtime counts as much as a newer one. A file put back by `cp -p`,
`rsync -a` or a restore from backup often has an older mtime. The mtime is
compared to the nanosecond, as finely as the filesystem records it.
- `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
same-size rewrite counts as a change whenever the filesystem gives it a later
mtime, even within the same second.
- The scan deletes each record under a scanned operand whose file it could not
verify. That covers deleted files, and paths the scan could not stat or read
this run. To stat a file is to read its details, such as its size, without
@@ -460,16 +424,15 @@ operands:
scanned on different schedules into one database.
- The scan keeps every record at or below a path that the walk leaves out for
one of these reasons:
- it is an `--exclude` path;
- it is a default exclude;
- it is not on a persistent filesystem;
- with `-x`, it is a directory on another filesystem than its operand.
Such records count as outside the scanned operands. So with `-x`, a
filesystem mounted under an operand can be scanned on its own schedule into
the same database. An operand may itself lie at or inside such a path, other
than an `--exclude` path (see "Operands"). Its walk verifies the records
below it, so they are deleted when their files are gone.
the same database. An operand may itself lie at or inside such a path, as
"Operands" describes. Its walk verifies the records below it, so they are
deleted when their files are gone.
#### Phases
@@ -521,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
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
path leads to them. The walk follows no symlink it finds. It opens each
operand at its real path, not the path given. An operand given through a
symlink can lead to the database files, and the walk passes over them there
as well. The walk prints no line for the database files and does not count
them as skipped. A record that an earlier scan left for one of them is
deleted.
path leads to them. The walk follows no symlink it finds. It does open each
operand at the path given, even when that path runs through a symlinked
directory. Such an operand can lead to the database files, and the walk
passes over them there as well. The walk prints no line for the database
files and does not count them as skipped. A record that an earlier scan left
for one of them is deleted.
- By default the walk enters directories on other filesystems than its
operand's. With `-x`, it never enters a directory on a different filesystem
@@ -568,17 +531,6 @@ walk practical on large or busy storage pools.
The path is not counted as skipped. The records below it are kept, as
described under "What a scan does to the database".
- The walk never enters or records an `--exclude` path, nor anything below it.
For each one it reaches, `scan` prints a line on stderr naming it:
```
scan: skipped /srv/cache (--exclude)
```
The path is not counted as skipped. The scan keeps the records at and below
it, as described under "What a scan does to the database".
`--no-default-excludes` does not change this rule.
- Below an operand, the walk does not enter a default exclude. On Linux, the
default excludes hold temporary files and the kernel's view of the running
system. They depend on the system, and `sfdupes scan --help` lists those of
@@ -653,8 +605,7 @@ from a thread of its own and never blocks the workers.
it repeats another or lies under another;
- the progress display of each phase (see "Progress"). `scan` prints each
warning for a skipped path as it happens. It prints a line the same way for
each path left out as an `--exclude` path, as a default exclude or as not on a
persistent filesystem;
each path left out as a default exclude or as not on a persistent filesystem;
- the summary line;
- a warning when a report still has the database open as the scan ends (see
"Concurrent reads").
@@ -695,32 +646,22 @@ and `scan` leaves it ignored.
### `report` mode
`report` reads every record from the database. It takes optional `PATH`
operands. With none, it uses every record. With one or more, it uses only the
records at or below them (see "Operands of `report` and `trees`"). `report`
never opens a scanned file. It checks that each path in a group still exists,
and looks at no other path. It never looks up an operand. The check is `lstat`,
the system call that looks up a path without following a symlink:
- A path is gone when `lstat` finds that it, or a directory above it, does not
exist.
- Any other error, such as a permission error, counts as present. So a user who
may not look inside a directory still sees its copies.
The check does not notice a file that changed after the scan. Run `scan` again
to bring the report up to date.
`report` reads every record from the database. It takes no positional arguments.
A positional argument is anything on the command line that is not a flag, such
as the paths `scan` takes. `report` never touches the scanned filesystem. It
neither stats nor opens any path in the records. It reads the database and
writes to stdout and stderr, and nothing else. Its output is the same whether or
not the scanned filesystem is still mounted.
Processing:
- Records are grouped by signature, the four values
`(size, head, tail, content)`.
- In a group with two or more paths, a path that is gone is left out. A group
that still has two or more paths is a duplicate group.
- Empty files form no group, because removing one frees nothing.
- Every group with two or more paths is a duplicate group.
- Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`.
- Groups are ordered by size, largest first, then by `first` path. The output is
the same for the same database contents 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
"Progress"). `open` opens the database. `read` gets the number of records from
@@ -732,27 +673,6 @@ with one signature come together, and so do the records of one size. So `report`
holds only the duplicate groups of the size it is reading. Once it reaches a
smaller size, it writes them, ordered by `first` path.
#### Operands of `report` and `trees`
`report` and `trees` take the same `PATH` operands:
- A record is used when its path is an operand or lies below one. `/srv/photos`
covers `/srv/photos/x` but not `/srv/photos2/x`.
- The records used are grouped, ordered and totalled as they are without
operands. A copy outside the operands is not reported. With copies `/a/f`,
`/srv/photos/x` and `/srv/photos/y`, `sfdupes report /srv/photos` prints one
row, `/srv/photos/x` and `/srv/photos/y`.
- A relative operand is joined to the working directory as `pwd -P` prints it.
- Each operand is then cleaned as text, as `realpath -ms` prints it. Its `.`
elements and repeated or trailing slashes are dropped. Each `..` drops the
element before it.
- No operand is looked up on the filesystem. A symlink in an operand is not
followed, and an operand need not exist. The records hold real paths (see
"Operands" under "`scan` mode"), so an operand that passes through a symlink
matches no record.
- An operand that matches no record is not an error; it adds nothing to the
report.
#### Report output format
The report is TSV (tab-separated values) on stdout. It holds a header line, then
@@ -780,25 +700,22 @@ Grouping and ordering use the stored path. The warnings about skipped paths that
The summary line on stderr gives:
- the records read, which are all the records in the database, with or without
operands;
- the records read;
- the duplicate groups;
- the dupe files, which is the number of 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` reads the same database as `report` and takes the same `PATH` operands
(see "Operands of `report` and `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 signature in both. It rebuilds
the directory tree from the paths in the records, split on `/`. Like `report`,
it never opens a scanned file and never looks up an operand. It checks only that
the directory at the top of each copy still exists, as `report` checks a path.
`trees` reads the same database as `report` and takes no positional arguments.
It reports whole duplicate directory trees. Two directories are duplicate trees
when they hold the same relative paths, and each path holds a file with the same
signature in both. Like `report`, it never touches the scanned filesystem. It
rebuilds the directory tree from the paths in the records, split on `/`.
Definitions:
@@ -817,26 +734,15 @@ Processing:
- `trees` builds the directory tree and computes every directory's digest. It
then groups the directories by digest. Every group with two or more
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
implies. A group is left out when its members' parents are distinct
directories that all have the same digest. The parents' group, or an
ancestor's, already implies it. A group is always reported when it holds
sibling directories, or when its members' parents have different digests.
- With operands, `trees` builds the tree from the records it uses. It reports
only directories at or below an operand. Any other directory lies above an
operand and can hold files that are not used, so its digest does not describe
it. Such a directory is never reported and never implies a group of its
children.
- A directory that is gone is dropped from its group. A group left with fewer
than two directories is not reported.
- Within a group, paths are sorted in byte order. The first is `first`, and
every other path is a `dupe`.
- Groups are ordered by total tree size, largest first, then by `first` path.
The output is the same for the same database contents and the same paths on
disk.
The output is the same for the same database contents.
Two limitations are accepted:
@@ -850,9 +756,8 @@ Two limitations are accepted:
`trees` has four phases, each with its own progress display on stderr (see
"Progress"). `open` opens the database. `read` gets the number of records from
the `totals` table, then reads them in path order. It builds the directory tree
and computes the digests as it goes. `group` groups the directories by digest,
picks the groups to report and checks that their directories still exist.
`write` writes the groups to stdout.
and computes the digests as it goes. `group` groups the directories by digest
and picks the groups to report. `write` writes them to stdout.
#### Trees output format
@@ -883,57 +788,15 @@ directory's path is `/`.
The summary line on stderr gives:
- the records read, which are all the records in the database, with or without
operands;
- the records read;
- the duplicate-tree groups;
- the dupe trees, which is the number of rows;
- the reclaimable bytes, the sum of `size` over all rows, leaving out a row
whose `dupe` lies inside another row's `dupe`.
- the reclaimable bytes, the sum of `size` over all rows.
```
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. A positional
argument is anything on the command line that is not a flag, such as the paths
`scan` takes. Like `scan`, it should run as root, so that it can read every
file.
`dwim` takes these flags of `scan`, with the same meaning (see "Settings"):
- `--workers N`;
- `-x`, `--one-file-system`;
- `--no-default-excludes`;
- `--exclude PATH`, which may be given more than once.
`dwim` runs what `sfdupes scan /` runs with the same flags. With no flags, the
walk skips the default excludes and crosses into other filesystems. The
database, its creation and the scan lock are those of `scan`. Then `dwim` runs
what `sfdupes trees` runs. The largest trees come first.
```
sfdupes dwim -x --exclude /srv/cache
```
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` takes no positional arguments and prints the database's details on
@@ -974,27 +837,12 @@ scan is writing. `info` writes its lines after the `count` display has ended.
### Database
All five subcommands work on one SQLite database file.
All four subcommands work on one SQLite database file.
#### Location
The database is the file named by `SFDUPES_DATABASE`, when that is set and not
empty. Otherwise it is at its default path, which depends on the user:
- For root, it is `/var/lib/sfdupes/db.sqlite`.
- For any other user, it is `sfdupes/db.sqlite` in the XDG state directory. That
directory is `$XDG_STATE_HOME` when that is set and not empty. Otherwise it is
`~/.local/state` on Linux and `~/Library/Application Support` on macOS. An
`XDG_STATE_HOME` that is not an absolute path is a fatal error, exit 1, for
every subcommand. The error names `XDG_STATE_HOME`.
`scan` creates each missing directory on the way to the database with mode 755.
At the default path of a user other than root, it uses mode 700 instead, as the
XDG Base Directory specification asks. Other users cannot enter a directory with
mode 700, so they cannot read a database in it.
All five subcommands use the same default, so a user's `report` reads the
database that user's `scan` wrote.
empty, and otherwise `/var/lib/sfdupes/db.sqlite`.
Each subcommand prints one line on stderr naming the database it uses, before
anything else and whether or not stderr is a terminal. The line gives the path
@@ -1005,8 +853,6 @@ scan: new database /var/lib/sfdupes/db.sqlite (default)
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
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
@@ -1034,9 +880,9 @@ Only one `scan` at a time can hold the lock.
- For its whole run, `scan` holds a lock on a lock file beside the database. The
lock is taken with the `flock(2)` system call, a request a program makes to
the kernel, and only one process can hold it. The lock file's name is the
database's path, with symlinks resolved, plus `.lock`. For root, by default,
that is `/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the
same lock file.
database's path, with symlinks resolved, plus `.lock`. By default that is
`/var/lib/sfdupes/db.sqlite.lock`. A path through a symlink names the same
lock file.
- `scan` takes the lock before it walks the filesystem or opens the database.
Just before that, it creates the database file, empty, if it is missing. So
the lock file is named the same way before the first scan as after. A symlink
@@ -1233,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
database between them.
- `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
each group still exist, and writes the report to stdout, while it reads.
- `group` of `trees` says `cpu/disk`, for work in memory and the check that each
copy still exists. Its `write` says `out`.
- The `read` phase of `report` says `db/out`, because it writes the report to
stdout while it reads.
- `group` of `trees` says `cpu`, for work in memory, and its `write` says `out`.
- A phase still waiting for its total says what it waits for, and nothing more.
On a terminal:
@@ -1258,14 +1103,6 @@ On a terminal:
⠹ [3/4] hash: [12345/98765], 92 files/s, elapsed 2m32s, remain 17m44s 12% |█ |
```
- During `scan`'s `walk`, the line below the display shows the path of the last
file the walk found. Each draw shows the path of that moment, cut short before
the terminal's last column. The path is escaped as in the reports (see "Report
output format"). Each control character left after that is written as `\x` and
its code in two hexadecimal digits, such as `\x1b` for the escape character. A
control character is one that a terminal acts on instead of showing. The line
is erased when the walk ends, and the next phase is drawn on it.
- No line reaches the terminal's last column. A line too wide for the terminal
first leaves out its rate, the words of a stalled phase, or what the phase is
waiting for. It leaves out its bar next. It is cut short at the edge only when
@@ -1299,12 +1136,9 @@ showing the count it stopped at.
### Error handling and exit codes
- `0`: success, even when files were skipped with warnings.
- `1`: a fatal error, a `scan` stopped by `SIGINT` or `SIGTERM`, or a `dwim`
stopped by either during its scan. A fatal error is one of these:
- an operand that does not exist, or whose real path `scan` cannot find;
- an `--exclude` path that does not exist, or whose real path `scan` cannot
find;
- an operand at or below an `--exclude` path;
- `1`: a fatal error, or a `scan` stopped by `SIGINT` or `SIGTERM`. A fatal
error is one of these:
- an operand that does not exist, or whose details `scan` cannot read;
- a list of mounted filesystems that `scan` cannot read;
- another `scan` holding the lock (see "The scan lock");
- a database, its directory or its lock file that cannot be created or
@@ -1324,9 +1158,7 @@ showing the count it stopped at.
`--workers abc`;
- `scan` with no operand;
- `--workers` below 1;
- an empty `--exclude` value, as in `--exclude ""`;
- an empty `PATH` for `report` or `trees`, as in `sfdupes report ""`;
- `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
stdout, such as a full disk, is reported the same way. Two cases never reach
@@ -1489,8 +1321,8 @@ commands of their own:
not a goal. `modernc.org/sqlite` is pure Go too, so the binary builds without
cgo. The `Makefile` exports `CGO_ENABLED=0`. The test build is the one
exception, because the race detector needs cgo (see `script/test`).
- `report` and `trees` give the same output for the same database contents and
the same paths on disk, whatever order the records were inserted in.
- `report` and `trees` give the same output for the same database contents,
whatever order the records were inserted in.
### Definition of done
@@ -1514,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
printf 'x' > "$d/tiny1"; printf 'x' > "$d/tiny2" # 1-byte duplicates
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
mkdir -p "$d/t1/sub" "$d/t2/sub" "$d/t3/sub"
head -c 3000 /dev/urandom > "$d/t1/f1"
@@ -1539,8 +1371,8 @@ All of the following, run in this directory, must pass:
is the path that sorts first.
- `t1/f1`, `t2/f1` and `t3/f1` form one group.
- `t1/sub/f2`, `t2/sub/f2` and `t3/sub/f2renamed` form one group.
- `tiny1` and `tiny2` form a pair.
- `unique.bin`, `tiny3`, `empty1` and `empty2` appear nowhere.
- `tiny1` and `tiny2` form a pair, and so do `empty1` and `empty2`.
- `unique.bin` and `tiny3` appear nowhere.
- The groups come in order of size, largest first.
`trees` prints exactly one row, `$d/t1` as `first` and `$d/t2` as `dupe`.
@@ -1553,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.
`unique.bin` is gone from the database.
The test suite automates this scenario (see
`internal/sfdupes/scan_test.go`). It also checks that `report` and `trees`
leave out a copy removed after the scan.
The test suite automates this scenario (see `scan_test.go`). It also checks
that `report` and `trees` never touch the scanned filesystem.
## TODO
+722 -3
View File
@@ -4,7 +4,7 @@
the tracker yet is filed as an issue first.
- Branch from `next`.
- Do the work, with tests, in small focused commits.
- Delete Next Step from `TODO.md`, in the same commit as the work.
- Record the step at the top of Completed Steps, in the same commit as the work.
- Push the branch and open a PR against `next`. Its title ends with
` (closes #N)`.
- An independent review gates each merge to `next`. Every finding is addressed
@@ -15,7 +15,8 @@
- Pre-1.0.
- The tracker is authoritative for the pre-1.0 backlog. The open issues under
the `1.0.0` milestone are what remains before the tag.
the `1.0.0` milestone are what remains before the tag. This file records
history and process, not the queue.
# Next Step
@@ -23,6 +24,722 @@
https://git.eeqj.de/sneak/sfdupes/milestone/17. The issues are not restated
here, because a copy drifts as soon as the tracker moves.
# Completed Steps
Newest first. An entry ends with its date and its issue or branch, where those
are known.
- `README.md` and a comment in `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
`scan_test.go` does (2026-11-03, https://git.eeqj.de/sneak/sfdupes/issues/152)
- The three disk size tests in `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
sentence. Every completed step is kept, with its date and its issue or branch.
The policy-compliance checklist of 2026-07-22 is folded into its step
(2026-11-02, https://git.eeqj.de/sneak/sfdupes/issues/135)
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
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.
Every statement was checked against the code on `next`. Five comments in
`main_test.go` cite "Settings", where their rules now live. One comment each
in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
now says (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
allocated blocks, `st_blocks` × 512, from the `lstat` call the walk already
makes. `trees` prints a `disk_size` column after `size`. It is the sum over
the `dupe` copy's files. On a compressed or sparse file `disk_size` differs
from `size`. A rescan updates the `disk_size` of an unchanged file whose size
on disk changed, without reading the file (2026-10-27,
https://git.eeqj.de/sneak/sfdupes/issues/147)
- The database keeps the number of records and the sum of their sizes in the one
row of a `totals` table. Three triggers on `files` keep that row current.
`info` reads that row. So do `scan`'s `load` phase and the `read` phases of
`report` and `trees`, for their totals. No count reads every record any more.
The triggers make each write to `files` slower. `scan` keeps SQLite's
temporary data in memory. That wins back part of that time (2026-10-27,
https://git.eeqj.de/sneak/sfdupes/issues/146)
- an operand at or inside a directory named `.zfs` is never scanned, whether
given alone or under another operand. `scan` warns about it, counts it as
skipped and does not walk it (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/142)
- A file or directory removed while the scan runs is not an error, in the walk
or in the hash phase. The scan prints no line for it and does not count it as
skipped. When the first of several hard links to a file is gone, the hash
phase reads the next one instead. On a terminal every progress display starts
with the spinner, the hash phase's included. A phase with an exact total ends
its line with its percent and a 10-column bar at the terminal's right edge.
The bar no longer moves as the line's length changes. A line too wide for the
terminal now loses its rate before its bar. The time remaining is labelled
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133)
- The `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
bytes of a SHA-256. That is half the size of the hex text they held. The
hashes stay raw from the hash phase through the database to `report` and
`trees`. Reading a record whose hash is not 32 bytes is an error (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/134)
- The hash phase gives three hashes to every file that the walk records. `head`
and `tail` are the SHA-256 of its first and last 64 KiB. `content` is the
SHA-256 of the whole file under 100 MiB, and of 1 MiB at each gigabyte from
100 MiB up. Gone are:
- the 10 MiB and 50 MiB thresholds;
- the size census, which counted the sizes of every walked file and of every
record outside the operands. A file was hashed only when the census showed
another file of its size;
- the records without hashes;
- the content phase.
A scan has four phases, and a rescan reads only new and changed files.
`info` no longer counts the records with `head` and `tail` hashes, nor those
with a content hash, since every record has all three (2026-10-25,
https://git.eeqj.de/sneak/sfdupes/issues/132)
- `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` were copied
again from `sneak/prompts` `next` at `0653579`. Two steps now come before
`ARG CACHEBUST`:
- the module download in the `lint`, `test` and `builder` stages of the
`Dockerfile`;
- the package install in `builder`.
Docker keeps them until one of these changes:
- the base image;
- the install line;
- `go.mod` or `go.sum`.
Every step that checks or compiles the code still comes after
`ARG CACHEBUST`. `golangci-lint run` also keeps the linter's own cache.
README says the same (2026-10-18,
https://git.eeqj.de/sneak/sfdupes/issues/109)
- The release rule was set. Until 1.0, each tip of `main` that passes the build
gets the next `v0.0.N` tag. `go install sneak.berlin/go/sfdupes@latest` then
installs it. `go.mod` retracts `v0.0.1`, the early build without a database.
When the linker set no version, `sfdupes --version` prints the version Go
recorded in the binary. That is the tag, or the pseudo-version of a
`go install` build. It prints `dev` only when neither is known. README
"Install" documented:
- the rule;
- what `@main` and `v0.0.1` give;
- how to check an installed binary.
(2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/116)
- `scan` creates the database's directory where the database path leads. The
directory is the path with its last element cut off as text, so a `..` in it
stays. A `..` after a symlinked directory therefore goes through the symlink,
as it does when the file is opened. With
`SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a symlink to
`/data/x`, it now creates `/data/new` and the database in it. It used to
create `/srv/new` and then fail (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/119)
- `scan` never records its own database. Once the database and its `.lock` file
are open, it notes the device and inode of each of these:
- the database file;
- its `-wal` and `-shm` files;
- the `.lock` file.
The walk passes over any file with the device and inode of one of them,
however it reaches the file. It prints no line for such a file and counts
nothing. A record an earlier scan left for one of them is deleted
(2026-10-17, https://git.eeqj.de/sneak/sfdupes/issues/115)
- `scan` never records or reads anything on a filesystem that is not persistent,
such as `tmpfs` or `proc`. Such a filesystem is one the kernel makes up or
keeps in memory. `scan` matches them by device ID, however the walk or the
content phase reaches a path. As the scan starts, it lists the mounted
filesystems of those types, from `/proc/self/mountinfo` on Linux and from the
mount list on macOS. No flag turns that off. Below an operand, the walk does
not enter the default excludes either. So `sfdupes scan /` skips the kernel's
view of the running system and the temporary directories. On Linux those are:
```
/proc, /sys, /dev, /run, /tmp, /var/tmp
```
On macOS they are:
```
/dev, /private/tmp, /private/var/tmp, /private/var/vm
/System/Volumes/VM, /System/Volumes/Data
```
A Windows list exists in the code but is not used. An operand at or inside a
default exclude is still walked. The scan prints a line for each path it
leaves out, without counting it as skipped. The records there count as
outside the scanned operands, as for `-x`. `--no-default-excludes` turns off
the default excludes only, and `scan --help` lists them (2026-10-17,
https://git.eeqj.de/sneak/sfdupes/issues/124)
- A progress display's rate covers the items completed over the last 5 seconds.
When stderr is not a terminal, it covers the items since the line before. It
falls to 0 while nothing completes. A burst after a wait no longer reads as a
steady rate of over 1,000 files a second. The time remaining still comes from
the phase's average. Once no item has completed for 10 seconds, the line shows
`stalled` and where the phase waits in place of the rate, as
`stalled disk/db`. The places are:
- `disk`;
- `db`;
- `out`, for stdout;
- `cpu`.
A phase that may wait in two places names both. Each phase's word is set in
the code that defines the phase. A line too wide for the terminal loses its
bar first, then its rate or those words. Four parts stay whole:
- the phase;
- the progress;
- the elapsed time;
- the ETA, the time remaining.
The content phase had one display for its checks and one for its reads. Both
lost those words on an 80-column terminal, at counts like those of README's
example. That happened for most elapsed times and ETAs in minutes. Every
other phase kept them whenever the elapsed time and the ETA were both in
minutes. The content phase writes its last partial batch, the records not
yet written, under a display of its own (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/125)
- New subcommand `info` prints the database's details on stdout, one per line as
a name, a tab and the value:
- the path and where it came from;
- the sizes of the database file and of its `-wal` file, when there is one;
- when the database file was last changed;
- the schema version;
- the number of records;
- how many records have `head` and `tail` hashes;
- how many records have a content hash;
- the sizes of all records added up.
It opens the database as `report` does and shows two phases on stderr,
`[1/2] open` and `[2/2] count`. The schema version and the counts come from
one read-only transaction, a read of the database at one moment. The counts
come from one query (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/117)
- `report` shows its work on stderr in two phases, `[1/2] open` and
`[2/2] read`, with the progress display `scan` and `trees` use. The display
runs from the moment `report` starts until the summary line. `open` says it is
opening the database, also while it waits on a busy one. `read` counts the
records, then reads them against that count in signature order. SQLite takes
that order from the `files_signature` index without sorting, so the first
record comes at once. `report` forms the duplicate groups itself and holds
only those of the size it is reading. It writes them ordered by first path
before it reads the next size. stdout and the summary line are unchanged, and
SQLite no longer needs a temporary file to sort in (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/114)
- `trees` shows its work on stderr in four phases, from `[1/4] open` to
`[4/4] write`, each with the progress display `scan` uses. The display runs
from the moment `trees` starts until the summary line. `open` says it is
opening the database, also while it waits on a busy one. `read` counts the
records, then reads them against that count. `group` counts the directories it
groups, and `write` the groups it writes. A display's rate names what its
phase counts:
- files;
- records;
- directories;
- groups.
stdout and the summary line are unchanged. When stdout and stderr are the
same file, as one terminal is, no line of stdout shares a line with the
display. When they are not, the display keeps being drawn while stdout waits
on a slow reader (2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/113)
- Each `scan` phase's progress display names the phase and its place among the
five, as `[2/5] walk`. It runs from the moment that the phase starts. It
shows:
- the progress;
- the elapsed time;
- the ETA, the time remaining.
`load` counts the records first. The walk's total is estimated from the
records under the operands. The content phase takes its total from SQLite's
grouping alone. Until the first record is returned, it says it is searching
the database. On a terminal a phase with an exact total draws its line
followed by the bar of the progress-bar library. Any other phase draws a
spinner followed by the line. Every display is drawn again several times a
second. No line reaches the terminal's last column, however narrow the
terminal is, even after it is made narrower during a phase. A line too wide
leaves out its rate, then its bar. The rest stays whole. The line is cut
short only when the rest alone does not fit, as below about 70 columns late
in a large scan. When stderr is not a terminal, the line is printed every 5
seconds whether or not an item completed (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/112)
- `scan`, `report` and `trees` first print a line on stderr naming the database
they use, as an absolute path. The line says whether that path is the default
or comes from `SFDUPES_DATABASE`. `scan` says when the database is new. README
"Database" says when `scan` writes each file (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/111)
- The canonical files and model scripts were copied again from `sneak/prompts`
`next` at `e3f79c4`:
- `golangci-lint` v2.14.0;
- lint and test are phases of the `Dockerfile` that write no image, and
`make test` runs the suite under the race detector. Gone are
`Dockerfile.lint`, `script/verify-lint-image-pin` and `make test-race`;
- every `docker build` in `script/` passes a new `CACHEBUST` value. Every
stage of the `Dockerfile` declares it first, so every `RUN` step runs on
every build. The steps that compile keep Go's build cache, and the tests
run with `-count=1`;
- `prettier` runs on the host, from the `node` and `yarn` that
`script/bootstrap` installs. The `prettier` and `markdown` stages are
gone, and the build stage installs `git` and `make` itself;
- a new push cancels the workflow's older run on the same branch, and a run
stops after 20 minutes;
- a coding agent's settings file is deleted.
Since https://git.eeqj.de/sneak/sfdupes/issues/109 two steps come before
`ARG CACHEBUST`:
- the module download in `lint`, `test` and the build stage;
- the install of `git` and `make` in the build stage.
Docker keeps them between builds. Every step that checks or compiles the
code still runs on every build (2026-10-15,
https://git.eeqj.de/sneak/sfdupes/issues/95)
- With `-x`, an operand under another operand is no longer dropped when a
directory between the two, the inner one included, is on a different
filesystem than the outer one. The inner operand is then walked on its own.
For a regular-file operand the check starts at its directory. The records of
files gone from below it are deleted (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/106)
- With `-x`, `scan` keeps the records below a directory on another filesystem
under its operand, instead of deleting them. It counts their sizes in the size
census (2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/99)
- The hash and content phases open each file without blocking and check that it
is still a regular file. A file that is now a FIFO or device node is warned
about and skipped instead of hanging the scan (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/100)
- The scan lock file is named from the database's path with symlinks resolved,
after `scan` creates the database file if it is missing. A scan through a
symlink to the database therefore fails while another scan holds the lock
(2026-10-14, https://git.eeqj.de/sneak/sfdupes/issues/101)
- On a terminal, a warning for the last file of a phase no longer erases the
phase's finished bar. The bar is drawn again below the warning (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/102)
- `scan` records the mtime to the nanosecond, as whole seconds in `mtime` plus
`mtime_nsec`, and compares it at that resolution. A same-size rewrite within
the same second is hashed again (2026-10-07,
https://git.eeqj.de/sneak/sfdupes/issues/12)
- The narration was cut from `TODO.md` Completed Steps and from the comments in
`script/`, `Dockerfile` and `Dockerfile.lint` (gone since
https://git.eeqj.de/sneak/sfdupes/issues/95). The Workflow section now
branches from and merges to `next` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/49)
- `make test-race` ran the test suite under the race detector in a cgo-enabled
container, outside `make check`. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 `make test` itself runs the suite
under the race detector, in the `Dockerfile`'s Debian-based `test` phase.
`make test-race` is gone (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/18)
- A bare `docker build .` failed, naming `script/cibuild` and `script/docker`,
rather than serve the gates, the lint and test steps, from cache. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 a bare build succeeds, as
`REPO_POLICIES.md` requires, and may serve the gates from cache. The builds in
`script/` pass a new `CACHEBUST` value, so theirs always run (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/39)
- `make fmt` and `make fmt-check` run `prettier` over all Markdown, and the
Gitea workflow checks it. All Markdown was reformatted (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/19)
- `script/lint` writes no image, so a run no longer leaves an untagged one
behind (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/48)
- Tests cover:
- a missing database;
- `scan` keeping stdout empty;
- its skip warning;
- the `report` and `trees` summary lines;
- every subcommand going through `runE`.
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/16)
- `.golangci.yml` was replaced with the current canonical copy, which uses
`gomodguard_v2`, so lint no longer prints a deprecation warning (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/26)
- A test fails when either `hashWorker` cancellation check in `scan.go` is
removed (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/83)
- A database path holding `?`, `#` or `%` opens exactly the file it names
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/55)
- `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)
- A test fails when either walk cancellation check in `scan.go` is removed
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/81)
- A test checks that `scan` refuses a database with another schema version
(2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/64)
- Four inaccurate comments in `cancel_test.go` were corrected, and
`walkCancelInFlightDirs` was renamed to `walkCancelInFlightFiles` (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/33)
- The `-x` filesystem-boundary rules in `subdirJob` are tested (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/17)
- `scan` creates the schema in one transaction. A version-0 database with a
`files` table is refused with a clear schema-version error (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/11)
- README documents install, Docker, a daily `cron` scan and how to read and
check the reports (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/54)
- The `Dockerfile` build stage kept the Go module cache out of `builder`'s home
and copied the sources with `--chown`, so no `chown -R` walked them. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 there is no `builder` user and
nothing changes owner. The build stage only compiles, as root, and the tests
run as `nobody` in the `test` phase (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/43)
- `--version` prints `sfdupes VERSION` to stdout. README documents it and
`--help` (2026-10-04, https://git.eeqj.de/sneak/sfdupes/issues/15)
- `scan` stops cleanly on `SIGINT` or `SIGTERM`: it commits what it has hashed,
deletes nothing more, and exits 1 (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/5)
- `report` and `trees` stream the records instead of holding them all in memory.
The schema gains the `files_signature` index (2026-10-04,
https://git.eeqj.de/sneak/sfdupes/issues/14)
- Progress prints at once on a non-terminal and uses a real terminal test.
Warnings print through a spinner instead of racing its redraw (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/13)
- These operands are warned about and skipped, and the records beneath them are
kept:
- symlinks;
- sockets;
- FIFOs;
- device nodes;
- `.zfs` directories.
(2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/9)
- `scan` holds a lock on a lock file beside the database for its whole run, so a
second `scan` fails at once with exit 1 (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/53)
- Tests cover stdout write failures in `report` and `trees`. README states that
`| head` ends `sfdupes` by `SIGPIPE` and that `>&-` writes to `/dev/null`
(2026-10-03, https://git.eeqj.de/sneak/sfdupes/issues/30)
- `report` and `trees` open the database read-only, and `scan` leaves it out of
WAL mode, so reading needs only read access (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/8)
- Four characters are escaped in `report`, `trees` and warning paths:
- tab;
- newline;
- carriage return;
- backslash.
The root directory's path is `/` (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/7)
- A plain `docker build .` stamps the `git` tag or short commit instead of
`dev`. `.dockerignore` sends `.git` without `.git/config`. The build stage
stamps the `VERSION` build argument, else `git describe --tags --always`. It
fails if the context carries `.git` and the version is still empty, `dev` or
`unknown`. The Gitea workflow checks out the full history (`fetch-depth: 0`),
so it stamps the same value as `make build` (2026-10-02, branch `next`,
https://git.eeqj.de/sneak/sfdupes/issues/67)
- The 1 KiB end-window sampling was replaced with the hashes chosen by file
size, and README "Duplicate detection" documented every size threshold. A file
under 10 MiB was hashed in full, and its `head`, `tail` and `content` all held
that hash. A larger file got only its 64 KiB `head` and `tail` in the hash
phase. The content phase ran after the update phase. It read a file for
`content` only when its size, `head` and `tail` matched another record's, from
this scan or an earlier one. `content` was the whole file below 50 MiB, and
gigabyte-spaced 1 MiB samples at or above. It never read a file gone or
changed since its record was written. `report` and `trees` left out any record
without a `content` hash. The `content` column is part of the version 1
schema. Superseded by https://git.eeqj.de/sneak/sfdupes/issues/132
(2026-09-22, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/61)
- The dead `files.dat` references were removed from `Makefile`, `.gitignore` and
`.dockerignore` (2026-09-21, branch `next`,
https://git.eeqj.de/sneak/sfdupes/issues/22)
- The lint-image pin comments and `FROM` form in `Dockerfile` and
`Dockerfile.lint` were fixed. Both pins became the policy
`# image:vX.Y.Z, YYYY-MM-DD` comment over a bare `FROM image@sha256:...`,
without the false `(Debian-based)` note or the tag. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 the `Dockerfile`'s `lint` phase
holds the only `golangci-lint` pin, in that form. `Dockerfile.lint` is gone,
and so is `script/verify-lint-image-pin`, which compared the two pins
(2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/25)
- All linting ran in Docker, via `Dockerfile.lint` and `script/lint`. Per the
owner ruling the linter is never installed on a host, and
`golangci-lint config verify` runs before `golangci-lint run`.
`script/bootstrap` stopped installing or pinning the linter, and
`script/verify-linter-pin` was retired. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 both commands run in the
`Dockerfile`'s `lint` phase. `script/lint` builds that phase alone, and the
build stage depends on it through `COPY --from=lint /src/go.sum /dev/null`.
`Dockerfile.lint` and `script/verify-lint-image-pin` are gone. Nothing inside
an image build may run `docker`, so the phase calls `golangci-lint` directly
(2026-08-10, branch `next`, https://git.eeqj.de/sneak/sfdupes/issues/46)
- The Docker build stage's prerequisites were installed by running
`script/bootstrap` instead of `apk add --no-cache make` inline.
`script/verify-linter-pin` failed the build unless the linter copied from the
lint stage was the version that `script/bootstrap` pinned. Builds then took up
to 5m14s from an empty cache, mostly in a `chown -R` of the module cache,
filed as https://git.eeqj.de/sneak/sfdupes/issues/43. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 the build stage installs `git` and
`make` with `apk add --no-cache` and only compiles. The `lint` and `test`
phases are the gates (2026-08-09, branch `dockerfile-bootstrap`,
https://git.eeqj.de/sneak/sfdupes/issues/42)
- The Docker layer cache is defeated for the gate steps, so `script/cibuild` and
`script/docker` cannot report a pass they did not earn. The `Dockerfile`
copies the tree before its gates. On an unchanged tree Docker served them from
cache, and the build exited 0 without running them. The fix was a
`CHECK_EPOCH` build argument. Since
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/`
passes a new `CACHEBUST` value instead. Every stage of the `Dockerfile`
declares it before every step that checks or compiles the code. Run as root,
the tests fail `TestScanHardlinkRunFailsTogether`, because root reads through
the `chmod(0)` the test relies on. So the `test` phase runs them as `nobody`
(2026-08-09, branch `cibuild-cache-bust`,
https://git.eeqj.de/sneak/sfdupes/issues/32)
- `script/bootstrap` checked the installed `golangci-lint` version instead of
only its presence. The version lived only in `GOLANGCI_LINT_VERSION`, with the
`go install` module reference derived from it. Any installed version that was
not the pin was reinstalled, whether:
- older;
- newer;
- absent;
- unparseable.
`go install` writes into `GOBIN`, or `GOPATH/bin`, while `make lint` ran the
first `golangci-lint` on `PATH`. So bootstrap re-read the effective version
after installing. On a mismatch it printed both paths and both versions and
exited non-zero. It did not reorder `PATH` or delete anyone's binary. The
`--version` call kept its stderr and was bounded by `timeout(1)` where that
exists. `git`, `make` and `go` kept presence-only checks. The version check
was verified by bootstrapping this host from v2.10.1 to v2.12.2, and again
to a no-op. Stub runs of the script under `dash` covered:
- a thirteen-input version-parse matrix;
- a shadowed install, which must exit non-zero;
- an install destination not on `PATH`;
- `GOBIN` set;
- a wedged binary, which must hit the timeout.
`make check` and `make lint` were clean at v2.12.2, so v2.10.1 had not been
hiding findings on `main`. Superseded by
https://git.eeqj.de/sneak/sfdupes/issues/46, since the linter is no longer
installed on a host (2026-08-09, branch `bootstrap-version-check`,
https://git.eeqj.de/sneak/sfdupes/issues/24)
- The hash worker pool unwinds on the error path. The pool is an owned,
context-aware `hashPool`. Every blocking send in the feeder and the workers
selects on `ctx.Done()`, and `jobs` is closed on every path out. `hashPhase`
defers `pool.stop()`, which cancels and then drains `results` until the last
goroutine has exited. Draining is what frees a worker parked on a send. `ctx`
is passed from `cmd.Context()` as the first parameter, through:
- `runScan`;
- `syncScan`;
- both worker pools;
- the whole database layer.
The walk pool gets the same treatment, plus a `ctx.Err()` guard after the
walk. A cancelled walk yields a partial size census, and every file that it
never reached looks vanished to the update phase. That phase's own `BeginTx`
also fails on the cancelled context before deleting anything. The guard is
still the barrier once an interrupted scan may commit what it has. Tests
drive `run(scan)` against a database whose insert trigger aborts. They
assert that the scan fails instead of hanging, and that the goroutine count
returns to its pre-scan baseline. Other tests cancel a scan part-way through
the walk, deterministically, by counting the scan's own checks of
`ctx.Done()`. They assert that it stops at the guard with every record
intact. It then holds a partial size census and a still-populated record
index. Direct tests cover the remaining cancellation branches of both pools.
They test:
- `sendEvent`;
- the walk workers;
- `dispatchDirs`;
- `feedHashJobs`;
- `hashWorker`;
- `hashPhase`.
(2026-08-09, branch `hash-pool-cleanup`,
https://git.eeqj.de/sneak/sfdupes/issues/6)
- The database is closed on every fatal exit path. `fatalf` and its `os.Exit(1)`
are gone. So the deferred `db.Close()` runs when a subcommand fails, and with
it the SQLite WAL checkpoint. These return errors instead:
- `runScan`;
- `runReport`;
- `runTrees`;
- `loadRecords`;
- `resolveRoots`.
The single exit point is `run` in `main.go`. It maps a `fatalError`,
anything a subcommand returned, to exit 1, and cobra's own argument and flag
errors to exit 2. So a runtime failure is never reported as a usage error,
nor printed with the usage text. `main_test.go` drives the CLI in-process
and asserts the exit codes from README "Error handling" and the
stdout/stderr split. It also checks that a fatal error after the database is
open leaves no `-wal` or `-shm` file behind, for `scan`, `report` or `trees`
(2026-08-09, branch `db-close-on-fatal`,
https://git.eeqj.de/sneak/sfdupes/issues/4)
- `golangci-lint` was updated to v2.12.2 with the canonical config, merged as
`38a01bd`. The pinned linter in the `Dockerfile` lint stage and
`script/bootstrap` went from v2.12.1 to v2.12.2, and `.golangci.yml` was
replaced with the canonical file. Four thresholds now live under
`linters.settings`, as the v2 schema requires, so they are applied:
- `lll`;
- `funlen`;
- `cyclop`;
- `dupl`.
No new lint findings surfaced (2026-08-09, branch `golangci-v2.12.2`,
https://git.eeqj.de/sneak/sfdupes/issues/3)
- `Makefile` targets were converted to scripts-to-rule-them-all `script/`
entrypoints, like the other managed repos. All 12 entrypoints exist:
- `bootstrap`, `setup` and `projectname`;
- `test`, `lint`, `fmt`, `fmt-check` and `check`;
- `docker`, `cibuild`, `precommit` and `install-precommit`.
Every `Makefile` target does nothing but call its script (2026-07-26, commit
`3abeacf`, https://git.eeqj.de/sneak/sfdupes/issues/1)
- The binary is the default `make` target. Plain `make` builds `sfdupes`, where
it used to run `check` plus `build`. `make build` remains as an alias
(2026-07-24, branch `make-default-target`)
- The phases became scan-wide, with concurrent operands and batched updates. All
operands seed the shared walk pool, and every pass runs once over the whole
scan. So the totals and ETAs cover the whole scan. The per-operand walk, hash
and update cycles and their stderr announcements are gone. The update pass
writes in batches, each in its own transaction. The filesystem is
authoritative and the database follows it, so one scan's changes need not land
all at once (2026-07-24, branch `scan-wide-phases`)
- The stat pass was split back out of the walk. Phases are strictly sequential
again, per operand, with parallelism only inside each phase:
- walk;
- stat;
- hash;
- update.
The walk enumerates paths with per-directory workers, and the stat pass
lstats them with per-file workers. That restores the exact total and ETA of
the stat bar (2026-07-24, branch `parallel-phases`)
- Each operand is announced on stderr before its passes. With per-operand walk,
hash and update cycles, a multi-operand run such as `scan /srv/*` showed pass
totals that looked like the whole run's (2026-07-24, branch
`scan-operand-progress`)
- The walk became parallel. The walk pass was a single goroutine and took hours
at about 20 million files on a busy pool. One ZFS server took 4 hours for 22
million files. It is now a walk by a pool of per-directory workers that
records size and mtime as it goes. That folds away the separate stat pass and
halves the metadata reads. Each `PATH` operand commits in its own transaction,
so an interrupted scan keeps completed operands (2026-07-24, branch
`parallel-walk`)
- `scan` gained a persistent database. It maintains an SQLite database that
survives between runs, with the absolute path as each record's key. The driver
is `modernc.org/sqlite`, pure Go, so cgo stays disabled. A rescan hashes only
new or changed files, by mtime and size. It deletes the records of files
vanished from under the scanned operands and leaves records outside them
untouched. So `scan` can run daily from `cron`. `report` and `trees` read the
database, with no positional arguments, instead of a scan stream. The database
is at `/var/lib/sfdupes/db.sqlite`, and `SFDUPES_DATABASE` overrides that. WAL
journaling plus a single-transaction update keep a report run during a scan
safe (2026-07-24, branch `persistent-database`)
- The `origin` remote, `git@git.eeqj.de:sneak/sfdupes.git`, was added. `v0.0.1`
was tagged, and `main` and the tags were pushed (2026-07-23)
- The `scan` command line was reworked. Required `PATH...` operands, parsed by
cobra, replace the `/srv` `-root` default. The new `-x`, `--one-file-system`
flag follows the GNU convention and stops at filesystem boundaries, which the
walk crosses by default (2026-07-23, branch `scan-required-paths`)
- The repo was brought into compliance with `REPO_POLICIES.md` (the version of
2026-07-06), the existing repo checklist and the Go styleguide. The audit of
2026-07-22 found these gaps, and the branch closed them:
- `.gitignore`, with the standard patterns for secrets and for OS, editor
and Go files. The compiled `sfdupes` binary and `files.dat` had been
sitting untracked in the tree;
- `.editorconfig`;
- `LICENSE` and the README License section. MIT was assumed from house
convention, for the owner to confirm;
- `REPO_POLICIES.md` in the repo root;
- the canonical `.golangci.yml`, with its 150 findings fixed, so that
`make lint` is clean;
- the `Makefile` targets the policy requires:
- `test` and `lint`;
- `fmt` and `fmt-check`;
- `docker` and `hooks`.
`check` depended on `build`, which wrote the binary. `make check` must
not modify files, so `check` no longer depends on `build`;
- a test suite, which the policy required with a 30-second timeout and the
conditional `-v` rerun. At 64% coverage, it covers:
- parsing and grouping;
- digests and suppression;
- hashing and the scan pipeline;
- a multistage `Dockerfile` and `.dockerignore`. The `Dockerfile` had:
- hash-pinned images;
- a lint stage that failed fast;
- a build stage that ran `make check`;
- `.gitea/workflows/check.yml`, running `docker build .` on push with the
checkout action pinned by commit SHA;
- the README sections the policy requires. The first is a Description whose
first line names the project, its purpose and category, its license and
its author. Then come Getting Started and Rationale, and at the end TODO,
License and Author;
- the stale README non-goal "no git repository setup and no CI" removed;
- the pre-commit hook, installed with `make hooks` once that target existed.
The code was already `gofmt`-clean, so no standalone formatting commit was
needed (2026-07-23, branch `repo-policy-compliance`)
- `git init` was run, with a README-only first commit. The code baseline was
committed on `main` (2026-07-22)
- The `scan`, `report` and `trees` subcommands were implemented (pre-git
history)
# Future Steps
- Possible later features, out of scope per README, are a byte-for-byte check of
@@ -30,4 +747,6 @@
# 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 (
"context"
@@ -358,9 +358,8 @@ func TestRunScanInterrupted(t *testing.T) {
stderr := captureStderr(t)
dir := buildSmokeTree(t)
opts := scanOptions{workers: walkCancelWorkers}
err := runScan(t.Context(), []string{dir}, opts)
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
false)
if err != nil {
t.Fatal(err)
}
@@ -383,7 +382,8 @@ func TestRunScanInterrupted(t *testing.T) {
go func() {
defer close(done)
err = runScan(cancelledContext(t), []string{dir}, opts)
err = runScan(cancelledContext(t), []string{dir}, walkCancelWorkers,
false, false)
}()
awaitReturn(t, done, "runScan")
@@ -410,7 +410,8 @@ func TestRunScanInterrupted(t *testing.T) {
got, before)
}
err = runScan(t.Context(), []string{dir}, opts)
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
false)
if err != nil {
t.Fatal(err)
}
@@ -436,7 +437,7 @@ func TestRunScanInterruptedMidHash(t *testing.T) {
dir := buildWalkCancelTree(t)
err := runScan(newWalkClock(hashCancelAtDone), []string{dir},
scanOptions{workers: walkCancelWorkers})
walkCancelWorkers, false, false)
if !errors.Is(err, errInterrupted) {
t.Fatalf("runScan interrupted mid-hash = %v, want %v",
err, errInterrupted)
-31
View File
@@ -1,31 +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 that the other subcommands read. The
// database is SFDUPES_DATABASE, or by default /var/lib/sfdupes/db.sqlite
// for root and sfdupes/db.sqlite in the XDG state directory for any
// other user.
//
// 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()) }
+32 -96
View File
@@ -1,4 +1,4 @@
package sfdupes
package main
import (
"context"
@@ -15,40 +15,28 @@ import (
"strings"
"time"
"github.com/adrg/xdg"
"golang.org/x/sys/unix"
// The pure-Go SQLite driver, registered as "sqlite"; keeps cgo
// disabled.
_ "modernc.org/sqlite"
)
// rootDatabasePath is the database root uses when SFDUPES_DATABASE is
// not set.
const rootDatabasePath = "/var/lib/sfdupes/db.sqlite"
// defaultDatabasePath is where the persistent scan database lives when
// SFDUPES_DATABASE is not set.
const defaultDatabasePath = "/var/lib/sfdupes/db.sqlite"
// databaseEnv is the environment variable that overrides the database
// path.
const databaseEnv = "SFDUPES_DATABASE"
// stateHomeEnv is the environment variable that names the XDG state
// directory.
const stateHomeEnv = "XDG_STATE_HOME"
// schemaVersion is the database schema version this build reads and
// writes, stored in PRAGMA user_version.
const schemaVersion = 1
// dbDirPerm is the mode for a directory scan creates for root's
// default database or for SFDUPES_DATABASE.
// dbDirPerm is the mode for a database parent directory created by
// scan.
const dbDirPerm = 0o755
// stateDirPerm is the mode for a directory scan creates for the
// database at a user's default path in the XDG state directory. It is
// the mode the XDG Base Directory specification asks for, and it keeps
// other users from reading the database, which lists every scanned
// path.
const stateDirPerm = 0o700
// dbFilePerm is the mode for a database file created by scan, the mode
// SQLite gives a database file it creates.
const dbFilePerm = 0o644
@@ -156,100 +144,48 @@ var errScanRunning = errors.New("another scan is running")
// 32 bytes of a SHA-256.
var errHashSize = errors.New("hash is not 32 bytes")
// errStateHomeNotAbsolute reports an XDG_STATE_HOME that is set to a
// path that is not absolute.
var errStateHomeNotAbsolute = errors.New(
stateHomeEnv + " must be an absolute path")
// defaultDatabasePath is the database used when SFDUPES_DATABASE is not
// set: rootDatabasePath for root (euid 0), and db.sqlite in the
// directory sfdupes under the XDG state directory for any other user.
// xdg reads XDG_STATE_HOME and HOME once, when the program starts. For
// any other user, an XDG_STATE_HOME that is set and not an absolute
// path is an error, where xdg would use its default instead. The path
// is joined as text, not cleaned, for the reason absolutePath gives.
func defaultDatabasePath(euid int) (string, error) {
if euid == 0 {
return rootDatabasePath, nil
}
stateHome := os.Getenv(stateHomeEnv)
if stateHome != "" && !filepath.IsAbs(stateHome) {
return "", fmt.Errorf("%w, got %q", errStateHomeNotAbsolute,
stateHome)
}
stateDir := strings.TrimSuffix(xdg.StateHome, "/")
return stateDir + "/sfdupes/db.sqlite", nil
}
// databasePath resolves the database location: SFDUPES_DATABASE when
// set and non-empty, defaultDatabasePath otherwise. It returns the
// path, made absolute by absolutePath, and where the path came from,
// "default" or "from SFDUPES_DATABASE", for the line each subcommand
// prints first on stderr.
// set and non-empty, the compiled-in default otherwise. It returns the
// path, with the working directory, its symlinks resolved, put in front
// of it when it is relative, and where the path came from, "default" or
// "from SFDUPES_DATABASE", for the line each subcommand prints first on
// stderr.
func databasePath() (string, string, error) {
path, source := os.Getenv(databaseEnv), "from "+databaseEnv
if path == "" {
def, err := defaultDatabasePath(os.Geteuid())
if err != nil {
return "", "", err
}
path, source = def, "default"
path, source := defaultDatabasePath, "default"
if p := os.Getenv(databaseEnv); p != "" {
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 {
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
}
return abs, source, nil
}
// 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
}
// 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.
// 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
// goes in, and any directory above it, if missing. path is absolute, as
// databasePath returns it. Each directory gets mode dbDirPerm, or
// stateDirPerm when databasePath uses a user's default path in the XDG
// state directory: the user is not root and SFDUPES_DATABASE is unset
// or empty. The directory is path with its last element cut off as
// text, by filepath.Split. filepath.Dir would clean it, dropping each
// ".." together with the directory name before it; when that name is a
// symlink, the cleaned path names another directory than the one the
// system reaches when it opens path.
// databasePath returns it. The directory is path with its last element
// cut off as text, by filepath.Split. filepath.Dir would clean it,
// dropping each ".." together with the directory name before it; when
// that name is a symlink, the cleaned path names another directory than
// the one the system reaches when it opens path.
func createDatabaseDirectory(path string) error {
dir, _ := filepath.Split(path)
perm := os.FileMode(dbDirPerm)
if os.Geteuid() != 0 && os.Getenv(databaseEnv) == "" {
perm = stateDirPerm
}
err := os.MkdirAll(dir, perm)
err := os.MkdirAll(dir, dbDirPerm)
if err != nil {
return fmt.Errorf("create database directory: %w", err)
}
+2 -52
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // calls the unexported database openers
package main
import (
"context"
@@ -9,13 +9,10 @@ import (
"io/fs"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
"github.com/adrg/xdg"
)
// testDBPath returns a database path inside a fresh temp dir, with
@@ -59,16 +56,11 @@ func TestDatabasePath(t *testing.T) {
t.Chdir(dir)
defaultPath, err := defaultDatabasePath(os.Geteuid())
if err != nil {
t.Fatal(err)
}
fromEnv := "from " + databaseEnv
cases := []struct {
env, path, source string
}{
{"", defaultPath, "default"},
{"", defaultDatabasePath, "default"},
{"/custom/place.sqlite", "/custom/place.sqlite", fromEnv},
{"/custom/link/../place.sqlite", "/custom/link/../place.sqlite",
fromEnv},
@@ -97,48 +89,6 @@ func TestDatabasePath(t *testing.T) {
}
}
func TestDefaultDatabasePath(t *testing.T) {
// README §Location: root's database is /var/lib/sfdupes/db.sqlite.
// Any other user's is db.sqlite in sfdupes under XDG_STATE_HOME, or
// under ~/.local/state when XDG_STATE_HOME is unset or empty. For
// that user, an XDG_STATE_HOME that is not an absolute path is an
// error.
if runtime.GOOS != goosLinux {
t.Skip("macOS has another default state directory")
}
t.Cleanup(xdg.Reload)
t.Setenv("HOME", "/home/someone")
cases := []struct {
euid int
stateHome string
want string
wantErr error
}{
{0, "", rootDatabasePath, nil},
{0, "/elsewhere/state", rootDatabasePath, nil},
{0, "relative/state", rootDatabasePath, nil},
{1000, "", "/home/someone/.local/state/sfdupes/db.sqlite", nil},
{1000, "relative/state", "", errStateHomeNotAbsolute},
{1000, "~/state", "", errStateHomeNotAbsolute},
{1000, "/elsewhere/state", "/elsewhere/state/sfdupes/db.sqlite", nil},
{1000, "/elsewhere/state/", "/elsewhere/state/sfdupes/db.sqlite", nil},
}
for _, tc := range cases {
t.Setenv(stateHomeEnv, tc.stateHome)
xdg.Reload()
got, err := defaultDatabasePath(tc.euid)
if got != tc.want || !errors.Is(err, tc.wantErr) {
t.Errorf("defaultDatabasePath(%d) with %s=%q = %q, %v; want %q, %v",
tc.euid, stateHomeEnv, tc.stateHome, got, err, tc.want,
tc.wantErr)
}
}
}
func TestOpenScanDatabaseCreates(t *testing.T) {
t.Parallel()
+13 -105
View File
@@ -1,9 +1,8 @@
package sfdupes
package main
// This file holds three rules for the walk (README "Rules for the
// walk"): the paths given with --exclude, which the scan never enters;
// 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
// operand unless scan is given --no-default-excludes; and the
// operand unless scan is given --no-default-excludes, and the
// filesystems that are not persistent, on which the scan never records
// or reads anything.
@@ -13,7 +12,6 @@ import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
@@ -45,25 +43,17 @@ const typesPerHelpLine = 6
// readMountInfo reads.
var errMountInfoLine = errors.New("unexpected line")
// errExcludedOperand reports an operand at or below a path given with
// --exclude.
var errExcludedOperand = errors.New("operand excluded by --exclude")
// walkRules holds the rules for the walk that a scan sets up as it
// starts. excludePaths are the paths given with --exclude, each with
// the device ID and inode number of the file it names (see
// excludePathInodes), which the walk neither enters nor records,
// whatever path leads to them. defaultExcludes are the default
// excludes, or none with --no-default-excludes. nonPersistent maps the
// device ID of every mounted filesystem that is not persistent to its
// type; the scan records and reads nothing on one of those, whatever
// the flags. databaseFiles are the scan's own database files, which the
// walk passes over (see databaseFiles in db.go).
// starts. excludes are the default excludes, or none with
// --no-default-excludes. nonPersistent maps the device ID of every
// mounted filesystem that is not persistent to its type; the scan
// records and reads nothing on one of those, whatever the flags.
// databaseFiles are the scan's own database files, which the walk
// passes over (see databaseFiles in db.go).
type walkRules struct {
excludePaths []fileRec
defaultExcludes []string
nonPersistent map[uint64]string
databaseFiles []fileRec
excludes []string
nonPersistent map[uint64]string
databaseFiles []fileRec
}
// defaultExcludes returns the default excludes of the system goos
@@ -107,88 +97,6 @@ func isDefaultExclude(path string, excludes []string) bool {
return false
}
// excludePathInodes returns each of paths, the --exclude values, as its
// real path (see realPaths) with the device ID and inode number of the
// file there. The walk compares what it reaches with those, as it does
// with the database files, so it also leaves out an --exclude path
// spelled another way, such as in another letter case on macOS.
func excludePathInodes(paths []string) ([]fileRec, error) {
resolved, err := realPaths(paths)
if err != nil {
return nil, err
}
files := make([]fileRec, 0, len(resolved))
for _, path := range resolved {
fi, err := os.Lstat(path)
if err != nil {
return nil, err
}
dev, ino := inodeOfInfo(fi)
files = append(files, fileRec{path: path, dev: dev, ino: ino})
}
return files, nil
}
// matchingExcludePath returns the path of the one of excludePaths with
// the device ID and inode number of fi, an lstat result, or "" when
// there is none.
func matchingExcludePath(fi fs.FileInfo, excludePaths []fileRec) string {
dev, ino := inodeOfInfo(fi)
f := fileRec{dev: dev, ino: ino}
for _, exclude := range excludePaths {
if sameInode(exclude, f) {
return exclude.path
}
}
return ""
}
// checkOperandsNotExcluded returns an error when one of roots, the
// operands, or a directory above it is one of excludePaths, the
// --exclude paths, by device ID and inode number. The error names the
// operand and the --exclude path. The roots are absolute, so the
// directories above each one end at /. A path here that lstat cannot
// read is an error too.
func checkOperandsNotExcluded(roots []string, excludePaths []fileRec) error {
for _, root := range roots {
for path := root; ; path = filepath.Dir(path) {
fi, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("scan %s: %w", root, err)
}
exclude := matchingExcludePath(fi, excludePaths)
if exclude != "" {
return fmt.Errorf("scan %s: %w %s", root, errExcludedOperand,
exclude)
}
if path == "/" {
break
}
}
}
return nil
}
// excludePathLine returns the line the scan prints for path when fi,
// its lstat, makes it one of rules.excludePaths, and "" when it does
// not.
func (rules walkRules) excludePathLine(path string, fi fs.FileInfo) string {
if matchingExcludePath(fi, rules.excludePaths) == "" {
return ""
}
return fmt.Sprintf("scan: skipped %s (--exclude)", path)
}
// nonPersistentFilesystemTypes returns the filesystem types that are
// not persistent: filesystems that the kernel makes up or that are kept
// in memory. devfs is the macOS filesystem of /dev; the others are
@@ -254,7 +162,7 @@ func readMountInfo(mountinfo io.Reader) (map[uint64]string, error) {
return devices, lines.Err()
}
// defaultExcludesHelp describes, for the scan and dwim help, the
// defaultExcludesHelp describes, for the scan command's help, the
// default excludes of the system goos names and, where the scan lists
// the mounted filesystems, the types it never records anything on.
func defaultExcludesHelp(goos string) string {
@@ -1,6 +1,6 @@
//go:build darwin
package sfdupes
package main
import (
"slices"
@@ -1,6 +1,6 @@
//go:build !darwin
package sfdupes
package main
import (
"fmt"
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // checks the unexported defaultExcludes
package main
import (
"bytes"
@@ -14,9 +14,6 @@ import (
"golang.org/x/sys/unix"
)
// excludeFlag is the scan flag that names a path the walk never enters.
const excludeFlag = "--exclude"
func TestDefaultExcludes(t *testing.T) {
t.Parallel()
@@ -191,7 +188,7 @@ func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
unseen := writeFile(t, excluded, "new", pattern(2, 10))
st, err := syncScan(t.Context(), db, []string{dir}, 1, false,
walkRules{defaultExcludes: []string{excluded}})
walkRules{excludes: []string{excluded}})
if err != nil {
t.Fatal(err)
}
@@ -237,7 +234,7 @@ func TestScanWalksOperandInDefaultExclude(t *testing.T) {
db := openTestDB(t)
st, err := syncScan(t.Context(), db, roots, 1, false,
walkRules{defaultExcludes: []string{excluded}})
walkRules{excludes: []string{excluded}})
if err != nil {
t.Fatal(err)
}
@@ -265,7 +262,7 @@ func TestScanNamesNoDefaultExcludeThatIsAnOperand(t *testing.T) {
f := writeFile(t, excluded, "f", pattern(1, 10))
st, err := syncScan(t.Context(), db, []string{dir, excluded}, 1, false,
walkRules{defaultExcludes: []string{excluded}})
walkRules{excludes: []string{excluded}})
if err != nil {
t.Fatal(err)
}
@@ -289,7 +286,7 @@ func TestRunScanTempDirOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
dir := t.TempDir()
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
t.Skip(dir + " is not inside a default exclude")
}
@@ -359,217 +356,6 @@ func TestRunScanNoDefaultExcludes(t *testing.T) {
assertRecorded(t, path, f, true)
}
// TestRunScanExcludeNested scans a tree through the command with an
// --exclude path two levels below the operand. The walk records nothing
// at or below it and says so. The record an earlier scan left there is
// kept although its file is gone.
func TestRunScanExcludeNested(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
excluded := filepath.Join(dir, "a", "b")
kept := writeFile(t, dir, "a/f", pattern(1, 10))
gone := writeFile(t, excluded, "gone", pattern(2, 10))
scanOK(t, dir)
err := os.Remove(gone)
if err != nil {
t.Fatal(err)
}
unseen := writeFile(t, excluded, "c/new", pattern(3, 10))
stderr := scanOK(t, excludeFlag, excluded, dir)
line := "scan: skipped " + excluded + " (--exclude)\n"
if !strings.Contains(stderr, line) {
t.Errorf("stderr = %q, want %q", stderr, line)
}
assertRecorded(t, path, kept, true)
assertRecorded(t, path, gone, true)
assertRecorded(t, path, unseen, false)
}
// TestRunScanExcludeRepeated gives --exclude twice, once for a
// directory and once for a file, the file through a symlink to it. The
// walk records neither, and records the file beside them.
func TestRunScanExcludeRepeated(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
inDir := writeFile(t, dir, "skip/f", pattern(1, 10))
file := writeFile(t, dir, "skip-file", pattern(2, 10))
kept := writeFile(t, dir, "keep", pattern(3, 10))
link := filepath.Join(t.TempDir(), "link")
symlink(t, file, link)
scanOK(t, excludeFlag, filepath.Dir(inDir), excludeFlag, link, dir)
assertRecorded(t, path, inDir, false)
assertRecorded(t, path, file, false)
assertRecorded(t, path, kept, true)
}
// TestRunScanExcludeReachedByAnotherPath gives --exclude a directory
// through a symlink and a file through "..", so the walk reaches
// neither by the path given. The walk also reaches the file through a
// hard link. It records none of these, prints a line naming each, and
// records the file beside them.
func TestRunScanExcludeReachedByAnotherPath(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
inDir := writeFile(t, dir, "a/cache/f", pattern(1, 10))
file := writeFile(t, dir, "a/big", pattern(2, 10))
kept := writeFile(t, dir, "keep", pattern(3, 10))
hardLink := filepath.Join(dir, "big-link")
err := os.Link(file, hardLink)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(t.TempDir(), "link")
symlink(t, filepath.Join(dir, "a"), link)
// Joined as text, since filepath.Join would drop the "..".
dotDot := dir + "/a/cache/../big"
stderr := scanOK(t, excludeFlag, filepath.Join(link, "cache"),
excludeFlag, dotDot, dir)
for _, skipped := range []string{filepath.Dir(inDir), file, hardLink} {
line := "scan: skipped " + skipped + " (--exclude)\n"
if !strings.Contains(stderr, line) {
t.Errorf("stderr = %q, want %q", stderr, line)
}
}
assertRecorded(t, path, inDir, false)
assertRecorded(t, path, file, false)
assertRecorded(t, path, hardLink, false)
assertRecorded(t, path, kept, true)
}
// TestRunScanExcludedOperand checks that an operand at or below an
// --exclude path is a fatal error that names both, and so is an
// --exclude path that does not exist. An operand that is a hard link to
// an --exclude file is at it. Neither scan writes a database.
func TestRunScanExcludedOperand(t *testing.T) {
dir := realTempDir(t)
sub := filepath.Join(dir, "sub")
f := writeFile(t, sub, "f", pattern(1, 10))
hardLink := filepath.Join(dir, "f-link")
err := os.Link(f, hardLink)
if err != nil {
t.Fatal(err)
}
cases := map[string]struct {
args []string
want string
}{
"at it": {
[]string{excludeFlag, sub, sub},
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
},
"below it": {
[]string{excludeFlag, dir, sub},
"scan " + sub + ": operand excluded by --exclude " + dir + "\n",
},
"after an operand it does not exclude": {
[]string{dir, excludeFlag, sub, sub},
"scan " + sub + ": operand excluded by --exclude " + sub + "\n",
},
"a hard link to it": {
[]string{excludeFlag, f, hardLink},
"scan " + hardLink + ": operand excluded by --exclude " + f + "\n",
},
"exclude missing": {
[]string{excludeFlag, filepath.Join(dir, "nope"), dir},
"--exclude: resolve " + filepath.Join(dir, "nope") + ": ",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
stdout := captureStdout(t)
stderr := captureStderr(t)
code := run(append([]string{cmdScan}, tc.args...), os.Stdout,
os.Stderr)
if code != exitFatal {
t.Errorf("run(scan %q) = %d, want %d", tc.args, code,
exitFatal)
}
assertFatalOutput(t, stderr(), stdout())
if !strings.Contains(stderr(), "sfdupes: "+tc.want) {
t.Errorf("stderr = %q, want %q", stderr(), tc.want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
// TestRunScanRejectsEmptyExclude checks that an empty --exclude value,
// given as its own argument, after "=" or after another --exclude, is
// a usage error reported in one line before anything is scanned
// (README "Settings").
func TestRunScanRejectsEmptyExclude(t *testing.T) {
dir := t.TempDir()
cases := map[string][]string{
"separate": {excludeFlag, "", dir},
"joined by =": {excludeFlag + "=", dir},
"after a path": {excludeFlag, t.TempDir(), excludeFlag, "", dir},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
var stdout, stderr bytes.Buffer
code := run(append([]string{cmdScan}, args...), &stdout,
&stderr)
if code != exitUsage {
t.Errorf("run(scan %q) = %d, want %d", args, code,
exitUsage)
}
want := "Error: --exclude must not be empty\n"
if stdout.String() != "" || stderr.String() != want {
t.Errorf("stdout, stderr = %q, %q, want \"\", %q",
stdout.String(), stderr.String(), want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
// TestRunScanSkipsNonPersistentOperand scans, through the command, a
// directory on a filesystem that is not persistent, with and without
// --no-default-excludes: either way it is skipped, with a line saying
+1 -2
View File
@@ -3,8 +3,6 @@ module sneak.berlin/go/sfdupes
go 1.25.7
require (
github.com/adrg/xdg v0.5.3
github.com/rivo/uniseg v0.4.7
github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.46.0
@@ -20,6 +18,7 @@ require (
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
-2
View File
@@ -1,5 +1,3 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+1 -1
View File
@@ -1,4 +1,4 @@
package sfdupes
package main
import (
"context"
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // seeds the database with unexported scanRec
package main
import (
"fmt"
-451
View File
@@ -1,451 +0,0 @@
// Package sfdupes implements the sfdupes command and the database its
// subcommands share.
package sfdupes
import (
"context"
"errors"
"fmt"
"io"
"os"
"runtime"
"runtime/debug"
"slices"
"github.com/spf13/cobra"
)
// Exit codes: exitOK is success (even with per-file warnings),
// exitFatal is a fatal error, exitUsage is a usage error.
const (
exitOK = 0
exitFatal = 1
exitUsage = 2
)
// The subcommand names, as typed on the command line.
const (
cmdScan = "scan"
cmdReport = "report"
cmdTrees = "trees"
cmdInfo = "info"
cmdDwim = "dwim"
)
// databaseHelp is the paragraph of the root and scan help that names
// the database (README "Location").
const databaseHelp = `scan and dwim store what they find in a database file. report,
trees and info read it. The file is $SFDUPES_DATABASE when that is set
and not empty. Otherwise root uses /var/lib/sfdupes/db.sqlite. Any other
user uses sfdupes/db.sqlite in the XDG state directory: $XDG_STATE_HOME,
or ~/.local/state when that is unset or empty
(~/Library/Application Support on macOS). An XDG_STATE_HOME that is
not an absolute path is an error.
`
// escapesHelp is the paragraph of the report and trees help on how
// paths are escaped (README "Report output format").
const escapesHelp = `Four characters in a path are escaped, so each row stays one line:
\\ a backslash
\t a tab
\n a newline
\r a carriage return
`
// operandsHelp is the paragraph of the report and trees help on their
// PATH operands (README "Operands of `report` and `trees`").
const operandsHelp = `With PATH operands, only the records at or below them are used. A
relative PATH is joined to the working directory. No PATH is looked up
on the filesystem, so a symlink in one is not followed.
`
// reportHelp, treesHelp, infoHelp and dwimHelp follow the one-line
// summary in the help of their subcommands.
const (
reportHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate file. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that file
size the file's size in bytes
Rows are ordered by size, largest first, then by path.
` + escapesHelp + "\n" + operandsHelp
treesHelp = `The report is tab-separated text on stdout: a header line, then one
row per duplicate directory tree. The columns are:
first the path that sorts first, byte by byte, in its group of copies
dupe another copy of that tree
files the number of regular files in one copy
size their total size in bytes
disk_size the bytes the dupe copy takes on disk
Rows are ordered by size, largest first, then by path.
` + escapesHelp + "\n" + operandsHelp
infoHelp = `The details go to stdout, one per line. Each line holds a name and its
value, separated by a tab:
database the path, and where it came from
database file size in bytes, then rounded, as 2147483648 (2.0 GiB)
-wal file size in bytes, then rounded; only if a -wal file exists
modified when the database file last changed, in UTC
schema version the version of the database's layout
records the number of records
size of recorded files their total size, in bytes, then rounded
`
dwimHelp = `dwim runs what "sfdupes scan /" runs with the same flags, then what
"sfdupes trees" runs. Run it as root, so that the scan can read every
file. The scan is stored in the database, as scan stores it, so
"sfdupes trees" prints the report again later.
`
)
// errNoSubcommand is returned by the root command when it is invoked
// without a subcommand. That is a usage error, and the usage text
// cobra prints for it is the whole message.
var errNoSubcommand = errors.New("no subcommand")
// errWorkersBelowOne is the usage error for a --workers value below 1.
var errWorkersBelowOne = errors.New("--workers must be at least 1")
// errEmptyExcludeValue is the usage error for an empty --exclude value,
// as from --exclude "$DIR" with DIR unset. Taken as a path, it
// would name the working directory.
var errEmptyExcludeValue = errors.New("--exclude must not be empty")
// errEmptyOperand is the usage error for an empty PATH operand of
// report or trees, as from "$DIR" with DIR unset. Taken as a path, it
// would name the working directory.
var errEmptyOperand = errors.New("PATH must not be empty")
// Version is the build version, injected at link time via -ldflags
// (see the Makefile); empty when the linker set none, as for a binary
// built by go install. buildVersion turns it into what --version prints.
//
//nolint:gochecknoglobals // written only by the linker
var Version string
// buildVersion returns the version that sfdupes --version prints
// (README "Install"): the version the linker set, when it set one;
// otherwise the main module's version that Go recorded in the binary,
// a tag such as v0.0.2 or the pseudo-version of an @main build; and
// "dev" when neither is known. info is what debug.ReadBuildInfo
// returns, nil when the binary carries none. Go records "(devel)" for
// a build that has no version, such as one inside a copy of the source
// without git.
func buildVersion(linkerVersion string, info *debug.BuildInfo) string {
if linkerVersion != "" {
return linkerVersion
}
if info == nil || info.Main.Version == "" ||
info.Main.Version == "(devel)" {
return "dev"
}
return info.Main.Version
}
// Main runs the command line in os.Args and returns the process exit
// code.
func Main() int {
// 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
// write instead of returning an error (README "Error handling").
// Registering for SIGPIPE with os/signal would change that.
return run(os.Args[1:], os.Stdout, os.Stderr)
}
// run executes args against the command tree and returns the process
// exit code. It is the program's single exit point: the subcommands
// return their errors instead of exiting, so every deferred cleanup —
// above all closing the database, which checkpoints the SQLite WAL —
// runs before the process ends. The report, trees, info and dwim
// subcommands write their data to stdout.
func run(args []string, stdout, stderr io.Writer) int {
// A nil slice makes cobra fall back to os.Args, which would let a
// test binary's own flags reach the command tree.
if args == nil {
args = []string{}
}
root := newRootCommand(stdout, stderr)
root.SetArgs(args)
err := root.Execute()
var fatal fatalError
switch {
case err == nil:
return exitOK
case errors.Is(err, errInterrupted):
// The interrupted scan has printed its own line.
return exitFatal
case errors.As(err, &fatal):
// The command ran and failed: a runtime error, reported
// without the usage text that a usage error gets.
_, _ = fmt.Fprintf(stderr, "sfdupes: %v\n", err)
return exitFatal
default:
// A usage error, which cobra has already reported on stderr.
return exitUsage
}
}
// newRootCommand builds the command tree. Everything on stdout is
// machine-readable data, the version line included; all human-facing
// output (help, usage, errors) goes to stderr.
func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
var showVersion bool
printVersion := runE(func(context.Context, []string) error {
info, _ := debug.ReadBuildInfo()
_, err := fmt.Fprintf(stdout, "sfdupes %s\n",
buildVersion(Version, info))
if err != nil {
return fmt.Errorf("write stdout: %w", err)
}
return nil
})
root := &cobra.Command{
Use: "sfdupes",
Short: "Find candidate duplicate files by size and head/tail/content SHA-256",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if showVersion {
return printVersion(cmd, args)
}
// A missing subcommand prints usage and exits 2: cobra
// prints the usage text for the returned error, and run
// maps everything that is not a fatal error to exit 2.
cmd.SilenceErrors = true
return errNoSubcommand
},
}
root.SetOut(stderr)
root.SetErr(stderr)
root.CompletionOptions.DisableDefaultCmd = true
// Cobra's built-in version flag prints through the help writer,
// stderr; this one prints to stdout.
root.Flags().BoolVarP(&showVersion, "version", "v", false,
"print the version to stdout")
reportCmd := &cobra.Command{
Use: cmdReport + " [PATH...]",
Short: "Read the scan database and print the file-level duplicates report",
Args: rejectEmptyOperand,
RunE: runE(func(ctx context.Context, args []string) error {
return runReport(ctx, args, stdout)
}),
}
treesCmd := &cobra.Command{
Use: cmdTrees + " [PATH...]",
Short: "Read the scan database and print the duplicate-tree report",
Args: rejectEmptyOperand,
RunE: runE(func(ctx context.Context, args []string) error {
return runTrees(ctx, args, stdout)
}),
}
infoCmd := &cobra.Command{
Use: cmdInfo,
Short: "Read the scan database and print its details",
Args: cobra.NoArgs,
RunE: runE(func(ctx context.Context, _ []string) error {
return runInfo(ctx, stdout)
}),
}
root.Long = root.Short + ".\n\n" + databaseHelp
reportCmd.Long = reportCmd.Short + ".\n\n" + reportHelp
treesCmd.Long = treesCmd.Short + ".\n\n" + treesHelp
infoCmd.Long = infoCmd.Short + ".\n\n" + infoHelp
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd,
newDwimCommand(stdout))
return root
}
// scanOptions holds the values of the flags that scan and dwim share:
// --workers, -x, --no-default-excludes and --exclude.
type scanOptions struct {
workers int
oneFS bool
noDefaultExcludes bool
excludePaths []string
}
// scanFlagsUsage names the flags that scan and dwim share, for their
// usage lines.
const scanFlagsUsage = "[--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]..."
// addScanFlags adds the flags that scan and dwim share to cmd, which
// stores their values in opts.
func addScanFlags(cmd *cobra.Command, opts *scanOptions) {
cmd.Flags().IntVar(&opts.workers, "workers", runtime.NumCPU(),
"concurrent workers for the walk and hash phases")
cmd.Flags().BoolVarP(&opts.oneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
cmd.Flags().BoolVar(&opts.noDefaultExcludes, "no-default-excludes",
false, "enter the default excludes, the paths listed above")
// A string array, not a slice, so a comma in a path is not a
// separator.
cmd.Flags().StringArrayVar(&opts.excludePaths, "exclude", nil,
"never enter or record `PATH` or anything below it; repeatable")
}
// check rejects the flag values that are usage errors, before the
// command runs. It is the PreRunE of scan and dwim.
func (o *scanOptions) check(cmd *cobra.Command, _ []string) error {
err := checkScanWorkers(cmd, o.workers)
if err != nil {
return err
}
return rejectEmptyExcludeValue(cmd, o.excludePaths)
}
// newScanCommand builds the scan subcommand. Its help names the
// database, and lists the default excludes of the system it runs on and
// the filesystem types it never records anything on.
func newScanCommand() *cobra.Command {
var opts scanOptions
short := "Walk trees and synchronize the scan database"
scanCmd := &cobra.Command{
Use: cmdScan + " " + scanFlagsUsage + " PATH...",
// The Use line already names every flag, so cobra must not
// append its generic "[flags]" to it.
DisableFlagsInUseLine: true,
Short: short,
Long: short + ".\n\n" + databaseHelp + "\n" +
defaultExcludesHelp(runtime.GOOS),
Args: cobra.MinimumNArgs(1),
PreRunE: opts.check,
RunE: runE(func(ctx context.Context, args []string) error {
ctx, stop := interruptContext(ctx)
defer stop()
return runScan(ctx, args, opts)
}),
}
addScanFlags(scanCmd, &opts)
return scanCmd
}
// newDwimCommand builds the dwim subcommand, which takes the scan flags
// with the meaning they have for scan. Its help says what dwim runs,
// then lists the default excludes and the filesystem types the scan
// never records anything on.
func newDwimCommand(stdout io.Writer) *cobra.Command {
var opts scanOptions
short := "Scan /, then print the duplicate-tree report"
dwimCmd := &cobra.Command{
Use: cmdDwim + " " + scanFlagsUsage,
DisableFlagsInUseLine: true,
Short: short,
Long: short + ".\n\n" + dwimHelp + "\n" +
defaultExcludesHelp(runtime.GOOS),
Args: cobra.NoArgs,
PreRunE: opts.check,
RunE: runE(func(ctx context.Context, _ []string) error {
return runDwim(ctx, "/", opts, stdout)
}),
}
addScanFlags(dwimCmd, &opts)
return dwimCmd
}
// checkScanWorkers rejects a --workers value below 1. That is a usage
// error reported in one line: cobra prints the returned message without
// the usage text, and run exits 2.
func checkScanWorkers(cmd *cobra.Command, workers int) error {
if workers >= 1 {
return nil
}
cmd.SilenceUsage = true
return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers)
}
// rejectEmptyExcludeValue rejects an empty --exclude value. That
// is a usage error reported in one line, as for --workers below 1.
func rejectEmptyExcludeValue(cmd *cobra.Command, excludePaths []string) error {
if !slices.Contains(excludePaths, "") {
return nil
}
cmd.SilenceUsage = true
return errEmptyExcludeValue
}
// rejectEmptyOperand rejects an empty PATH operand of report or trees.
// That is a usage error reported in one line, as for --workers below 1.
func rejectEmptyOperand(cmd *cobra.Command, args []string) error {
if !slices.Contains(args, "") {
return nil
}
cmd.SilenceUsage = true
return errEmptyOperand
}
// runE adapts a subcommand implementation, or the version print, to
// cobra's RunE. Cobra prints the error and the command's usage text for
// every error RunE returns, but a subcommand that ran and failed has no
// usage problem to report: both are silenced here, and the error is
// marked fatal so that run reports it on stderr and exits 1 rather than
// 2. The command's context is handed to the implementation: cancelling
// it unwinds the scan's worker pools.
func runE(
fn func(ctx context.Context, args []string) error,
) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := fn(cmd.Context(), args)
if err != nil {
return fatalError{err: err}
}
return nil
}
}
// fatalError marks a runtime failure, as opposed to the usage errors
// cobra itself produces while parsing arguments and flags. Both come
// out of Execute as plain errors, so the wrapper is what tells run to
// report this one as "sfdupes: ..." and exit 1.
type fatalError struct {
err error
}
func (e fatalError) Error() string { return e.err.Error() }
func (e fatalError) Unwrap() error { return e.err }
-38
View File
@@ -1,38 +0,0 @@
package sfdupes
import (
"context"
"fmt"
"io"
"os"
)
// runDwim implements the dwim subcommand: it runs what "sfdupes scan
// root" runs with the scan flags in opts, 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, opts scanOptions, stdout io.Writer,
) error {
scanCtx, stop := interruptContext(ctx)
err := runScan(scanCtx, []string{root}, opts)
stop()
if err != nil {
return err
}
err = runTrees(ctx, nil, stdout)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr,
`dwim: run "sfdupes trees" to print this report again`)
return nil
}
-210
View File
@@ -1,210 +0,0 @@
package sfdupes //nolint:testpackage // calls the unexported runDwim
import (
"bytes"
"errors"
"os"
"path/filepath"
"runtime"
"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, scanOptions{workers: 1}, &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, scanOptions{workers: 1}, &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)
}
}
func TestRunDwimExcludeLeavesTreeOut(t *testing.T) {
// dwim passes its --exclude paths to the scan: an excluded copy is
// not recorded, so its pair is not reported.
t.Setenv(databaseEnv, testDBPath(t))
root := realTempDir(t)
kept := []string{
filepath.Dir(writeFile(t, root, "a1/f.bin", pattern(1, 300))),
filepath.Dir(writeFile(t, root, "a2/f.bin", pattern(1, 300))),
}
writeFile(t, root, "b1/f.bin", pattern(2, 5000))
excluded := filepath.Dir(writeFile(t, root, "b2/f.bin", pattern(2, 5000)))
var stdout bytes.Buffer
stderr := captureStderr(t)
opts := scanOptions{workers: 1, excludePaths: []string{excluded}}
err := runDwim(t.Context(), root, opts, &stdout)
if err != nil {
t.Fatalf("runDwim: %v; stderr: %s", err, stderr())
}
lines := strings.Split(strings.TrimSuffix(stdout.String(), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("stdout = %q, want a header and one group",
stdout.String())
}
want := kept[0] + "\t" + kept[1] + "\t1\t300\t"
if !strings.HasPrefix(lines[1], want) {
t.Errorf("group = %q, want it to start %q", lines[1], want)
}
skipped := "scan: skipped " + excluded + " (--exclude)\n"
if got := stderr(); !strings.Contains(got, skipped) {
t.Errorf("stderr = %q, want it to contain %q", got, skipped)
}
}
func TestRunDwimTakesScanFlags(t *testing.T) {
// The dwim command parses and checks the scan flags as scan does.
// Each case stops before the walk of / begins, and before the
// database is created. "dwim --exclude /" excludes its own operand.
cases := map[string]struct {
args []string
code int
want string
}{
"--exclude /": {
[]string{cmdDwim, excludeFlag, "/"},
exitFatal,
"sfdupes: scan /: operand excluded by --exclude /\n",
},
"--exclude empty": {
[]string{cmdDwim, excludeFlag, ""},
exitUsage,
"Error: --exclude must not be empty\n",
},
"--workers 0": {
[]string{cmdDwim, workersFlag, "0", "-x", "--no-default-excludes"},
exitUsage,
"Error: --workers must be at least 1, got 0\n",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
stdout := captureStdout(t)
stderr := captureStderr(t)
code := run(tc.args, os.Stdout, os.Stderr)
if code != tc.code {
t.Errorf("run(%q) = %d, want %d", tc.args, code, tc.code)
}
if !strings.HasSuffix(stderr(), tc.want) || stdout() != "" {
t.Errorf("stdout, stderr = %q, %q, want \"\" and stderr "+
"ending %q", stdout(), stderr(), tc.want)
}
_, err := os.Lstat(path)
if !errors.Is(err, os.ErrNotExist) {
t.Errorf("lstat %s: %v, want no database", path, err)
}
})
}
}
func TestRunDwimHelpListsScanFlags(t *testing.T) {
t.Parallel()
// README "Subcommands" shows this usage line for dwim.
const want = "\n sfdupes dwim [--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]...\n"
var stdout, stderr bytes.Buffer
run([]string{cmdDwim, "-h"}, &stdout, &stderr)
if !strings.Contains(stderr.String(), want) {
t.Errorf("dwim -h stderr = %q, want the usage line %q",
stderr.String(), want)
}
if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("dwim -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag)
}
// README "Settings": the help lists the default excludes and the
// filesystem types the walk never records anything on.
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
if !strings.Contains(stderr.String(), help) {
t.Errorf("dwim -h stderr = %q, want it to hold %q", stderr.String(),
help)
}
}
+294
View File
@@ -0,0 +1,294 @@
// 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 --version
//
// See README.md for the complete specification.
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"runtime"
"runtime/debug"
"github.com/spf13/cobra"
)
// Exit codes: exitOK is success (even with per-file warnings),
// exitFatal is a fatal error, exitUsage is a usage error.
const (
exitOK = 0
exitFatal = 1
exitUsage = 2
)
// The subcommand names, as typed on the command line.
const (
cmdScan = "scan"
cmdReport = "report"
cmdTrees = "trees"
cmdInfo = "info"
)
// errNoSubcommand is returned by the root command when it is invoked
// without a subcommand. That is a usage error, and the usage text
// cobra prints for it is the whole message.
var errNoSubcommand = errors.New("no subcommand")
// errWorkersBelowOne is the usage error for a scan --workers value
// below 1.
var errWorkersBelowOne = errors.New("--workers must be at least 1")
// Version is the build version, injected at link time via -ldflags
// (see the Makefile); empty when the linker set none, as for a binary
// built by go install. buildVersion turns it into what --version prints.
//
//nolint:gochecknoglobals // written only by the linker
var Version string
// buildVersion returns the version that sfdupes --version prints
// (README "Install"): the version the linker set, when it set one;
// otherwise the main module's version that Go recorded in the binary,
// a tag such as v0.0.2 or the pseudo-version of an @main build; and
// "dev" when neither is known. info is what debug.ReadBuildInfo
// returns, nil when the binary carries none. Go records "(devel)" for
// a build that has no version, such as one inside a copy of the source
// without git.
func buildVersion(linkerVersion string, info *debug.BuildInfo) string {
if linkerVersion != "" {
return linkerVersion
}
if info == nil || info.Main.Version == "" ||
info.Main.Version == "(devel)" {
return "dev"
}
return info.Main.Version
}
func main() {
// 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
// write instead of returning an error (README "Error handling").
// Registering for SIGPIPE with os/signal would change that.
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
// run executes args against the command tree and returns the process
// exit code. It is the program's single exit point: the subcommands
// return their errors instead of exiting, so every deferred cleanup —
// above all closing the database, which checkpoints the SQLite WAL —
// runs before the process ends. The report, trees and info subcommands
// write their data to stdout.
func run(args []string, stdout, stderr io.Writer) int {
// A nil slice makes cobra fall back to os.Args, which would let a
// test binary's own flags reach the command tree.
if args == nil {
args = []string{}
}
root := newRootCommand(stdout, stderr)
root.SetArgs(args)
err := root.Execute()
var fatal fatalError
switch {
case err == nil:
return exitOK
case errors.Is(err, errInterrupted):
// The interrupted scan has printed its own line.
return exitFatal
case errors.As(err, &fatal):
// The command ran and failed: a runtime error, reported
// without the usage text that a usage error gets.
_, _ = fmt.Fprintf(stderr, "sfdupes: %v\n", err)
return exitFatal
default:
// A usage error, which cobra has already reported on stderr.
return exitUsage
}
}
// newRootCommand builds the command tree. Everything on stdout is
// machine-readable data, the version line included; all human-facing
// output (help, usage, errors) goes to stderr.
func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
var showVersion bool
printVersion := runE(func(context.Context, []string) error {
info, _ := debug.ReadBuildInfo()
_, err := fmt.Fprintf(stdout, "sfdupes %s\n",
buildVersion(Version, info))
if err != nil {
return fmt.Errorf("write stdout: %w", err)
}
return nil
})
root := &cobra.Command{
Use: "sfdupes",
Short: "Find candidate duplicate files by size and head/tail/content SHA-256",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if showVersion {
return printVersion(cmd, args)
}
// A missing subcommand prints usage and exits 2: cobra
// prints the usage text for the returned error, and run
// maps everything that is not a fatal error to exit 2.
cmd.SilenceErrors = true
return errNoSubcommand
},
}
root.SetOut(stderr)
root.SetErr(stderr)
root.CompletionOptions.DisableDefaultCmd = true
// Cobra's built-in version flag prints through the help writer,
// stderr; this one prints to stdout.
root.Flags().BoolVarP(&showVersion, "version", "v", false,
"print the version to stdout")
reportCmd := &cobra.Command{
Use: cmdReport,
Short: "Read the scan database and print the file-level duplicates report",
Args: cobra.NoArgs,
RunE: runE(func(ctx context.Context, _ []string) error {
return runReport(ctx, stdout)
}),
}
treesCmd := &cobra.Command{
Use: cmdTrees,
Short: "Read the scan database and print the duplicate-tree report",
Args: cobra.NoArgs,
RunE: runE(func(ctx context.Context, _ []string) error {
return runTrees(ctx, stdout)
}),
}
infoCmd := &cobra.Command{
Use: cmdInfo,
Short: "Read the scan database and print its details",
Args: cobra.NoArgs,
RunE: runE(func(ctx context.Context, _ []string) error {
return runInfo(ctx, stdout)
}),
}
root.AddCommand(newScanCommand(), reportCmd, treesCmd, infoCmd)
return root
}
// newScanCommand builds the scan subcommand, with its --workers, -x and
// --no-default-excludes flags. Its help lists the default excludes of
// the system it runs on, and the filesystem types it never records
// anything on.
func newScanCommand() *cobra.Command {
var (
scanWorkers int
scanOneFS bool
scanNoDefaultExcludes bool
)
short := "Walk trees and synchronize the scan database"
scanCmd := &cobra.Command{
Use: cmdScan + " [--workers N] [-x] [--no-default-excludes] PATH...",
Short: short,
Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS),
Args: cobra.MinimumNArgs(1),
PreRunE: func(cmd *cobra.Command, _ []string) error {
return checkScanWorkers(cmd, scanWorkers)
},
RunE: runE(func(ctx context.Context, args []string) error {
ctx, stop := interruptContext(ctx)
defer stop()
return runScan(ctx, args, scanWorkers, scanOneFS,
scanNoDefaultExcludes)
}),
}
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
"concurrent workers for the walk and hash phases")
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
"do not cross filesystem boundaries")
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
false, "enter the default excludes, the paths listed above")
return scanCmd
}
// checkScanWorkers rejects a scan --workers value below 1. That is a
// usage error reported in one line: cobra prints the returned message
// without the usage text, and run exits 2.
func checkScanWorkers(cmd *cobra.Command, workers int) error {
if workers >= 1 {
return nil
}
cmd.SilenceUsage = true
return fmt.Errorf("%w, got %d", errWorkersBelowOne, workers)
}
// runE adapts a subcommand implementation, or the version print, to
// cobra's RunE. Cobra prints the error and the command's usage text for
// every error RunE returns, but a subcommand that ran and failed has no
// usage problem to report: both are silenced here, and the error is
// marked fatal so that run reports it on stderr and exits 1 rather than
// 2. The command's context is handed to the implementation: cancelling
// it unwinds the scan's worker pools.
func runE(
fn func(ctx context.Context, args []string) error,
) func(*cobra.Command, []string) error {
return func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := fn(cmd.Context(), args)
if err != nil {
return fatalError{err: err}
}
return nil
}
}
// fatalError marks a runtime failure, as opposed to the usage errors
// cobra itself produces while parsing arguments and flags. Both come
// out of Execute as plain errors, so the wrapper is what tells run to
// report this one as "sfdupes: ..." and exit 1.
type fatalError struct {
err error
}
func (e fatalError) Error() string { return e.err.Error() }
func (e fatalError) Unwrap() error { return e.err }
+70 -507
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported run
package main
import (
"bytes"
@@ -14,21 +14,12 @@ import (
"strconv"
"strings"
"testing"
"github.com/adrg/xdg"
"golang.org/x/sys/unix"
)
// usageMarker is the first line of cobra's usage text, which a usage
// error prints and a runtime failure must not.
const usageMarker = "Usage:"
// helpFlag asks for the help text.
const helpFlag = "--help"
// workersFlag is the scan and dwim flag that sets the worker count.
const workersFlag = "--workers"
// TestMain stops the tests at once, saying why, when their temporary
// directory is not on a persistent filesystem: scan records nothing
// there (README "Rules for the walk"), so most scan tests would fail
@@ -560,17 +551,13 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
// line.
t.Setenv(databaseEnv, "")
dbPath, err := defaultDatabasePath(os.Geteuid())
if err != nil {
t.Fatal(err)
}
// Whether scan calls the database new depends on that machine too.
scanWant := "scan: database " + dbPath + " (default)\n"
scanWant := "scan: database " + defaultDatabasePath + " (default)\n"
_, err = os.Stat(dbPath)
_, err := os.Stat(defaultDatabasePath)
if errors.Is(err, fs.ErrNotExist) {
scanWant = "scan: new database " + dbPath + " (default)\n"
scanWant = "scan: new database " + defaultDatabasePath +
" (default)\n"
}
missing := filepath.Join(t.TempDir(), "nope")
@@ -581,30 +568,29 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
}{
cmdScan: {
run: func() error {
return runScan(t.Context(), []string{missing},
scanOptions{workers: 1})
return runScan(t.Context(), []string{missing}, 1, false, false)
},
want: scanWant,
},
cmdReport: {
run: func() error {
return runReport(cancelledContext(t), nil, io.Discard)
return runReport(cancelledContext(t), io.Discard)
},
want: "report: database " + dbPath + " (default)\n" +
want: "report: database " + defaultDatabasePath + " (default)\n" +
reportOpenLine + "\n" + reportOpenLine + "\n",
},
cmdTrees: {
run: func() error {
return runTrees(cancelledContext(t), nil, io.Discard)
return runTrees(cancelledContext(t), io.Discard)
},
want: "trees: database " + dbPath + " (default)\n" +
want: "trees: database " + defaultDatabasePath + " (default)\n" +
treesOpenLine + "\n" + treesOpenLine + "\n",
},
cmdInfo: {
run: func() error {
return runInfo(cancelledContext(t), io.Discard)
},
want: "info: database " + dbPath + " (default)\n" +
want: "info: database " + defaultDatabasePath + " (default)\n" +
infoOpenLine + "\n" + infoOpenLine + "\n",
},
}
@@ -631,8 +617,9 @@ func TestRunUsageErrors(t *testing.T) {
}{
"no subcommand": {[]string{}, usageMarker},
"scan without paths": {[]string{cmdScan}, usageMarker},
"report with args": {[]string{cmdReport, "x"}, usageMarker},
"trees with args": {[]string{cmdTrees, "x"}, usageMarker},
"info with args": {[]string{cmdInfo, "x"}, usageMarker},
"dwim with args": {[]string{cmdDwim, "x"}, usageMarker},
"unknown flag": {[]string{cmdScan, "--nope", "/"}, usageMarker},
"unknown subcommand": {[]string{"nope"}, "unknown command"},
}
@@ -671,7 +658,7 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
var stdout, stderr bytes.Buffer
args := []string{cmdScan, workersFlag, workers, t.TempDir()}
args := []string{cmdScan, "--workers", workers, t.TempDir()}
code := run(args, &stdout, &stderr)
if code != exitUsage {
@@ -702,7 +689,7 @@ func TestRunHelp(t *testing.T) {
// README §Settings: help goes to stderr, exits 0, and leaves stdout
// empty.
cases := [][]string{{helpFlag}, {"-h"}, {cmdScan, helpFlag}}
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
for _, args := range cases {
var stdout, stderr bytes.Buffer
@@ -721,126 +708,6 @@ func TestRunHelp(t *testing.T) {
t.Errorf("run(%v) stdout = %q, want nothing (data only)",
args, got)
}
// README §Location: the help of sfdupes and of scan says where
// the database is, and that XDG_STATE_HOME must be an absolute
// path.
for _, want := range []string{
"scan and dwim store", databaseEnv, rootDatabasePath,
stateHomeEnv, "~/.local/state", "not an absolute path is an error",
} {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr = %q, want it to name %q",
args, stderr.String(), want)
}
}
}
// The scan help lists --exclude with its argument.
var stdout, stderr bytes.Buffer
run([]string{cmdScan, "-h"}, &stdout, &stderr)
if !strings.Contains(stderr.String(), excludeFlag+" PATH") {
t.Errorf("scan -h stderr = %q, want %s PATH", stderr.String(),
excludeFlag)
}
}
func TestRunScanUsageLine(t *testing.T) {
t.Parallel()
// README §Subcommands shows this usage line for scan, and the help
// and the usage error both print it.
const want = "\n sfdupes scan [--workers N] [-x] [--no-default-excludes] " +
"[--exclude PATH]... PATH...\n"
cases := map[string][]string{
"help": {cmdScan, "-h"},
"missing paths": {cmdScan},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
run(args, &stdout, &stderr)
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) stderr = %q, want the usage line %q",
args, stderr.String(), want)
}
})
}
}
func TestRunHelpDescribesOutput(t *testing.T) {
t.Parallel()
// README §Settings: the help of report, trees, info and dwim says
// what each prints. The help of report and trees also explains
// their PATH operands. TestRunHelp checks the database paragraph
// of the root and scan help.
escapes := []string{`\\`, `\t`, `\n`, `\r`}
operands := []string{
"With PATH operands", "at or below them", "working directory",
"No PATH is looked up", "not followed",
}
// The subcommand whose help is printed.
cases := []struct {
subcommand []string
want []string
}{
{
[]string{cmdReport},
slices.Concat([]string{
"tab-separated", "\n first ", "\n dupe ", "\n size ",
"largest first",
}, escapes, operands),
},
{
[]string{cmdTrees},
slices.Concat([]string{
"tab-separated", "\n first ", "\n dupe ", "\n files ",
"\n size ", "\n disk_size ", "largest first",
}, escapes, operands),
},
{
[]string{cmdInfo},
[]string{
"\n database ", "\n database file size ",
"\n -wal file size ", "\n modified ",
"\n schema version ", "\n records ",
"\n size of recorded files ",
},
},
{
[]string{cmdDwim},
[]string{
"sfdupes scan /", "as root", "sfdupes trees", "database",
},
},
}
for _, tc := range cases {
var stdout, stderr bytes.Buffer
args := slices.Concat(tc.subcommand, []string{helpFlag})
code := run(args, &stdout, &stderr)
if code != exitOK {
t.Fatalf("run(%v) = %d, want %d", args, code, exitOK)
}
for _, want := range tc.want {
if !strings.Contains(stderr.String(), want) {
t.Errorf("run(%v) help lacks %q:\n%s",
args, want, stderr.String())
}
}
}
}
@@ -930,7 +797,7 @@ func TestRunVersionWriteFailureIsFatal(t *testing.T) {
func scanFixture(t *testing.T) []string {
t.Helper()
dir := realTempDir(t)
dir := t.TempDir()
dupes := []string{
writeFile(t, dir, "one/a.bin", pattern(1, 300)),
@@ -1000,7 +867,7 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
dir := t.TempDir()
writeFile(t, dir, "a.bin", pattern(1, 300))
// Same size as a.bin, so the scan reads it, and the read fails.
@@ -1028,278 +895,58 @@ func TestRunScanSucceedsDespiteWarnings(t *testing.T) {
assertNoSidecars(t, path)
}
// realTempDir returns a new temporary directory by its real path, so
// 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) {
func TestRunScanSkipsSymlinkOperand(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
dir := t.TempDir()
writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
stderr := scanOK(t, 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)
err := os.Symlink(filepath.Join(dir, "target"), link)
if err != nil {
t.Fatal(err)
}
if got := storedPaths(t, path); !slices.Equal(got, []string{f}) {
t.Errorf("records = %q, want %q", got, []string{f})
}
// Scanning a directory through the symlink stores a record beneath
// 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) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "target/sub/f", pattern(1, 10))
dir := t.TempDir()
writeFile(t, dir, "target/sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
symlink(t, filepath.Join(dir, "target"), link)
stderr := scanOK(t, link, filepath.Join(link, "sub"))
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)
err := os.Symlink(filepath.Join(dir, "target"), link)
if err != nil {
t.Fatal(err)
}
symlink(t, filepath.Join(dir, "data", "proj"),
filepath.Join(dir, "home", "proj"))
// link is dropped as a symlink, but link/sub must still be scanned,
// 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
// 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)
db, err := openDB(path, reportParams)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(dir, "link")
symlink(t, fifo, link)
t.Cleanup(func() { _ = db.Close() })
stderr := scanOK(t, link)
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)
}
recordByPath(t, dbRecords(t, db), filepath.Join(link, "sub", "f"))
}
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))
// No scan records a file inside a .zfs directory, so the record
@@ -1350,7 +997,7 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
dir := t.TempDir()
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
writeFile(t, hourly, "f", pattern(1, 10))
f := writeFile(t, dir, "f", pattern(2, 10))
@@ -1394,19 +1041,23 @@ func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
// 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) {
t.Setenv(databaseEnv, testDBPath(t))
dir := realTempDir(t)
dir := t.TempDir()
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
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"
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
"0 unchanged), 1 skipped\n"
@@ -1418,19 +1069,24 @@ func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
}
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
// a symlink to pool/data. The operand is scanned as usual, and its file
// is recorded by its real path.
// a symlink to a directory outside any .zfs directory. The operand is
// scanned as usual, and its file is recorded by the path given.
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
path := testDBPath(t)
t.Setenv(databaseEnv, path)
dir := realTempDir(t)
f := writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
dir := t.TempDir()
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
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, " +
"0 unchanged), 0 skipped\n"
@@ -1438,9 +1094,14 @@ func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
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})
db, err := openDB(path, reportParams)
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) {
@@ -1626,100 +1287,6 @@ func TestRunDatabasePathUsedAsGiven(t *testing.T) {
})
}
func TestRunNonRootUsesStateDirectory(t *testing.T) {
// README §Location: without SFDUPES_DATABASE, a user other than
// root keeps the database in the XDG state directory. scan creates
// the missing directories with mode 0700, and report and trees read
// the same file.
if os.Geteuid() == 0 {
t.Skip("root uses " + rootDatabasePath)
}
t.Cleanup(xdg.Reload)
state := filepath.Join(t.TempDir(), "state")
t.Setenv(stateHomeEnv, state)
t.Setenv(databaseEnv, "")
xdg.Reload()
assertRunsUseDatabase(t, filepath.Join(state, "sfdupes"), "db.sqlite")
for _, dir := range []string{state, filepath.Join(state, "sfdupes")} {
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != stateDirPerm {
t.Errorf("%s mode = %#o, want %#o", dir, got, stateDirPerm)
}
}
}
func TestRunNonRootDatabaseEnvDirectoryMode(t *testing.T) {
// README §Location: for SFDUPES_DATABASE, scan creates each missing
// directory with mode 755, for a user other than root too.
if os.Geteuid() == 0 {
t.Skip("needs a user other than root")
}
top := filepath.Join(t.TempDir(), "missing")
sub := filepath.Join(top, "sub")
t.Setenv(databaseEnv, filepath.Join(sub, "db.sqlite"))
scanOK(t, t.TempDir())
for _, dir := range []string{top, sub} {
fi, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if got := fi.Mode().Perm(); got != dbDirPerm {
t.Errorf("%s mode = %#o, want %#o", dir, got, dbDirPerm)
}
}
}
func TestRunRelativeStateHomeFails(t *testing.T) {
// README §Location: without SFDUPES_DATABASE, a user other than
// root whose XDG_STATE_HOME is not an absolute path gets an error
// naming it from every subcommand, which exits 1 before it does
// anything else.
if os.Geteuid() == 0 {
t.Skip("root does not use " + stateHomeEnv)
}
t.Setenv(stateHomeEnv, "relative/state")
t.Setenv(databaseEnv, "")
want := "sfdupes: " + stateHomeEnv +
" must be an absolute path, got \"relative/state\"\n"
// A scan that got past the check would stop at this missing
// operand, before it creates anything.
missing := filepath.Join(t.TempDir(), "missing")
for _, args := range [][]string{
{cmdScan, missing}, {cmdReport}, {cmdTrees}, {cmdInfo},
} {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code != exitFatal || stderr.String() != want {
t.Errorf("run(%v) = %d with stderr %q, want %d and %q",
args, code, stderr.String(), exitFatal, want)
}
}
// dwim scans /, so runDwim gets the missing operand instead.
err := runDwim(t.Context(), missing, scanOptions{workers: 1}, io.Discard)
if !errors.Is(err, errStateHomeNotAbsolute) {
t.Errorf("runDwim: error = %v, want %v", err,
errStateHomeNotAbsolute)
}
}
// holdScanLock takes the lock on the database at path, as a running
// scan does, and holds it until the test ends. It fails the test when
// the lock is already held.
@@ -1950,13 +1517,9 @@ func TestStdoutWriteErrorPropagates(t *testing.T) {
scanFixture(t)
cases := map[string]func(context.Context, io.Writer) error{
cmdReport: func(ctx context.Context, w io.Writer) error {
return runReport(ctx, nil, w)
},
cmdTrees: func(ctx context.Context, w io.Writer) error {
return runTrees(ctx, nil, w)
},
cmdInfo: runInfo,
cmdReport: runReport,
cmdTrees: runTrees,
cmdInfo: runInfo,
}
for name, fn := range cases {
+10 -108
View File
@@ -1,4 +1,4 @@
package sfdupes
package main
import (
"fmt"
@@ -9,10 +9,8 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/rivo/uniseg"
"github.com/schollz/progressbar/v3"
"golang.org/x/term"
)
@@ -118,8 +116,7 @@ type drawnCount struct {
// terminal's width. A phase with an exact total ends the line with its
// percent and bar, at the terminal's right edge, when the terminal has
// room for them. It is drawn again every redrawInterval, so its elapsed
// time keeps moving while the phase waits for its next item. Each draw
// of the walk shows the path it reached last on the line below. When
// time keeps moving while the phase waits for its next item. When
// stderr is not a TTY it emits no ANSI redraws: it prints line as the
// phase starts, every plainInterval while it runs, and as it ends.
//
@@ -144,7 +141,6 @@ type progress struct {
estimate bool
waitingFor string
stalledOn string
path string // the path the walk reached last; "" for none
count int64
start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the time remaining
@@ -243,82 +239,6 @@ func (p *progress) draw() {
// less the terminal's last column, which spinnerMargin keeps clear. A
// bar's cells take one column each but more than one byte.
p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1)
if p.path != "" {
// The library draws the path on the line below and returns to
// the phase's line. The path is escaped as warnings are, and its
// other control characters are written out, so that the terminal
// shows them instead of acting on them. It stays clear of the
// last column, so that no terminal wraps it.
shown := escapeControlChars(escapePath(p.path))
_ = p.bar.AddDetail(cutToColumns(shown, width-1))
}
}
// escapeControlChars returns s with each control character written as
// \x and its code in two hex digits, as \x1b for the escape character.
// The control characters are U+0000 to U+001F, U+007F and U+0080 to
// U+009F, as unicode.IsControl reports them. A byte from 0x80 to 0x9F
// that is not part of a valid UTF-8 character is written the same way,
// as \x9b, since a terminal not in UTF-8 mode acts on it. Every other
// byte is kept as it is.
func escapeControlChars(s string) string {
var b strings.Builder
for rest := s; rest != ""; {
char, size := utf8.DecodeRuneInString(rest)
if char == utf8.RuneError && size == 1 {
// Not part of a valid UTF-8 character. A terminal not in
// UTF-8 mode reads the byte as the character with its code,
// so that 0x9b is a control character there and 0xe9 is not.
char = rune(rest[0])
}
if unicode.IsControl(char) {
fmt.Fprintf(&b, `\x%02x`, char)
} else {
b.WriteString(rest[:size])
}
rest = rest[size:]
}
return b.String()
}
// cutToColumns returns s cut to at most columns terminal columns, at a
// character boundary. A wide character, as in Chinese, takes two
// columns.
func cutToColumns(s string, columns int) string {
used, end, state := 0, 0, -1
for rest := s; rest != ""; {
char, next, width, nextState := uniseg.FirstGraphemeClusterInString(
rest, state)
if used+width > columns {
break
}
used += width
end += len(char)
rest, state = next, nextState
}
return s[:end]
}
// clear erases the display from the terminal: its line, and the path on
// the line below when one is shown. p.mu must be held.
func (p *progress) clear() {
if p.bar == nil {
return
}
if p.path != "" {
_ = p.bar.AddDetail("")
}
_ = p.bar.Clear()
}
// newBar has the library make what draw draws: its spinner, followed by
@@ -343,8 +263,6 @@ func (p *progress) newBar(width int) {
// The text carries the elapsed time and the time remaining.
progressbar.OptionSetElapsedTime(false),
progressbar.OptionSetPredictTime(false),
// The line below the phase's line, for the walk's path.
progressbar.OptionSetMaxDetailRow(1),
)
p.widest = 0
}
@@ -396,19 +314,6 @@ func (p *progress) increment() {
p.lastItem = now
}
// setPath records the path the walk reached last, which a display on a
// terminal shows at its next draw.
func (p *progress) setPath(path string) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.path = path
}
// warnf prints a one-line warning to stderr without corrupting the
// display. The whole message is escaped like a report's path columns,
// so a path holding a newline cannot split the warning.
@@ -422,9 +327,11 @@ func (p *progress) warnf(format string, args ...any) {
p.mu.Lock()
defer p.mu.Unlock()
// The warning takes the display's line; the next draw shows the
// display again, on the line below.
p.clear()
if p.bar != nil {
// The warning takes the display's line; the next draw shows
// the display again, on the line below.
_ = p.bar.Clear()
}
fmt.Fprintln(os.Stderr, msg)
}
@@ -485,7 +392,9 @@ func (w *progressWriter) Flush() error {
w.p.mu.Lock()
defer w.p.mu.Unlock()
w.p.clear()
if w.p.bar != nil {
_ = w.p.bar.Clear()
}
}
_, err := w.out.Write(w.buf)
@@ -505,13 +414,6 @@ func (p *progress) finish() {
close(p.stop)
<-p.done
// The walk's path is only for someone watching the walk. Its line is
// erased, and the next phase is drawn on it.
p.mu.Lock()
p.clear()
p.path = ""
p.mu.Unlock()
p.draw()
if p.width != nil {
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported startProgress
package main
import (
"fmt"
@@ -6,7 +6,6 @@ import (
"math"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
@@ -643,9 +642,9 @@ func TestTerminalStalledLines(t *testing.T) {
{label: scanPhase("hash"), stalledOn: hashStalledOn},
{label: scanPhase("update"), stalledOn: "db"},
{label: treesPhase("read"), stalledOn: "db"},
{label: treesPhase("group"), stalledOn: "cpu/disk"},
{label: treesPhase("group"), stalledOn: "cpu"},
{label: treesPhase("write"), stalledOn: "out"},
{label: reportPhase("read"), stalledOn: "db/disk/out"},
{label: reportPhase("read"), stalledOn: "db/out"},
}
for _, params := range phases {
@@ -1139,164 +1138,3 @@ func TestWarningAfterLastItemKeepsBar(t *testing.T) {
t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
}
}
func TestCutToColumns(t *testing.T) {
t.Parallel()
cases := []struct {
s string
columns int
want string
}{
{"/srv/a", 10, "/srv/a"},
{"/srv/photos", 8, "/srv/pho"},
// Each of these two fullwidth letters takes two columns.
{"/AB/a", 4, "/A"},
{"/AB/a", 5, "/AB"},
// An accent that combines with the letter before stays with it.
{"/café/a", 5, "/café"},
{"/srv", 0, ""},
}
for _, tc := range cases {
if got := cutToColumns(tc.s, tc.columns); got != tc.want {
t.Errorf("cutToColumns(%q, %d) = %q, want %q",
tc.s, tc.columns, got, tc.want)
}
}
}
func TestEscapeControlChars(t *testing.T) {
t.Parallel()
cases := map[string]string{
"/srv/music": "/srv/music",
"/a\x1b[2Jb": `/a\x1b[2Jb`,
"/a\x00\a\x7fb": `/a\x00\x07\x7fb`,
// U+009B takes two bytes in UTF-8.
"/a\u009b2Jb": `/a\x9b2Jb`,
// Byte 0x9b alone is not valid UTF-8. A terminal not in UTF-8
// mode acts on it, as on every byte from 0x80 to 0x9f.
"/a\x9b2Jb": `/a\x9b2Jb`,
"/a\x80\x9f\xa0b": `/a\x80\x9f` + "\xa0b",
// In ě, which is c4 9b in UTF-8, byte 0x9b is part of a valid
// character.
"/aě2Jb": "/aě2Jb",
"/café/AB": "/café/AB",
"/latin1-caf\xe9": "/latin1-caf\xe9",
}
for in, want := range cases {
if got := escapeControlChars(in); got != want {
t.Errorf("escapeControlChars(%q) = %q, want %q", in, got, want)
}
}
}
// pathLines returns the text of each line the library wrote below a
// display's line. It writes one as an erase to the end of the line, a
// carriage return, the text and a newline.
func pathLines(out string) []string {
matches := regexp.MustCompile("\x1b\\[K\r([^\n]*)\n").
FindAllStringSubmatch(out, -1)
lines := make([]string, 0, len(matches))
for _, m := range matches {
lines = append(lines, m[1])
}
return lines
}
// TestTerminalWalkShowsPathBelow checks that the walk's display on a
// terminal 30 columns wide shows the path it reached last on the line
// below, cut to 29 columns. That line is erased before a warning is
// printed, and again when the walk ends, so the next phase is drawn on
// an empty line.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkShowsPathBelow(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 30 }, testRedraw, time.Now)
const shown = "/srv/photos/2026/holiday/beac"
p.setPath("/srv/photos/2026/holiday/beach.jpg")
p.increment()
awaitOutput(t, stderr, shown, 1)
p.warnf("warning")
before, _, _ := strings.Cut(stderr(), "warning")
if lines := pathLines(before); lines[len(lines)-1] != "" {
t.Errorf("line below shows %q as the warning is printed, want it "+
"empty", lines[len(lines)-1])
}
// Drawn again below the warning.
awaitOutput(t, stderr, shown, strings.Count(before, shown)+1)
p.finish()
lines := pathLines(stderr())
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
if last := lines[len(lines)-1]; last != "" {
t.Errorf("line below the display shows %q after the walk, want it "+
"empty", last)
}
}
// TestTerminalWalkEscapesControlChars checks that a walked path holding
// escape characters, which would clear the screen and set the window
// title, puts none on the line below the walk's display. The line shows
// each control character written out, as \x1b.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalWalkEscapesControlChars(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, func() int { return 80 }, testRedraw, time.Now)
p.setPath("/srv/\x1b[2J\x1b]0;title\a/beach.jpg")
p.increment()
awaitOutput(t, stderr, "beach.jpg", 1)
p.finish()
lines := pathLines(stderr())
for _, line := range lines {
if strings.Contains(line, "\x1b") {
t.Errorf("line below the display = %q, want no escape "+
"character", line)
}
}
const shown = `/srv/\x1b[2J\x1b]0;title\x07/beach.jpg`
if !slices.Contains(lines, shown) {
t.Errorf("lines below the display = %q, want %q among them",
lines, shown)
}
}
// TestPlainWalkShowsNoPath checks that the walk's display prints no path
// when stderr is not a terminal.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestPlainWalkShowsNoPath(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{
label: scanPhase("walk"), unit: scanUnit, total: -1,
}, nil, testRedraw, time.Now)
p.setPath("/srv/a")
p.increment()
p.finish()
if strings.Contains(stderr(), "/srv/a") {
t.Errorf("stderr = %q, want no path", stderr())
}
}
+18 -102
View File
@@ -1,18 +1,14 @@
package sfdupes
package main
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
)
@@ -45,10 +41,8 @@ func reportPhase(name string) string {
}
// runReport implements the report subcommand: it prints the file-level
// duplicates report as TSV on stdout, from the records at or below the
// operands in args, or from every record when args is empty. Of the
// scanned filesystem it only checks, with pathExists, that each path in
// a duplicate group is still there; it never looks up an operand. It
// duplicates report as TSV on stdout. It never touches the scanned
// filesystem; its only I/O is the database, stdout, and stderr. It
// names the database on stderr before it opens it. Any database
// problem, including a missing database, is fatal.
//
@@ -58,12 +52,7 @@ func reportPhase(name string) string {
// order, writing each size's duplicate groups to stdout once that size
// is read; see readReport). Each display ends before the next phase
// starts, and before an error is returned to be printed.
func runReport(ctx context.Context, args []string, stdout io.Writer) error {
operands, err := cleanOperands(args)
if err != nil {
return err
}
func runReport(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath()
if err != nil {
return err
@@ -88,15 +77,13 @@ func runReport(ctx context.Context, args []string, stdout io.Writer) error {
defer func() { _ = db.Close() }()
// The read phase checks the paths of each duplicate group on disk
// and writes the report to stdout while it reads the records, so it
// may wait on any of the three.
// The read phase writes the report to stdout while it reads the
// records, so it may wait on either.
prog = newProgress(progressParams{
label: reportPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db/disk/out",
waitingFor: countingRecords, stalledOn: "db/out",
})
report, err := readReport(ctx, db, dbPath, operands, stdout, pathExists,
prog)
report, err := readReport(ctx, db, dbPath, stdout, prog)
prog.finish()
@@ -117,17 +104,14 @@ func runReport(ctx context.Context, args []string, stdout io.Writer) error {
// dbPath, as its total, then reads every record in signature order,
// 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
// read. Only the records that underOperand finds under operands join a
// group, and a path for which exists returns false is left out of its
// group. stdout may be the terminal prog is drawn on, so the lines go
// read. stdout may be the terminal prog is drawn on, so the lines go
// through prog's writer. It returns what it wrote, with the totals for
// the summary line.
func readReport(ctx context.Context, db *sql.DB, dbPath string,
operands []string, stdout io.Writer, exists func(path string) bool,
prog *progress,
stdout io.Writer, prog *progress,
) (*reportWriter, error) {
out := prog.writer(stdout)
report := &reportWriter{out: out, exists: exists}
report := &reportWriter{out: out}
_, err := fmt.Fprintln(out, "first\tdupe\tsize")
if err != nil {
@@ -139,12 +123,6 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
err = loadSignatureRows(ctx, db, prog.setTotal, func(r scanRec) error {
prog.increment()
report.records++
if !underOperand(r.path, operands) {
return nil
}
writeErr = report.add(r)
return writeErr
@@ -176,14 +154,10 @@ func readReport(ctx context.Context, db *sql.DB, dbPath string,
// records of one size, so it holds only the duplicate groups of the
// size being read, and writes them, ordered by first path, once a
// record of a smaller size comes. It counts what the summary line
// reports; readReport counts the records read.
// reports.
type reportWriter struct {
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
paths []string // the paths read so far with sig
groups [][]string // the duplicate groups of sig.size, each sorted
@@ -196,6 +170,8 @@ type reportWriter struct {
// its paths; any other ends them, and one of a smaller size first
// writes the groups of the size before.
func (w *reportWriter) add(r scanRec) error {
w.records++
if r.size == w.sig.size && r.head == w.sig.head &&
r.tail == w.sig.tail && r.content == w.sig.content {
w.paths = append(w.paths, r.path)
@@ -218,20 +194,11 @@ func (w *reportWriter) add(r scanRec) error {
return nil
}
// endGroup ends the paths read with w.sig: two or more that are still
// on disk are a duplicate group, held with its paths sorted until
// 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.
// endGroup ends the paths read with w.sig: two or more are a duplicate
// group, held with its paths sorted until writeGroups writes it.
func (w *reportWriter) endGroup() {
if len(w.paths) >= minGroupSize && w.sig.size > 0 {
w.paths = slices.DeleteFunc(w.paths, func(p string) bool {
return !w.exists(p)
})
}
if len(w.paths) < minGroupSize || w.sig.size == 0 {
// The paths are dropped, and their slice reused.
if len(w.paths) < minGroupSize {
// A path with no duplicate is dropped, and its slice reused.
w.paths = w.paths[:0]
return
@@ -278,57 +245,6 @@ func (w *reportWriter) finish() error {
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)
}
// cleanOperands returns the operands of report or trees, each made
// absolute by absolutePath and then cleaned. The records hold real
// paths, with no "." or ".." element and no repeated or trailing
// slash, so an operand is cleaned to match them. No operand is looked
// up on the filesystem. None is empty: rejectEmptyOperand refuses an
// empty operand on the command line.
func cleanOperands(args []string) ([]string, error) {
operands := make([]string, 0, len(args))
for _, a := range args {
abs, err := absolutePath(a)
if err != nil {
return nil, fmt.Errorf("resolve path %s: %w", escapePath(a), err)
}
operands = append(operands, filepath.Clean(abs))
}
return operands, nil
}
// underOperand reports whether path is one of operands or lies below
// one of them. Each operand is clean and absolute, as cleanOperands
// returns it. With no operands, a report covers the whole database, so
// underOperand is true for every path.
func underOperand(path string, operands []string) bool {
if len(operands) == 0 {
return true
}
for _, o := range operands {
// Only the root directory's clean path ends in "/".
dir := strings.TrimSuffix(o, "/") + "/"
if path == o || strings.HasPrefix(path, dir) {
return true
}
}
return false
}
// escapePath returns a path as it is written in a report column (README
// "Report output format"): a backslash, tab, newline or carriage return
// 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 (
"bytes"
@@ -72,38 +72,15 @@ type dupeGroup struct {
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
// 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
// disk.
// each naming the group's first path.
func dupeGroups(t *testing.T, db *sql.DB) []dupeGroup {
t.Helper()
var out bytes.Buffer
_, err := readReport(t.Context(), db, "", nil, &out, everyPathExists,
nil)
_, err := readReport(t.Context(), db, "", &out, nil)
if err != nil {
t.Fatal(err)
}
@@ -149,139 +126,8 @@ func dupeGroupsOf(t *testing.T, recs []scanRec) []dupeGroup {
return dupeGroups(t, db)
}
// TestRunReportOperands runs report with operands on three copies of
// one file, two of them under srv/photos, and a fourth copy in
// srv/photos2, all in a new directory. Only the records at or below the
// operands form the groups and the totals, however each operand is
// written.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestRunReportOperands(t *testing.T) {
h := testHash("h")
recs := make([]scanRec, 0, 4)
for _, p := range []string{
"/a/copy", "/srv/photos/x", "/srv/photos/y", "/srv/photos2/z",
} {
recs = append(recs,
scanRec{size: 7, head: h, tail: h, content: h, path: p})
}
dir, recs := recsOnDisk(t, recs)
a, x, y, z := recs[0].path, recs[1].path, recs[2].path, recs[3].path
t.Setenv(databaseEnv, seedDatabase(t, recs))
header := "first\tdupe\tsize\n"
row := func(first, dupe string) string {
return first + "\t" + dupe + "\t7\n"
}
photos := header + row(x, y)
cases := map[string]struct {
operands []string
want string
summary string
}{
"directory": {[]string{dir + "/srv/photos"}, photos,
"1 duplicate groups, 1 dupe files, 7 B reclaimable"},
"trailing slash": {[]string{dir + "/srv/photos/"}, photos, ""},
"dot and dot-dot": {[]string{dir + "/srv/./a/../photos"}, photos, ""},
"files": {[]string{a, z}, header + row(a, z), ""},
"overlapping": {[]string{dir + "/srv", dir + "/srv/photos"},
header + row(x, y) + row(x, z), ""},
"root": {[]string{"/"},
header + row(a, x) + row(a, y) + row(a, z), ""},
"no record": {[]string{dir + "/srv/photo"}, header,
"0 duplicate groups, 0 dupe files, 0 B reclaimable"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
var stdout bytes.Buffer
stderr := captureStderr(t)
code := run(append([]string{cmdReport}, tc.operands...),
&stdout, os.Stderr)
if code != exitOK {
t.Fatalf("run(report %q) = %d, want %d; stderr: %s",
tc.operands, code, exitOK, stderr())
}
if got := stdout.String(); got != tc.want {
t.Errorf("stdout = %q, want %q", got, tc.want)
}
// The records read are all four, whatever the operands.
summary := "report: 4 records read, " + tc.summary + "\n"
if got := stderr(); tc.summary != "" &&
!strings.HasSuffix(got, summary) {
t.Errorf("stderr = %q, want it to end %q", got, summary)
}
})
}
}
//nolint:paralleltest // t.Chdir changes the process-wide working directory
func TestCleanOperandsMakesRelativePathsAbsolute(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
wd, err := absolutePath(".")
if err != nil {
t.Fatal(err)
}
got, err := cleanOperands([]string{"photos/", "a/../b", "/c//d"})
if err != nil {
t.Fatal(err)
}
want := []string{
filepath.Join(wd, "photos"), filepath.Join(wd, "b"), "/c/d",
}
if !slices.Equal(got, want) {
t.Errorf("cleanOperands = %q, want %q", got, want)
}
}
// TestRunReportsRejectEmptyOperand checks that an empty PATH operand of
// report or trees, alone or after another operand, is a usage error
// reported in one line before the database is opened (README "Error
// handling and exit codes").
func TestRunReportsRejectEmptyOperand(t *testing.T) {
cases := map[string][]string{
"report": {cmdReport, ""},
"trees": {cmdTrees, ""},
"after an operand": {cmdTrees, t.TempDir(), ""},
}
for name, args := range cases {
t.Run(name, func(t *testing.T) {
// No database exists there, so opening one would be a fatal
// error, exit 1.
t.Setenv(databaseEnv, testDBPath(t))
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
if code != exitUsage {
t.Errorf("run(%q) = %d, want %d", args, code, exitUsage)
}
want := "Error: PATH must not be empty\n"
if stdout.String() != "" || stderr.String() != want {
t.Errorf("stdout, stderr = %q, %q, want \"\", %q",
stdout.String(), stderr.String(), want)
}
})
}
}
func TestRunReportEscapesPaths(t *testing.T) {
dir, recs := recsOnDisk(t, awkwardPairRecs())
t.Setenv(databaseEnv, seedDatabase(t, recs))
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
var stdout, stderr bytes.Buffer
@@ -292,7 +138,7 @@ func TestRunReportEscapesPaths(t *testing.T) {
}
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 {
t.Errorf("stdout = %q, want %q", got, want)
}
@@ -326,8 +172,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
// Counts the records read, and draws nothing.
prog := &progress{now: time.Now}
_, err = readReport(t.Context(), db, "", nil, failingWriter{},
everyPathExists, prog)
_, err = readReport(t.Context(), db, "", failingWriter{}, prog)
if !errors.Is(err, errWriteFailed) ||
!strings.HasPrefix(err.Error(), "write stdout: ") {
t.Errorf("error = %v, want write stdout: %v", err, errWriteFailed)
@@ -358,8 +203,7 @@ const smokeReport = "first\tdupe\tsize\n" +
// it ends; read counts the 6 records before it reads them. The summary
// comes last, and stdout holds the report alone.
func TestRunReportProgressNamesEachPhase(t *testing.T) {
dir, recs := recsOnDisk(t, smokeTreeRecs())
path := seedDatabase(t, recs)
path := seedDatabase(t, smokeTreeRecs())
t.Setenv(databaseEnv, path)
stderr := captureStderr(t)
@@ -372,9 +216,8 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
code, exitOK, stderr())
}
report := strings.ReplaceAll(smokeReport, "/d/", dir+"/d/")
if got := stdout.String(); got != report {
t.Errorf("stdout = %q, want %q", got, report)
if got := stdout.String(); got != smokeReport {
t.Errorf("stdout = %q, want %q", got, smokeReport)
}
// How each line starts; the rate at the end of a phase varies.
@@ -423,8 +266,7 @@ func TestReadReportOnTerminal(t *testing.T) {
waitingFor: countingRecords,
}, terminalWidth, time.Hour, time.Now)
_, err = readReport(t.Context(), db, "", nil, os.Stderr,
everyPathExists, p)
_, err = readReport(t.Context(), db, "", os.Stderr, p)
p.finish()
@@ -459,8 +301,6 @@ func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"},
)
_, recs = recsOnDisk(t, recs)
reversed := slices.Clone(recs)
slices.Reverse(reversed)
@@ -594,141 +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: "/copy/1"},
{size: 9, head: a, tail: a, content: a, path: "/copy/2"},
{size: 9, head: a, tail: a, content: a, path: "/copy/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, "", nil, io.Discard, exists, nil)
if err != nil {
t.Fatal(err)
}
slices.Sort(checked)
want := []string{"/copy/1", "/copy/2", "/copy/3"}
if !slices.Equal(checked, want) {
t.Errorf("paths checked on disk = %q, want %q", checked, want)
}
}
// TestReadReportDropsGroupLeftWithOnePath checks that a group of two
// paths, one of them gone, is no duplicate group. If kept, it would
// write no row, but the summary line would count it.
func TestReadReportDropsGroupLeftWithOnePath(t *testing.T) {
t.Parallel()
a := testHash("a")
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/a/kept"},
{size: 9, head: a, tail: a, content: a, path: "/a/gone"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
exists := func(path string) bool { return path != "/a/gone" }
report, err := readReport(t.Context(), db, "", nil, io.Discard, exists,
nil)
if err != nil {
t.Fatal(err)
}
if report.dupeGroups != 0 {
t.Errorf("duplicate groups = %d, want 0", report.dupeGroups)
}
}
// TestReadReportKeepsGroupLeftWithTwoPaths checks that a group of three
// paths, one of them gone, is a duplicate group of the other two.
func TestReadReportKeepsGroupLeftWithTwoPaths(t *testing.T) {
t.Parallel()
a := testHash("a")
recs := []scanRec{
{size: 9, head: a, tail: a, content: a, path: "/b/kept1"},
{size: 9, head: a, tail: a, content: a, path: "/b/gone"},
{size: 9, head: a, tail: a, content: a, path: "/b/kept2"},
}
db := openTestDB(t)
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatal(err)
}
exists := func(path string) bool { return path != "/b/gone" }
var out bytes.Buffer
report, err := readReport(t.Context(), db, "", nil, &out, exists, nil)
if err != nil {
t.Fatal(err)
}
if report.dupeGroups != 1 {
t.Errorf("duplicate groups = %d, want 1", report.dupeGroups)
}
want := "first\tdupe\tsize\n/b/kept1\t/b/kept2\t9\n"
if got := out.String(); got != want {
t.Errorf("stdout = %q, want %q", got, want)
}
}
func TestDupeGroupsContentSeparates(t *testing.T) {
t.Parallel()
+86 -103
View File
@@ -1,4 +1,4 @@
package sfdupes
package main
import (
"cmp"
@@ -98,47 +98,44 @@ func scanPhase(name string) string {
// runs, and the lock is released after it. When ctx is cancelled, as by
// the SIGINT or SIGTERM that interruptContext catches, the scan keeps
// what it has hashed (see syncScan), prints how many files its walk
// reached, and returns errInterrupted. opts.workers must be at least 1;
// the scan and dwim commands reject anything less. Before the lock is
// taken, the scan lists the filesystems that are not persistent, on
// which the walk records nothing; unless opts.noDefaultExcludes is set,
// the walk does not enter the default excludes either. The walk neither
// enters nor records the paths in opts.excludePaths, the --exclude
// values; an operand at or below one is a fatal error. Once the
// database is open, the scan notes its files, which the walk passes
// over.
func runScan(ctx context.Context, roots []string, opts scanOptions) error {
// reached, and returns errInterrupted. workers must be at least 1; the
// scan command rejects anything less. Before the lock is taken, the
// scan lists the filesystems that are not persistent, on which the walk
// records nothing; unless noDefaultExcludes is set, the walk does not
// enter the default excludes either. Once the database is open, the
// scan notes its files, which the walk passes over.
func runScan(ctx context.Context, roots []string, workers int,
oneFS, noDefaultExcludes bool,
) error {
dbPath, source, err := databasePath()
if err != nil {
return err
}
printDatabasePath(dbPath, source)
// A database file missing now is created by lockScanDatabase below.
what := "database"
roots, err = realPaths(roots)
_, err = os.Stat(dbPath)
if errors.Is(err, fs.ErrNotExist) {
what = "new database"
}
fmt.Fprintf(os.Stderr, "scan: %s %s (%s)\n", what, escapePath(dbPath),
source)
roots, err = resolveRoots(roots)
if err != nil {
return err
}
var rules walkRules
rules.excludePaths, err = excludePathInodes(opts.excludePaths)
if err != nil {
return fmt.Errorf("--exclude: %w", err)
}
err = checkOperandsNotExcluded(roots, rules.excludePaths)
if err != nil {
return err
}
rules.nonPersistent, err = nonPersistentFilesystems()
nonPersistent, err := nonPersistentFilesystems()
if err != nil {
return fmt.Errorf("list the mounted filesystems: %w", err)
}
if !opts.noDefaultExcludes {
rules.defaultExcludes = defaultExcludes(runtime.GOOS)
rules := walkRules{nonPersistent: nonPersistent}
if !noDefaultExcludes {
rules.excludes = defaultExcludes(runtime.GOOS)
}
lock, err := lockScanDatabase(dbPath)
@@ -166,7 +163,7 @@ func runScan(ctx context.Context, roots []string, opts scanOptions) error {
return err
}
st, err := syncScan(ctx, db, roots, opts.workers, opts.oneFS, rules)
st, err := syncScan(ctx, db, roots, workers, oneFS, rules)
if errors.Is(err, context.Canceled) {
return interrupted(st.walked)
}
@@ -184,21 +181,6 @@ func runScan(ctx context.Context, roots []string, opts scanOptions) error {
return nil
}
// printDatabasePath prints on stderr the line that names the database
// at dbPath, with source, saying whether it is new. A database file
// missing now is new: lockScanDatabase creates it.
func printDatabasePath(dbPath, source string) {
what := "database"
_, err := os.Stat(dbPath)
if errors.Is(err, fs.ErrNotExist) {
what = "new database"
}
fmt.Fprintf(os.Stderr, "scan: %s %s (%s)\n", what, escapePath(dbPath),
source)
}
// interruptContext returns a copy of ctx that the first SIGINT or
// SIGTERM cancels; the scan command runs the scan under it. stop
// releases the signals.
@@ -227,31 +209,29 @@ func interrupted(walked int) error {
return errInterrupted
}
// realPaths returns the real path of each of paths, the PATH operands
// or the --exclude values: absolute, with every symlink in it followed.
// Database records are keyed by that path, so scan results depend
// neither on the working directory nor on the symlinks an operand was
// given through. A path that 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 realPaths(paths []string) ([]string, error) {
resolved := make([]string, 0, len(paths))
// resolveRoots converts each PATH operand to an absolute, lexically
// cleaned path (symlinks are not resolved) and verifies that it
// exists. Database records are keyed by absolute path, so scan results
// must not depend on the working directory.
func resolveRoots(roots []string) ([]string, error) {
abs := make([]string, 0, len(roots))
for _, path := range paths {
a, err := absolutePath(path)
for _, root := range roots {
a, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", path, 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 {
return nil, fmt.Errorf("resolve %s: %w", path, 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:
@@ -314,7 +294,7 @@ func walkReaches(above, r string, oneFS bool, rules walkRules,
}
for ; dir != above; dir = filepath.Dir(dir) {
if isDefaultExclude(dir, rules.defaultExcludes) {
if isDefaultExclude(dir, rules.excludes) {
return false
}
@@ -437,16 +417,14 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
// walkableRoots returns the operands the walk can start from: regular
// files, and directories not named .zfs, none of them inside a
// directory named .zfs. The operands are real paths from realPaths,
// so these checks apply to what a symlink operand points at. Every
// other operand is warned about, counted as skipped, and dropped. A
// dropped operand is no longer a root, so the records stored beneath it
// are treated as if it had not been given: those under no other root
// count as outside the roots and are not deleted as unverified. An
// operand on a filesystem in rules that is not persistent is dropped
// the same way, with a line saying so that is not a warning, and is not
// counted as skipped. An operand that fails lstat here is kept, and the
// walk warns about it unless it no longer exists.
// directory named .zfs. Every other operand is warned about, counted as
// skipped, and dropped. A dropped operand is no longer a root, so the
// records stored beneath it are treated as if it had not been given:
// those under no other root count as outside the roots and are not
// deleted as unverified. An operand on a filesystem in rules that is not
// persistent is dropped the same way, with a line saying so that is not
// a warning, and is not 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 {
kept := make([]string, 0, len(roots))
@@ -512,11 +490,10 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// as verified. When its disk_size differs from the record's, it is
// queued for the update phase, which writes the new disk_size. A record
// at or below a path the walk left out, because -x stopped at it, it is
// an --exclude path or a default exclude, or it is not on a persistent
// filesystem, counts as outside the roots: it is not deleted. That does
// not hold below a root at or inside that path, since that root's own
// walk verifies the records below it; nor is a line printed for such a
// root.
// a default exclude, or it is not on a persistent filesystem, counts as
// outside the roots: it is not deleted. That does not hold below a root
// at or inside that path, since that root's own walk verifies the
// records below it; nor is a line printed for such a root.
func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
// The records the last scan left under the roots estimate how many
// files the walk will find. A first scan has none, and no estimate.
@@ -562,11 +539,10 @@ func (s *scanState) walkPhase(events <-chan walkEvent, roots []string) {
s.st.walked++
prog.setPath(ev.rec.path)
prog.increment()
old, ok := s.existing[ev.rec.path]
if !ok || old.size != ev.rec.size || !ev.rec.mtime.Equal(old.mtime) {
if !ok || old.size != ev.rec.size || mtimeAfter(ev.rec.mtime, old.mtime) {
s.toHash = append(s.toHash, ev.rec)
continue
@@ -801,6 +777,16 @@ func (s *scanState) updatePhase(ctx context.Context) error {
return applyChanges(ctx, s.db, nil, deletes, prog)
}
// mtimeAfter reports whether mtime a is later than mtime b.
// Not a.After(b): time.Time wraps an mtime past year 292 billion; Unix() undoes it.
func mtimeAfter(a, b time.Time) bool {
if a.Unix() != b.Unix() {
return a.Unix() > b.Unix()
}
return a.Nanosecond() > b.Nanosecond()
}
// underAnyRoot reports whether path is any of the roots or lies under
// one of them.
func underAnyRoot(path string, roots []string) bool {
@@ -855,9 +841,9 @@ type dirJob struct {
// file's statted record, a warning when fail is set, or a path the walk
// left out: when otherFSDir is set, a directory -x did not enter
// because it is on another filesystem than its operand, and when
// leftOut is set, an --exclude path, a default exclude or a directory or
// file on a filesystem that is not persistent, with warn holding the
// line that says so.
// leftOut is set, a default exclude or a directory or file on a
// filesystem that is not persistent, with warn holding the line that
// says so.
type walkEvent struct {
rec fileRec
warn string
@@ -908,13 +894,19 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// 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: a regular file, or a directory not named .zfs, that is not
// inside a directory named .zfs. realPaths has already followed the
// operand's symlinks, so a symlink here is an operand replaced by one
// after it was resolved, and is not followed.
// inside a directory named .zfs, neither as given nor with its symlinks
// resolved. Symlinks are never followed, including as operands.
func operandWarning(root string, fi fs.FileInfo) string {
// The walk never enters a .zfs directory, so it starts from nothing
// inside one either.
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") {
// inside one either, not even through a symlink. An operand whose
// 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",
root)
}
@@ -1139,9 +1131,8 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
// no longer exists, and one that has stopped being a regular file is
// skipped. A file on a filesystem in rules that is not persistent, as
// through a bind mount of one file, is not recorded: it gets an event
// naming it instead, and so does a file given with --exclude, reached
// by any path, a hard link to it included. One of the scan's own
// database files in rules gets no event at all.
// naming it instead. One of the scan's own database files in rules gets
// no event at all.
func emitFile(ctx context.Context, p string, e fs.DirEntry,
rules walkRules, events chan<- walkEvent,
) {
@@ -1165,10 +1156,6 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
}
line := rules.nonPersistentLine(p, info)
if line == "" {
line = rules.excludePathLine(p, info)
}
if line != "" {
sendEvent(ctx, events, walkEvent{leftOut: p, warn: line})
@@ -1193,11 +1180,11 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
// subdirJob applies the descent rules to directory p: never enter
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
// snapshot), never enter a directory on a filesystem in rules that is
// not persistent, nor an --exclude path or a default exclude in rules,
// and with -x never enter a directory on a different filesystem than
// its operand. For all but .zfs it sends an event that names the
// directory, so the scan keeps the records below it, except those below
// an operand at or inside it.
// not persistent, nor a default exclude in rules, and with -x never
// enter a directory on a different filesystem than its operand. For the
// last three it sends an event that names the directory, so the scan
// keeps the records below it, except those below an operand at or
// inside it.
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
parent dirJob, oneFS bool, rules walkRules,
events chan<- walkEvent,
@@ -1222,17 +1209,13 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
}
line := rules.nonPersistentLine(p, info)
if line == "" {
line = rules.excludePathLine(p, info)
}
if line != "" {
sendEvent(ctx, events, walkEvent{leftOut: p, warn: line})
return dirJob{}, false
}
if isDefaultExclude(p, rules.defaultExcludes) {
if isDefaultExclude(p, rules.excludes) {
sendEvent(ctx, events, walkEvent{
leftOut: p,
warn: fmt.Sprintf("scan: skipped %s (default exclude)", p),
@@ -1,6 +1,6 @@
//go:build darwin
package sfdupes
package main
import "syscall"
@@ -1,6 +1,6 @@
//go:build !darwin
package sfdupes
package main
import "syscall"
+47 -159
View File
@@ -1,4 +1,4 @@
package sfdupes //nolint:testpackage // drives the unexported syncTree
package main
import (
"bytes"
@@ -1094,7 +1094,7 @@ func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
func buildSmokeTree(t *testing.T) string {
t.Helper()
dir := realTempDir(t)
dir := t.TempDir()
one := pattern(10, 2000)
f1 := pattern(30, 3000)
f2 := pattern(40, 100)
@@ -1185,12 +1185,11 @@ func assertSmokeDupeGroups(t *testing.T, dir string, db *sql.DB) {
t.Helper()
groups := dupeGroups(t, db)
if len(groups) != 4 {
t.Fatalf("len(groups) = %d, want 4", len(groups))
if len(groups) != 5 {
t.Fatalf("len(groups) = %d, want 5", len(groups))
}
// The empty files empty1 and empty2 form no group.
wantSizes := []int64{3000, 2000, 100, 1}
wantSizes := []int64{3000, 2000, 100, 1, 0}
for i, g := range groups {
if g.size != wantSizes[i] {
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)
tg := collectTreeGroups(dirs, super, everyPathExists, nil)
tg := collectTreeGroups(dirs, super, nil)
if len(tg) != 1 {
t.Fatalf("len(tree groups) = %d, want 1", len(tg))
}
@@ -1384,47 +1383,6 @@ func TestSyncScanMtimeBump(t *testing.T) {
}
}
// TestSyncScanMtimeMovedBack replaces a file with other content of the
// same size and an older mtime, as cp -p or a restore from backup does.
// The next scan must hash the file again.
func TestSyncScanMtimeMovedBack(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
a := writeFile(t, dir, "a.bin", pattern(1, 500))
recorded := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
err := os.Chtimes(a, recorded, recorded)
if err != nil {
t.Fatal(err)
}
syncTree(t, db, dir)
replaced := pattern(3, 500)
writeFile(t, dir, "a.bin", replaced)
older := recorded.Add(-time.Hour)
err = os.Chtimes(a, older, older)
if err != nil {
t.Fatal(err)
}
st := syncTree(t, db, dir)
if st != (scanStats{walked: 1, updated: 1}) {
t.Fatalf("rescan stats = %+v, want 1 updated", st)
}
assertWholeFileHashed(t, db, a, replaced)
if r := recordByPath(t, dbRecords(t, db), a); !r.mtime.Equal(older) {
t.Fatalf("mtime = %v, want %v", r.mtime, older)
}
}
// assertWholeFileHashed fails unless the record for path holds the
// whole-file hash of data as its head, tail, and content.
func assertWholeFileHashed(t *testing.T, db *sql.DB, path string,
@@ -2196,7 +2154,7 @@ func TestScanHashWriteFailureUnwindsPool(t *testing.T) {
var stderr bytes.Buffer
code := run([]string{
cmdScan, workersFlag, strconv.Itoa(hashLeakWorkers), dir,
cmdScan, "--workers", strconv.Itoa(hashLeakWorkers), dir,
}, io.Discard, &stderr)
if code != exitFatal {
t.Fatalf("run(scan) = %d, want %d; stderr: %s",
@@ -2289,7 +2247,7 @@ func TestPruneRoots(t *testing.T) {
// The walk of /p does not enter /p/q when it is a default exclude, so
// /p/q is kept, without -x too; /p/q/r lies under it and is dropped.
got = pruneRoots(nested, false, walkRules{defaultExcludes: []string{pqRoot}},
got = pruneRoots(nested, false, walkRules{excludes: []string{pqRoot}},
device)
if !slices.Equal(got, want) {
t.Fatalf("pruneRoots excluding %s = %q, want %q", pqRoot, got, want)
@@ -2307,86 +2265,23 @@ func TestPruneRoots(t *testing.T) {
}
}
// TestRunReportsLeaveOutRemovedCopies scans the smoke tree, then
// 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) {
func TestReportsNeverTouchFilesystem(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := writeFile(t, dir, "file", nil)
locked := writeFile(t, dir, "locked/file", nil)
dir := buildSmokeTree(t)
db := openTestDB(t)
// Without search permission on its directory, a file cannot be
// looked at by anyone but root.
err := os.Chmod(filepath.Dir(locked), 0)
syncTree(t, db, dir)
// Remove the scanned tree entirely; the analysis must be
// unaffected because it reads the database alone.
err := os.RemoveAll(dir)
if err != nil {
t.Fatal(err)
}
// Runs before t.TempDir's own cleanup, which must delete the files.
t.Cleanup(func() {
//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)
}
}
assertSmokeDupeGroups(t, dir, db)
assertSmokeTreeGroups(t, dir, db)
}
func TestUnderRoot(t *testing.T) {
@@ -2415,24 +2310,33 @@ func TestUnderRoot(t *testing.T) {
}
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own
// database, and in one case also a hard link to the database file. The
// walk knows the database's files by device ID and inode number, so it
// passes over the database file, its -wal and -shm files, its .lock
// file and the hard link with no line and no count. The record an
// earlier scan left for the .lock file is deleted. A rescan of the
// unchanged tree updates nothing, and report lists none of the
// database's files.
// database, reached directly and through a symlinked directory. The
// walk passes over the database file, its -wal and -shm files and its
// .lock file with no line and no count, and the record an earlier scan
// left for the .lock file is deleted. A rescan of the unchanged tree
// updates nothing, and report lists none of the database's files.
func TestRunScanPassesOverDatabase(t *testing.T) {
for _, viaHardLink := range []bool{false, true} {
t.Run(fmt.Sprintf("via hard link %v", viaHardLink), func(t *testing.T) {
tree := realTempDir(t)
hardLink := filepath.Join(tree, "hardlink")
for _, viaSymlink := range []bool{false, true} {
t.Run(fmt.Sprintf("via symlink %v", viaSymlink), func(t *testing.T) {
base := t.TempDir()
tree := filepath.Join(base, "real", "tree")
operand := tree
writeFile(t, tree, "a", pattern(1, 100))
writeFile(t, tree, "b", pattern(1, 100))
writeFile(t, tree, "empty1", 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")
t.Setenv(databaseEnv, dbPath)
@@ -2442,7 +2346,7 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
}
err = applyChanges(t.Context(), db, []scanRec{
{path: filepath.Join(tree, "db.sqlite.lock")},
{path: filepath.Join(operand, "db.sqlite.lock")},
}, nil, nil)
if err != nil {
t.Fatal(err)
@@ -2450,30 +2354,17 @@ func TestRunScanPassesOverDatabase(t *testing.T) {
closeScanDatabase(t.Context(), db, dbPath)
if viaHardLink {
err = os.Link(dbPath, hardLink)
if err != nil {
t.Fatal(err)
}
}
stderr := assertScanSummary(t,
assertScanSummary(t,
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
tree)
stderr += assertScanSummary(t,
operand)
assertScanSummary(t,
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
tree)
if strings.Contains(stderr, hardLink) {
t.Errorf("scan stderr = %q, want no line naming %s",
stderr, hardLink)
}
operand)
report := runStdout(t, cmdReport)
if strings.Contains(report, "db.sqlite") ||
strings.Contains(report, hardLink) ||
!strings.Contains(report, filepath.Join(tree, "b")) {
t.Errorf("report = %q, want a and b and none of "+
!strings.Contains(report, filepath.Join(operand, "empty2")) {
t.Errorf("report = %q, want the empty files and none of "+
"the database's files", report)
}
})
@@ -2497,9 +2388,8 @@ func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
// assertScanSummary scans operands and fails the test unless the
// 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
// everything the scan printed to stderr.
func assertScanSummary(t *testing.T, counts string, operands ...string) string {
// the database is the only one that names one of its files.
func assertScanSummary(t *testing.T, counts string, operands ...string) {
t.Helper()
stderr := scanOK(t, operands...)
@@ -2513,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 "+
"database to name one of its files", operands, stderr)
}
return stderr
}
+34 -112
View File
@@ -1,4 +1,4 @@
package sfdupes
package main
import (
"context"
@@ -37,14 +37,11 @@ func treesPhase(name string) string {
// runTrees implements the trees subcommand: it reads every record from
// the database in path order, reconstructs the directory hierarchy from
// the paths of the records at or below the operands in args (of every
// record when args is empty), computes a Merkle-style digest per
// directory, and prints maximal duplicate-tree groups as TSV on stdout.
// Of the scanned filesystem it only checks, with pathExists, that the
// root of each duplicate tree is still there, never the files below it;
// it never looks up an operand. It names the database on stderr before
// it opens it. Any database problem, including a missing database, is
// fatal.
// the record paths, computes a Merkle-style digest per directory, and
// prints maximal duplicate-tree groups as TSV on stdout. It never
// touches the scanned filesystem; its only I/O is the database, stdout,
// and stderr. It names the database on stderr before it opens it. Any
// database problem, including a missing database, is fatal.
//
// 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
@@ -52,12 +49,7 @@ func treesPhase(name string) string {
// hierarchy), group (group the directories by digest) and write (write
// the groups to stdout). Each display ends before the next phase
// starts, and before an error is returned to be printed.
func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
operands, err := cleanOperands(args)
if err != nil {
return err
}
func runTrees(ctx context.Context, stdout io.Writer) error {
dbPath, source, err := databasePath()
if err != nil {
return err
@@ -86,7 +78,7 @@ func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
label: treesPhase("read"), unit: "records", total: -1,
waitingFor: countingRecords, stalledOn: "db",
})
records, super, allDirs, err := readTree(ctx, db, operands, prog)
records, super, allDirs, err := readTree(ctx, db, prog)
prog.finish()
@@ -94,13 +86,13 @@ func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
return fmt.Errorf("database %s: %w", dbPath, err)
}
// Grouping works in memory, then checks the root of each duplicate
// tree on disk, so it waits on the processor or the disk.
// Grouping works in memory, so it waits on nothing but the
// processor.
prog = newProgress(progressParams{
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()
@@ -108,7 +100,7 @@ func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
stalledOn: "out",
})
dupeTrees, err := writeTreeGroups(stdout, dupes, prog)
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog)
prog.finish()
@@ -119,23 +111,16 @@ func runTrees(ctx context.Context, args []string, stdout io.Writer) error {
fmt.Fprintf(os.Stderr,
"trees: %d records read, %d duplicate tree groups, %d dupe trees, "+
"%s reclaimable\n",
records, len(dupes), dupeTrees, humanBytes(reclaimableBytes(dupes)))
records, len(dupes), dupeTrees, humanBytes(reclaimable))
return nil
}
// readTree gives prog the number of records in db as its total, then
// reads every record in path order, counting each on prog. It adds the
// records that underOperand finds under operands to the directory
// hierarchy. It returns the number of records read, the super-root and
// every directory at or below one of operands.
//
// Any other directory lies above an operand and can hold files that are
// not used, so its digest does not describe it. It is left out of the
// directories returned. A directory whose parent is left out gets the
// super-root as its parent, so suppressed never judges its group by
// that parent's digest.
func readTree(ctx context.Context, db *sql.DB, operands []string,
// reads every record in path order into the directory hierarchy,
// counting each on prog. It returns the number of records read, the
// super-root and every directory.
func readTree(ctx context.Context, db *sql.DB,
prog *progress,
) (int, *treeNode, []*treeNode, error) {
records := 0
@@ -145,10 +130,7 @@ func readTree(ctx context.Context, db *sql.DB, operands []string,
records++
prog.increment()
if underOperand(r.path, operands) {
tree.add(r)
}
tree.add(r)
})
if err != nil {
return 0, nil, nil, err
@@ -156,40 +138,29 @@ func readTree(ctx context.Context, db *sql.DB, operands []string,
super, allDirs := tree.finish()
dirs := allDirs[:0]
for _, d := range allDirs {
if !underOperand(d.path, operands) {
continue
}
if !underOperand(d.parent.path, operands) {
d.parent = super
}
dirs = append(dirs, d)
}
return records, super, dirs, nil
return records, super, allDirs, nil
}
// writeTreeGroups writes dupes to stdout as TSV, a header line and then
// a row for every member of each group but its first, counting each
// group on prog once its rows are written. The disk_size column is the
// dupe's own. stdout may be the terminal prog is drawn on, so the lines
// go through prog's writer. It returns the number of rows (dupe trees).
// go through prog's writer. It returns the number of rows (dupe trees)
// and the bytes they could reclaim.
func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
prog *progress,
) (int, error) {
) (int, int64, error) {
out := prog.writer(stdout)
_, err := fmt.Fprintln(out, "first\tdupe\tfiles\tsize\tdisk_size")
if err != nil {
return 0, fmt.Errorf("write stdout: %w", err)
return 0, 0, fmt.Errorf("write stdout: %w", err)
}
dupeTrees := 0
var reclaimable int64
for _, g := range dupes {
first := g[0]
for _, n := range g[1:] {
@@ -197,10 +168,11 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
escapePath(first.path), escapePath(n.path),
first.fileCount, first.totalSize, n.diskSize)
if err != nil {
return 0, fmt.Errorf("write stdout: %w", err)
return 0, 0, fmt.Errorf("write stdout: %w", err)
}
dupeTrees++
reclaimable += first.totalSize
}
prog.increment()
@@ -208,44 +180,10 @@ func writeTreeGroups(stdout io.Writer, dupes [][]*treeNode,
err = out.Flush()
if err != nil {
return 0, fmt.Errorf("write stdout: %w", err)
return 0, 0, fmt.Errorf("write stdout: %w", err)
}
return dupeTrees, nil
}
// reclaimableBytes returns the sum of the size column over the rows
// that writeTreeGroups writes for dupes, leaving out a row whose dupe
// lies inside another row's dupe.
func reclaimableBytes(dupes [][]*treeNode) int64 {
isDupe := make(map[*treeNode]bool)
for _, g := range dupes {
for _, n := range g[1:] {
isDupe[n] = true
}
}
var total int64
for n := range isDupe {
if !insideDupe(n, isDupe) {
total += n.totalSize
}
}
return total
}
// insideDupe reports whether a directory above n is in isDupe.
func insideDupe(n *treeNode, isDupe map[*treeNode]bool) bool {
for p := n.parent; p != nil; p = p.parent {
if isDupe[p] {
return true
}
}
return false
return dupeTrees, reclaimable, nil
}
// treeBuilder reconstructs the directory hierarchy from records added
@@ -362,14 +300,10 @@ func fileEntry(name string, r scanRec) string {
// collectTreeGroups groups directories by digest, counting each one on
// prog as it is grouped, and returns every maximal group with two or
// more members for which exists returns true, each group's members
// sorted by path, groups ordered by tree size descending then by first
// 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.
// more members, each group's members sorted by path, groups ordered by
// tree size descending then by first path ascending.
func collectTreeGroups(allDirs []*treeNode, super *treeNode,
exists func(path string) bool, prog *progress,
prog *progress,
) [][]*treeNode {
groups := make(map[[sha256.Size]byte][]*treeNode)
for _, d := range allDirs {
@@ -381,19 +315,7 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
var dupes [][]*treeNode
for _, g := range groups {
// The members share a digest, so they share a total size.
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 {
if len(g) < minGroupSize || suppressed(g, super) {
continue
}
+17 -259
View File
@@ -1,12 +1,10 @@
package sfdupes //nolint:testpackage // checks the unexported treeNode results
package main
import (
"bytes"
"crypto/sha256"
"database/sql"
"os"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -180,7 +178,7 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
t.Fatalf("directory paths = %q, want %q", got, want)
}
groups := collectTreeGroups(dirs, super, everyPathExists, nil)
groups := collectTreeGroups(dirs, super, nil)
gotGroups := groupPaths(groups)
wantGroups := [][]string{{"/a", "/c"}}
@@ -196,8 +194,7 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
}
func TestRunTreesEscapesPaths(t *testing.T) {
dir, recs := recsOnDisk(t, awkwardPairRecs())
t.Setenv(databaseEnv, seedDatabase(t, recs))
t.Setenv(databaseEnv, seedDatabase(t, awkwardPairRecs()))
var stdout, stderr bytes.Buffer
@@ -208,63 +205,12 @@ func TestRunTreesEscapesPaths(t *testing.T) {
}
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 {
t.Errorf("stdout = %q, want %q", got, want)
}
}
// TestRunTreesOperands runs trees with operands on records in a new
// directory. x/a and y/a are copies, and so are y/a and srv/b. x and y
// also hold files that differ. Given x/a and y/a, trees reads x and y
// as holding one copy each of a, yet neither is reported: only the
// directories at or below an operand are.
func TestRunTreesOperands(t *testing.T) {
f, g, k := testHash("f"), testHash("g"), testHash("k")
dir, recs := recsOnDisk(t, []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/srv/b/f"},
{size: 10, head: f, tail: f, content: f, path: "/x/a/f"},
{size: 20, head: g, tail: g, content: g, path: "/x/g"},
{size: 10, head: f, tail: f, content: f, path: "/y/a/f"},
{size: 30, head: k, tail: k, content: k, path: "/y/k"},
})
b, xa, ya := dir+"/srv/b", dir+"/x/a", dir+"/y/a"
header := "first\tdupe\tfiles\tsize\tdisk_size\n"
row := func(first, dupe string) string {
return first + "\t" + dupe + "\t1\t10\t0\n"
}
cases := map[string]struct {
operands []string
want string
}{
"none": {nil, header + row(b, xa) + row(b, ya)},
"two copies": {[]string{xa, ya}, header + row(xa, ya)},
"parents": {[]string{dir + "/x", dir + "/y"}, header + row(xa, ya)},
"one copy": {[]string{b}, header},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Setenv(databaseEnv, seedDatabase(t, recs))
var stdout, stderr bytes.Buffer
code := run(append([]string{cmdTrees}, tc.operands...),
&stdout, &stderr)
if code != exitOK {
t.Fatalf("run(trees %q) = %d, want %d; stderr: %s",
tc.operands, code, exitOK, stderr.String())
}
if got := stdout.String(); got != tc.want {
t.Errorf("stdout = %q, want %q", got, tc.want)
}
})
}
}
// TestRunTreesDiskSize runs trees on two copies of a tree whose files
// take different space on disk. The row's disk_size is the sum over the
// dupe copy's files, those in its subdirectory included.
@@ -277,11 +223,9 @@ func TestRunTreesDiskSize(t *testing.T) {
{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))
want := "first\tdupe\tfiles\tsize\tdisk_size\n" +
dir + "/a\t" + dir + "/b\t2\t30\t1536\n"
want := "first\tdupe\tfiles\tsize\tdisk_size\n/a\t/b\t2\t30\t1536\n"
if got := runStdout(t, cmdTrees); got != want {
t.Errorf("stdout = %q, want %q", got, want)
}
@@ -292,11 +236,10 @@ func TestRunTreesDiskSize(t *testing.T) {
// 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
// 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.
func TestRunTreesProgressNamesEachPhase(t *testing.T) {
dir, recs := recsOnDisk(t, smokeTreeRecs())
path := seedDatabase(t, recs)
path := seedDatabase(t, smokeTreeRecs())
t.Setenv(databaseEnv, path)
stderr := captureStderr(t)
@@ -310,15 +253,11 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
}
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 {
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.
want := []string{
"trees: database " + path + " (from " + databaseEnv + ")",
@@ -326,9 +265,8 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
treesOpenLine,
"[2/4] read: counting the records, elapsed 0s, remain unknown",
"[2/4] read: [6/6] 100%, ",
"[3/4] group: [0/" + dirs + "] 0%, 0 directories/s, elapsed 0s, " +
"remain unknown",
"[3/4] group: [" + dirs + "/" + dirs + "] 100%, ",
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown",
"[3/4] group: [8/8] 100%, ",
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown",
"[4/4] write: [1/1] 100%, ",
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
@@ -357,14 +295,14 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
func TestWriteTreeGroupsOnTerminal(t *testing.T) {
stderr := captureStderr(t)
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.
p := startProgress(progressParams{
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
}, terminalWidth, time.Hour, time.Now)
_, err := writeTreeGroups(os.Stderr, dupes, p)
_, _, err := writeTreeGroups(os.Stderr, dupes, p)
p.finish()
@@ -438,7 +376,7 @@ func TestCollectTreeGroupsMaximal(t *testing.T) {
super, dirs := treeOf(t, smokeTreeRecs())
groups := collectTreeGroups(dirs, super, everyPathExists, nil)
groups := collectTreeGroups(dirs, super, nil)
want := [][]string{{"/d/t1", "/d/t2"}}
if got := groupPaths(groups); !slices.EqualFunc(got, want,
@@ -462,16 +400,14 @@ func TestCollectTreeGroupsDeterministic(t *testing.T) {
super, dirs := treeOf(t, recs)
forward := groupPaths(collectTreeGroups(dirs, super, everyPathExists,
nil))
forward := groupPaths(collectTreeGroups(dirs, super, nil))
reversed := slices.Clone(recs)
slices.Reverse(reversed)
superR, dirsR := treeOf(t, reversed)
backward := groupPaths(collectTreeGroups(dirsR, superR, everyPathExists,
nil))
backward := groupPaths(collectTreeGroups(dirsR, superR, nil))
if !slices.EqualFunc(forward, backward, slices.Equal) {
t.Fatalf("output depends on record order: %v vs %v",
forward, backward)
@@ -491,36 +427,7 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
super, dirs := treeOf(t, recs)
got := groupPaths(collectTreeGroups(dirs, super, everyPathExists, 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))
got := groupPaths(collectTreeGroups(dirs, super, nil))
want := [][]string{{"/p/x1", "/p/x2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
@@ -543,159 +450,10 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
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"}}
if !slices.EqualFunc(got, want, slices.Equal) {
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.
func TestCollectTreeGroupsChecksOnlyReportedCopies(t *testing.T) {
t.Parallel()
f, g, u := testHash("f"), testHash("g"), testHash("u")
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: 10, head: f, tail: f, content: f, path: "/b/proj/f"},
{size: 20, head: g, tail: g, content: g, path: "/b/proj/sub/g"},
// /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])
}
}
// TestCollectTreeGroupsDropsGroupLeftWithOneCopy checks that a group of
// two copies, one of them gone, is not returned. If kept, it would
// write no row, but the summary line would count it.
func TestCollectTreeGroupsDropsGroupLeftWithOneCopy(t *testing.T) {
t.Parallel()
f := testHash("f")
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/c/kept/f"},
{size: 10, head: f, tail: f, content: f, path: "/c/gone/f"},
}
super, dirs := treeOf(t, recs)
exists := func(path string) bool { return path != "/c/gone" }
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
if len(got) != 0 {
t.Errorf("groups = %v, want none", got)
}
}
// TestCollectTreeGroupsKeepsGroupLeftWithTwoCopies checks that a group
// of three copies, one of them gone, is returned with the other two.
func TestCollectTreeGroupsKeepsGroupLeftWithTwoCopies(t *testing.T) {
t.Parallel()
f := testHash("f")
recs := []scanRec{
{size: 10, head: f, tail: f, content: f, path: "/d/kept1/f"},
{size: 10, head: f, tail: f, content: f, path: "/d/gone/f"},
{size: 10, head: f, tail: f, content: f, path: "/d/kept2/f"},
}
super, dirs := treeOf(t, recs)
exists := func(path string) bool { return path != "/d/gone" }
got := groupPaths(collectTreeGroups(dirs, super, exists, nil))
want := [][]string{{"/d/kept1", "/d/kept2"}}
if !slices.EqualFunc(got, want, slices.Equal) {
t.Errorf("groups = %v, want %v", got, want)
}
}
// TestReclaimableBytesCountsNestedDupeOnce checks that a dupe tree
// inside another dupe tree adds nothing to the reclaimable bytes. /B/x
// lies in /B, so the total adds the sizes of /B and /C/x, 1500 + 1000.
func TestReclaimableBytesCountsNestedDupeOnce(t *testing.T) {
t.Parallel()
fh, ft, fc := testHash("fh"), testHash("ft"), testHash("fc")
gh, gt, gc := testHash("gh"), testHash("gt"), testHash("gc")
recs := []scanRec{
{size: 1000, head: fh, tail: ft, content: fc, path: "/A/x/f"},
{size: 500, head: gh, tail: gt, content: gc, path: "/A/y/g"},
{size: 1000, head: fh, tail: ft, content: fc, path: "/B/x/f"},
{size: 500, head: gh, tail: gt, content: gc, path: "/B/y/g"},
{size: 1000, head: fh, tail: ft, content: fc, path: "/C/x/f"},
}
super, dirs := treeOf(t, recs)
dupes := collectTreeGroups(dirs, super, everyPathExists, nil)
wantGroups := [][]string{{"/A", "/B"}, {"/A/x", "/B/x", "/C/x"}}
if got := groupPaths(dupes); !slices.EqualFunc(got, wantGroups,
slices.Equal) {
t.Fatalf("groups = %v, want %v", got, wantGroups)
}
if got := reclaimableBytes(dupes); got != 2500 {
t.Errorf("reclaimableBytes = %d, want 2500", got)
}
}
// TestReclaimableBytesCountsDupeInsideGrandparentDupeOnce checks that a
// dupe tree adds nothing when a dupe tree above its parent holds it.
// /B/m/x lies in /B, and /B/m is no dupe, since the group of /A and /B
// implies that of /A/m and /B/m. The total adds the sizes of /B and
// /C/x, 1300 + 1000.
func TestReclaimableBytesCountsDupeInsideGrandparentDupeOnce(t *testing.T) {
t.Parallel()
f, w := testHash("f"), testHash("w")
recs := []scanRec{
{size: 1000, head: f, tail: f, content: f, path: "/A/m/x/f"},
{size: 300, head: w, tail: w, content: w, path: "/A/m/w"},
{size: 1000, head: f, tail: f, content: f, path: "/B/m/x/f"},
{size: 300, head: w, tail: w, content: w, path: "/B/m/w"},
{size: 1000, head: f, tail: f, content: f, path: "/C/x/f"},
}
super, dirs := treeOf(t, recs)
dupes := collectTreeGroups(dirs, super, everyPathExists, nil)
wantGroups := [][]string{{"/A", "/B"}, {"/A/m/x", "/B/m/x", "/C/x"}}
if got := groupPaths(dupes); !slices.EqualFunc(got, wantGroups,
slices.Equal) {
t.Fatalf("groups = %v, want %v", got, wantGroups)
}
if got := reclaimableBytes(dupes); got != 2300 {
t.Errorf("reclaimableBytes = %d, want 2300", got)
}
}