Compare commits
6
Commits
69dbbfc1d7
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ddb58ae1 | ||
|
|
6346319701 | ||
|
|
4e4a94db2b | ||
|
|
dcfe0ea080 | ||
|
|
003e184f71 | ||
|
|
68f6bde5ee |
+20
-12
@@ -1,16 +1,23 @@
|
|||||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||||
# on every build, which reruns every RUN step after it. The steps that
|
# system packages and download Go's modules, or right after FROM in a
|
||||||
# compile mount Go's build cache, which survives from build to build.
|
# stage with neither, and every step that lints, tests or compiles
|
||||||
|
# comes after it. script/ passes a new value on every build, which
|
||||||
|
# reruns every RUN step after it. Docker keeps the layers above it
|
||||||
|
# until the base image, their own lines or go.mod and go.sum change.
|
||||||
|
# The steps that compile mount Go's build cache, and golangci-lint run
|
||||||
|
# also mounts golangci-lint's; both survive from build to build. The
|
||||||
|
# test phase runs as nobody and so mounts Go's build cache at
|
||||||
|
# /tmp/.cache/go-build; its own comment says why.
|
||||||
|
|
||||||
# Lint phase, built alone by script/lint. The tools are invoked directly
|
# Lint phase, built alone by script/lint. The tools are invoked directly
|
||||||
# rather than through `make lint`, which runs docker itself and so cannot
|
# rather than through `make lint`, which runs docker itself and so cannot
|
||||||
# run inside a build step.
|
# run inside a build step.
|
||||||
# golangci/golangci-lint:v2.14.0, 2026-10-07
|
# golangci/golangci-lint:v2.14.0, 2026-10-07
|
||||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
ARG CACHEBUST
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The gofmt half of `make fmt-check`. gofmt's output is assigned to a
|
# The gofmt half of `make fmt-check`. gofmt's output is assigned to a
|
||||||
@@ -24,6 +31,7 @@ RUN files="$(gofmt -s -l .)" && \
|
|||||||
# Validates .golangci.yml against the schema the pinned binary embeds.
|
# Validates .golangci.yml against the schema the pinned binary embeds.
|
||||||
RUN golangci-lint config verify --config .golangci.yml
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase, built alone by script/test. -race needs cgo and so a C
|
# Test phase, built alone by script/test. -race needs cgo and so a C
|
||||||
@@ -38,12 +46,12 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
|
|||||||
# nobody (65534) so the tests can write to it.
|
# nobody (65534) so the tests can write to it.
|
||||||
# golang:1.25-trixie, 2026-10-04
|
# golang:1.25-trixie, 2026-10-04
|
||||||
FROM golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73 AS test
|
FROM golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73 AS test
|
||||||
ARG CACHEBUST
|
|
||||||
USER nobody
|
USER nobody
|
||||||
ENV HOME=/tmp
|
ENV HOME=/tmp
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/tmp/.cache/go-build,uid=65534,gid=65534 \
|
RUN --mount=type=cache,target=/tmp/.cache/go-build,uid=65534,gid=65534 \
|
||||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
@@ -51,19 +59,19 @@ RUN --mount=type=cache,target=/tmp/.cache/go-build,uid=65534,gid=65534 \
|
|||||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies are
|
# Build stage. Nothing is wanted from either phase above; the copies are
|
||||||
# what make BuildKit build them first, so this stage cannot run unless
|
# what make BuildKit build them first, so nothing after them can run
|
||||||
# lint and test passed.
|
# unless lint and test passed.
|
||||||
# golang:1.25-alpine, 2026-07-23
|
# golang:1.25-alpine, 2026-07-23
|
||||||
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
FROM golang@sha256:56961d79ea8129efddcc0b8643fd8a5416b4e6228cfd477e3fd61deb2672c587 AS builder
|
||||||
ARG CACHEBUST
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
COPY --from=test /src/go.sum /dev/null
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
|
||||||
RUN git config --system --add safe.directory /src
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The version stamped into the binary: the VERSION build argument when
|
# The version stamped into the binary: the VERSION build argument when
|
||||||
|
|||||||
@@ -49,15 +49,40 @@ results are as fresh as the last completed scan.
|
|||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
With Go installed, this builds and installs the current `main` branch:
|
With Go installed, this builds and installs the newest release:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install sneak.berlin/go/sfdupes@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Until 1.0, releases are `v0.0.N` tags on the tip of the `main` branch: each time
|
||||||
|
`main` moves and its tip passes `script/cibuild`, that tip is tagged with the
|
||||||
|
next `v0.0.N`. So `@latest` installs `main`'s tip once it has passed the build.
|
||||||
|
`@main` installs the tip before it is tagged:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go install sneak.berlin/go/sfdupes@main
|
go install sneak.berlin/go/sfdupes@main
|
||||||
```
|
```
|
||||||
|
|
||||||
The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set. A
|
`v0.0.1` is an early build without a database. It does not work as this README
|
||||||
binary installed this way reports its version as `dev`; one built from a clone
|
describes, and `go.mod` retracts it.
|
||||||
or into the Docker image carries the git tag or commit it was built from.
|
|
||||||
|
The binary goes to `$(go env GOPATH)/bin`, or to `$GOBIN` when that is set. To
|
||||||
|
see which version an installed binary is:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sfdupes --version
|
||||||
|
go version -m "$(command -v sfdupes)"
|
||||||
|
```
|
||||||
|
|
||||||
|
`sfdupes --version` prints `sfdupes VERSION`. For a binary from `go install`,
|
||||||
|
`VERSION` is the version Go recorded in it: the release tag, such as `v0.0.3`,
|
||||||
|
or for `@main` a pseudo-version ending in the first 12 characters of the
|
||||||
|
commit's hash. A binary built from a clone or into the Docker image carries the
|
||||||
|
git tag or commit it was built from instead, and `VERSION` is `dev` only when
|
||||||
|
the binary carries no version at all. `go version -m` prints what Go recorded in
|
||||||
|
the binary; its `mod` line names the module's version. Binaries from `v0.0.2`
|
||||||
|
and earlier print `dev`, so check those with `go version -m`.
|
||||||
|
|
||||||
From a clone, `make build` writes the binary to `./sfdupes`:
|
From a clone, `make build` writes the binary to `./sfdupes`:
|
||||||
|
|
||||||
@@ -100,9 +125,11 @@ As a file `/etc/cron.d/sfdupes`:
|
|||||||
`/var/log/sfdupes.log`, holds a first line naming the database (see
|
`/var/log/sfdupes.log`, holds a first line naming the database (see
|
||||||
"Database"), plain progress lines naming each phase and its place among the
|
"Database"), plain progress lines naming each phase and its place among the
|
||||||
five, as `[2/5] walk` (one as the phase starts, one every 5 seconds while it
|
five, as `[2/5] walk` (one as the phase starts, one every 5 seconds while it
|
||||||
runs, and one as it ends), a warning for each path it skips, and the summary
|
runs, and one as it ends), a warning for each path it skips, a line for each
|
||||||
line (see "Progress" and "`scan` mode"). The log grows with every scan; rotate
|
default exclude its walk does not enter and for each path it leaves out
|
||||||
it like any other.
|
because it is not on a persistent filesystem (see "Rules for the walk"), and
|
||||||
|
the summary line (see "Progress" and "`scan` mode"). The log grows with every
|
||||||
|
scan; rotate it like any other.
|
||||||
- Skipped paths do not fail a scan: it still exits 0, and cron sends nothing. A
|
- Skipped paths do not fail a scan: it still exits 0, and cron sends nothing. A
|
||||||
scan that fails, or is stopped by `SIGINT` or `SIGTERM`, exits 1 with the
|
scan that fails, or is stopped by `SIGINT` or `SIGTERM`, exits 1 with the
|
||||||
reason among the last lines of the log; `tail` prints them, and cron mails
|
reason among the last lines of the log; `tail` prints them, and cron mails
|
||||||
@@ -248,7 +275,7 @@ Four subcommands, all implemented:
|
|||||||
schema version and the counts of its records.
|
schema version and the counts of its records.
|
||||||
|
|
||||||
```
|
```
|
||||||
sfdupes scan [--workers N] [-x] PATH...
|
sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
|
||||||
sfdupes report > dupes.tsv
|
sfdupes report > dupes.tsv
|
||||||
sfdupes trees > dupetrees.tsv
|
sfdupes trees > dupetrees.tsv
|
||||||
sfdupes info
|
sfdupes info
|
||||||
@@ -257,12 +284,13 @@ sfdupes [command] --help
|
|||||||
```
|
```
|
||||||
|
|
||||||
`--workers N` sets the size of each `scan` worker pool (default: the number of
|
`--workers N` sets the size of each `scan` worker pool (default: the number of
|
||||||
CPUs), and `-x` (`--one-file-system`) keeps the walk of each operand on that
|
CPUs), `-x` (`--one-file-system`) keeps the walk of each operand on that
|
||||||
operand's filesystem; both are described under "`scan` mode".
|
operand's filesystem, and `--no-default-excludes` lets the walk enter the
|
||||||
`sfdupes --version` (or `-v`) prints one line, `sfdupes VERSION`, to stdout and
|
default excludes, a list of paths; all three are described under "`scan` mode".
|
||||||
exits 0, writing nothing to stderr. `-h` or `--help`, alone or after a
|
`sfdupes --version` (or `-v`) prints one line, `sfdupes VERSION` (see
|
||||||
subcommand, prints the help text to stderr and exits 0, writing nothing to
|
"Install"), to stdout and exits 0, writing nothing to stderr. `-h` or `--help`,
|
||||||
stdout.
|
alone or after a subcommand, prints the help text to stderr and exits 0, writing
|
||||||
|
nothing to stdout.
|
||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
@@ -439,11 +467,12 @@ results do not depend on the working directory. All operands belong to a single
|
|||||||
scan and are enumerated concurrently: every operand seeds the shared walk worker
|
scan and are enumerated concurrently: every operand seeds the shared walk worker
|
||||||
pool. Overlapping operands are harmless — an operand that duplicates another or
|
pool. Overlapping operands are harmless — an operand that duplicates another or
|
||||||
lies under another is dropped before walking, so every file is reached exactly
|
lies under another is dropped before walking, so every file is reached exactly
|
||||||
once and produces one database record. With `-x`, though, an operand under
|
once and produces one database record. An operand under another one is kept,
|
||||||
another one is kept and walked on its own when any directory from it (from its
|
though, and walked on its own when any directory from it (from its directory,
|
||||||
directory, for a regular file) up to but not including the operand covering it
|
for a regular file) up to but not including the operand covering it is a default
|
||||||
is on a different filesystem than that operand, because that operand's walk does
|
exclude, is not on a persistent filesystem (see "Rules for the walk"), or, with
|
||||||
not enter such a directory and so does not reach it.
|
`-x`, is on a different filesystem than that operand, because that operand's
|
||||||
|
walk does not enter such a directory and so does not reach it.
|
||||||
|
|
||||||
An operand that is a symlink (never followed, not even as an operand), socket,
|
An operand that is a symlink (never followed, not even as an operand), socket,
|
||||||
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
||||||
@@ -453,7 +482,10 @@ beneath it is still scanned. The records stored beneath it are not deleted: they
|
|||||||
are treated like any other record outside the scanned operands, including the
|
are treated like any other record outside the scanned operands, including the
|
||||||
content-phase exception below. If it lies under another operand, they are under
|
content-phase exception below. If it lies under another operand, they are under
|
||||||
that operand instead, and are treated like any other record there. This is not
|
that operand instead, and are treated like any other record there. This is not
|
||||||
an error: a scan whose every operand is dropped walks nothing and exits 0.
|
an error: a scan whose every operand is dropped walks nothing and exits 0. An
|
||||||
|
operand that is not on a persistent filesystem (see "Rules for the walk") is
|
||||||
|
dropped the same way, except that the line `scan` prints for it is not a
|
||||||
|
warning, and it is not counted as skipped.
|
||||||
|
|
||||||
`scan` synchronizes the database with the filesystem state under the scanned
|
`scan` synchronizes the database with the filesystem state under the scanned
|
||||||
operands:
|
operands:
|
||||||
@@ -489,14 +521,16 @@ operands:
|
|||||||
can be scanned on different schedules into the same database. The one
|
can be scanned on different schedules into the same database. The one
|
||||||
exception is the content phase below: a stored file of 10 MiB or more without
|
exception is the content phase below: a stored file of 10 MiB or more without
|
||||||
a `content` hash is read for one, wherever it lies, once its size, `head`, and
|
a `content` hash is read for one, wherever it lies, once its size, `head`, and
|
||||||
`tail` match another record's. If that file is gone or has changed since its
|
`tail` match another record's. If that file is gone, has changed since its
|
||||||
record was written, the record is left as it is.
|
record was written, or is not on a persistent filesystem, it is not read and
|
||||||
- With `-x`, a record below a directory the walk did not enter because that
|
the record is left as it is.
|
||||||
directory is on another filesystem than its operand counts as outside the
|
- A record at or below a path the walk left out, because that path is a default
|
||||||
scanned operands: it is not deleted, and its size joins the size census. A
|
exclude, is not on a persistent filesystem, or, with `-x`, is a directory on
|
||||||
filesystem mounted under an operand can therefore be scanned on its own
|
another filesystem than its operand, counts as outside the scanned operands:
|
||||||
schedule into the same database. Records below an operand at or inside such a
|
it is not deleted, and its size joins the size census. A filesystem mounted
|
||||||
directory are not kept this way, since that operand's own walk verifies them.
|
under an operand can therefore be scanned on its own schedule into the same
|
||||||
|
database. Records below an operand at or inside such a path are not kept this
|
||||||
|
way, since that operand's own walk verifies them.
|
||||||
|
|
||||||
`scan` runs **five sequential phases over the whole scan**. Parallelism lives
|
`scan` runs **five sequential phases over the whole scan**. Parallelism lives
|
||||||
inside the walk, hash and content phases; batched database writes begin during
|
inside the walk, hash and content phases; batched database writes begin during
|
||||||
@@ -542,19 +576,20 @@ the hash phase:
|
|||||||
be read are kept in memory, never every file's hashes. Every record sharing
|
be read are kept in memory, never every file's hashes. Every record sharing
|
||||||
their size, `head`, and `tail`, including one that already has a `content`
|
their size, `head`, and `tail`, including one that already has a `content`
|
||||||
hash, has its file checked with `lstat` first. A file that is gone, is no
|
hash, has its file checked with `lstat` first. A file that is gone, is no
|
||||||
longer a regular file, or has changed (a different size, or an mtime newer
|
longer a regular file, has changed (a different size, or an mtime newer than
|
||||||
than recorded) keeps its record as it is and does not count as a match for
|
recorded), or is not on a persistent filesystem (see "Rules for the walk")
|
||||||
the others. Any other `lstat` error is warned about and counted as skipped,
|
keeps its record as it is and does not count as a match for the others. Any
|
||||||
with the same result. If such a record has no `content` hash, it stays out
|
other `lstat` error is warned about and counted as skipped, with the same
|
||||||
of duplicate groups; if it has one, it is still reported until a scan
|
result. If such a record has no `content` hash, it stays out of duplicate
|
||||||
covering its own tree updates or removes it. The files that pass and have no
|
groups; if it has one, it is still reported until a scan covering its own
|
||||||
`content` hash are read only if at least two of those records pass, so a
|
tree updates or removes it. The files that pass and have no `content` hash
|
||||||
file whose only matches are stale costs no read; a file that already has a
|
are read only if at least two of those records pass, so a file whose only
|
||||||
`content` hash is never read again. They are read by a worker pool as in the
|
matches are stale costs no read; a file that already has a `content` hash is
|
||||||
hash phase, in inode order and once per inode, and their content hashes are
|
never read again. They are read by a worker pool as in the hash phase, in
|
||||||
committed in batches. A failed read is warned about and counted as skipped;
|
inode order and once per inode, and their content hashes are committed in
|
||||||
its record keeps an empty `content`, so it is not a duplicate, and a later
|
batches. A failed read is warned about and counted as skipped; its record
|
||||||
scan tries again.
|
keeps an empty `content`, so it is not a duplicate, and a later scan tries
|
||||||
|
again.
|
||||||
|
|
||||||
Rules for the walk:
|
Rules for the walk:
|
||||||
|
|
||||||
@@ -565,6 +600,15 @@ Rules for the walk:
|
|||||||
- Never descend into a directory named `.zfs` (ZFS snapshot pseudo-dirs; walking
|
- Never descend into a directory named `.zfs` (ZFS snapshot pseudo-dirs; walking
|
||||||
them would list every file once per snapshot), not even when it is an operand;
|
them would list every file once per snapshot), not even when it is an operand;
|
||||||
such an operand is dropped the same way.
|
such an operand is dropped the same way.
|
||||||
|
- Never record the scan's own database. Once the database and its `.lock` file
|
||||||
|
are open, `scan` notes the device and inode of the database file, its `-wal`
|
||||||
|
and `-shm` files and its `.lock` file, and the walk passes over a file with
|
||||||
|
one of them, however it reaches it: as an operand, below one, or through a
|
||||||
|
symlinked directory. It prints no line for such a file and does not count it
|
||||||
|
as skipped. A record that an earlier scan left for one of them is deleted,
|
||||||
|
like the record of any file the scan no longer verifies. There is no
|
||||||
|
`-journal` file during the walk, because the database is in WAL mode then (see
|
||||||
|
"Database").
|
||||||
- Filesystem boundaries are crossed by default. With `-x` (long form
|
- Filesystem boundaries are crossed by default. With `-x` (long form
|
||||||
`--one-file-system`, following the GNU `du`/`rsync` convention), never descend
|
`--one-file-system`, following the GNU `du`/`rsync` convention), never descend
|
||||||
into a directory on a different filesystem than its `PATH` operand; each
|
into a directory on a different filesystem than its `PATH` operand; each
|
||||||
@@ -572,6 +616,74 @@ Rules for the walk:
|
|||||||
unless they lie below an operand at or inside that directory, count as outside
|
unless they lie below an operand at or inside that directory, count as outside
|
||||||
the scanned operands, as described in "`scan` mode" above: they are not
|
the scanned operands, as described in "`scan` mode" above: they are not
|
||||||
deleted, and their sizes join the size census.
|
deleted, and their sizes join the size census.
|
||||||
|
- Never record anything that is not on a persistent filesystem. A filesystem is
|
||||||
|
not persistent when the kernel makes it up or keeps it in memory: one of type
|
||||||
|
`proc`, `sysfs`, `devtmpfs`, `devpts`, `tmpfs`, `ramfs`, `cgroup`, `cgroup2`,
|
||||||
|
`debugfs`, `tracefs`, `securityfs`, `pstore`, `bpf`, `configfs`, `fusectl`,
|
||||||
|
`mqueue`, `hugetlbfs`, `efivarfs`, `binfmt_misc`, `autofs`, `nsfs`,
|
||||||
|
`rpc_pipefs`, `selinuxfs` or `devfs` (the macOS filesystem of `/dev`). After
|
||||||
|
it names the database and before it takes the lock, `scan` lists the mounted
|
||||||
|
filesystems, from `/proc/self/mountinfo` on Linux and from the system's list
|
||||||
|
of mounted filesystems on macOS (on any other system it lists none), and notes
|
||||||
|
the device ID of each one of those types. On Linux that is the device ID the
|
||||||
|
list gives, so a filesystem that a later mount hides still gives its own. On
|
||||||
|
macOS it is read with `lstat` on the mount point, and where one filesystem is
|
||||||
|
mounted over another on the same mount point, the one mounted last counts. If
|
||||||
|
`scan` cannot list them, it fails (exit 1). The walk then never enters a
|
||||||
|
directory, and never records a file, on one of those devices, however it
|
||||||
|
reaches it: as an operand, below one, or through a bind mount, which keeps the
|
||||||
|
device ID of what it binds. The content phase never reads a file on one of
|
||||||
|
them either. No flag turns this off. For each operand, directory or file it
|
||||||
|
leaves out this way, `scan` prints a line on stderr naming it and the type of
|
||||||
|
its filesystem, as it prints a warning, so it never lands inside a progress
|
||||||
|
display:
|
||||||
|
|
||||||
|
```
|
||||||
|
scan: skipped /dev/shm (tmpfs, not a persistent filesystem)
|
||||||
|
```
|
||||||
|
|
||||||
|
That line is not a warning, and the path is not counted as skipped. Records
|
||||||
|
that an earlier scan left at or below it, unless they lie below an operand
|
||||||
|
at or inside it, count as outside the scanned operands, as described in
|
||||||
|
"`scan` mode" above: they are not deleted, and their sizes join the size
|
||||||
|
census.
|
||||||
|
|
||||||
|
- Below an operand, never descend into a default exclude: a path that holds the
|
||||||
|
kernel's view of the running system, or temporary files, so that
|
||||||
|
`sfdupes scan /` walks only the files worth comparing. The default excludes
|
||||||
|
depend on the system, and `sfdupes scan --help` lists those of the system it
|
||||||
|
runs on:
|
||||||
|
- On Linux: `/proc`, `/sys`, `/dev`, `/run`, `/tmp` and `/var/tmp`.
|
||||||
|
- On macOS: `/dev`, `/private/tmp`, `/private/var/tmp`, `/private/var/vm`,
|
||||||
|
`/System/Volumes/VM` and `/System/Volumes/Data`. A walk of `/` reaches
|
||||||
|
everything in `/System/Volumes/Data` through the firmlinks, so walking it
|
||||||
|
as well would record each of those files twice.
|
||||||
|
- On Windows, relative to the root of the drive an operand lies on: the
|
||||||
|
page, swap and hibernation files `pagefile.sys`, `swapfile.sys` and
|
||||||
|
`hiberfil.sys`, and the directories `System Volume Information`,
|
||||||
|
`$Recycle.Bin`, `Windows\Temp` and `Users\*\AppData\Local\Temp`, where `*`
|
||||||
|
matches one path component, as it does in any default exclude. sfdupes
|
||||||
|
does not build for Windows yet, so this list is not used.
|
||||||
|
- On any other system there are none.
|
||||||
|
|
||||||
|
`--no-default-excludes` turns them all off; what is not on a persistent
|
||||||
|
filesystem stays left out. An operand at or inside a default exclude is
|
||||||
|
still walked, so `sfdupes scan /tmp/x` works, unless it is not on a
|
||||||
|
persistent filesystem. For each default exclude its walk does not enter,
|
||||||
|
`scan` prints a line on stderr naming it, as it prints a warning:
|
||||||
|
|
||||||
|
```
|
||||||
|
scan: skipped /var/tmp (default exclude)
|
||||||
|
```
|
||||||
|
|
||||||
|
That line is not a warning, and the directory is not counted as skipped. A
|
||||||
|
default exclude that is not on a persistent filesystem, as `/proc` is, gets
|
||||||
|
the line for its filesystem instead, and one that is itself an operand gets
|
||||||
|
no line, since it is walked as that operand. The records below a default
|
||||||
|
exclude the walk does not enter, unless they lie below an operand at or
|
||||||
|
inside it, count as outside the scanned operands, as described in "`scan`
|
||||||
|
mode" above: they are not deleted, and their sizes join the size census.
|
||||||
|
|
||||||
- On any per-path error (permission denied, file vanished between passes,
|
- On any per-path error (permission denied, file vanished between passes,
|
||||||
unreadable): print a one-line warning to stderr, skip the path, and continue.
|
unreadable): print a one-line warning to stderr, skip the path, and continue.
|
||||||
Per-file errors never abort the run; the final summary reports how many were
|
Per-file errors never abort the run; the final summary reports how many were
|
||||||
@@ -799,7 +911,8 @@ A scan has five phases (`[1/5] load`, `[2/5] walk`, `[3/5] hash`,
|
|||||||
`head`, and `tail`, without fetching the records. It then says it is searching
|
`head`, and `tail`, without fetching the records. It then says it is searching
|
||||||
the database until SQLite returns the first record; the checks, and their rate
|
the database until SQLite returns the first record; the checks, and their rate
|
||||||
and ETA, start there. The files to read then get a display of their own, with
|
and ETA, start there. The files to read then get a display of their own, with
|
||||||
their count as its total.
|
their count as its total, and so does the writing of the last of their records
|
||||||
|
to the database, with the count of those records as its total.
|
||||||
|
|
||||||
`trees` has four phases (`[1/4] open`, `[2/4] read`, `[3/4] group`,
|
`trees` has four phases (`[1/4] open`, `[2/4] read`, `[3/4] group`,
|
||||||
`[4/4] write`, see "`trees` mode"):
|
`[4/4] write`, see "`trees` mode"):
|
||||||
@@ -833,17 +946,36 @@ Each display is one line holding:
|
|||||||
- a `[m/n] x%` display (items processed / total items, percent); before a phase
|
- a `[m/n] x%` display (items processed / total items, percent); before a phase
|
||||||
has a total, the items processed, or what the phase is waiting for
|
has a total, the items processed, or what the phase is waiting for
|
||||||
- current rate (items/s), naming the items the phase counts: files, records,
|
- current rate (items/s), naming the items the phase counts: files, records,
|
||||||
directories or groups
|
directories or groups. It covers the items completed over the last 5 seconds,
|
||||||
|
or, when stderr is not a terminal, since the line before, so it falls to 0
|
||||||
|
while nothing completes, and a burst of items after a wait shows as a burst
|
||||||
|
rather than as a steady rate. Once no item has completed for 10 seconds, the
|
||||||
|
word `stalled` and where the phase waits take the rate's place (see below).
|
||||||
- elapsed time
|
- elapsed time
|
||||||
- estimated time remaining, or `unknown`
|
- estimated time remaining, or `unknown`. It comes from the phase's average rate
|
||||||
|
since it started counting, so that it does not swing with each burst.
|
||||||
|
|
||||||
Example shape (exact layout is flexible, content is not):
|
Example shape (exact layout is flexible, content is not):
|
||||||
|
|
||||||
```
|
```
|
||||||
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
|
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
|
||||||
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
|
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
|
||||||
|
[3/5] hash: [12345/45678] 27%, stalled disk/db, elapsed 4m30s, eta 12m9s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When no item has completed for 10 seconds, as while one large file is read, a
|
||||||
|
disk is slow or the database is busy, the line says `stalled` and where the
|
||||||
|
phase waits in place of the rate, which would read 0: `disk` for files, `db` for
|
||||||
|
the database, `out` for stdout, and two of them, as `disk/db`, for a phase that
|
||||||
|
may wait on either. `walk` says `disk`. `hash` and the content phase's reads say
|
||||||
|
`disk/db`, as they read files and write their records to the database between
|
||||||
|
them, and so do the content phase's checks, which `lstat` each record's file as
|
||||||
|
the database search returns it. `load`, `update`, the content phase's last write
|
||||||
|
and the `read` phase of `trees` say `db`. `trees`' `write` says `out`, and its
|
||||||
|
`group`, which works in memory, says `cpu`. The `read` phase of `report` writes
|
||||||
|
the report while it reads the records, so it says `db/out`. A phase still
|
||||||
|
waiting for its total already says what it waits for, and says nothing more.
|
||||||
|
|
||||||
Additional requirements:
|
Additional requirements:
|
||||||
|
|
||||||
- On a terminal, a phase with an exact total (every phase but `scan`'s `walk`,
|
- On a terminal, a phase with an exact total (every phase but `scan`'s `walk`,
|
||||||
@@ -855,15 +987,20 @@ Additional requirements:
|
|||||||
times a second, so its elapsed time keeps moving while a phase waits for its
|
times a second, so its elapsed time keeps moving while a phase waits for its
|
||||||
next item.
|
next item.
|
||||||
- On a terminal no line reaches the terminal's last column, however narrow the
|
- On a terminal no line reaches the terminal's last column, however narrow the
|
||||||
terminal is. The phase and its place, the progress, the elapsed time and the
|
terminal is. A line too wide for the terminal first loses its bar, which gives
|
||||||
ETA are kept whole: a line too wide for the terminal leaves out its rate first
|
way to the spinner, then its rate, the words of a stalled phase, or what the
|
||||||
(or what the phase is waiting for), and a bar with no room beside those four
|
phase is waiting for. The phase and its place, the progress, the elapsed time
|
||||||
gives way to the spinner. Only when those four alone are too wide, as on a
|
and the ETA are kept whole. On an 80-column terminal, at counts like those of
|
||||||
terminal narrower than about 70 columns hours into a scan of millions of
|
the stalled line in the example above, a stalled line keeps its words whenever
|
||||||
files, is the line cut short at the terminal's edge, so the end of the ETA is
|
the elapsed time and the ETA are both in minutes, except in the content
|
||||||
lost first. A terminal made narrower while a phase runs gets the same: the
|
phase's checks and reads. Their lines are the widest, and at most times in
|
||||||
next draw erases the line within the new width and fits the line to it, and
|
minutes they lose the words. Where the words and the ETA do not both fit, as
|
||||||
the display goes on redrawing in place.
|
at millions of files and times in hours, the words give way. Only when those
|
||||||
|
four alone are too wide, as on a terminal narrower than about 70 columns hours
|
||||||
|
into a scan of millions of files, is the line cut short at the terminal's
|
||||||
|
edge, so the end of the ETA is lost first. A terminal made narrower while a
|
||||||
|
phase runs gets the same: the next draw erases the line within the new width
|
||||||
|
and fits the line to it, and the display goes on redrawing in place.
|
||||||
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
|
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
|
||||||
redraws: print the line the moment each phase starts, every 5 seconds while it
|
redraws: print the line the moment each phase starts, every 5 seconds while it
|
||||||
runs (whether or not an item has completed, and never more often), and once
|
runs (whether or not an item has completed, and never more often), and once
|
||||||
@@ -988,13 +1125,22 @@ entrypoints are:
|
|||||||
- `script/install-precommit` — install the git pre-commit hook, as
|
- `script/install-precommit` — install the git pre-commit hook, as
|
||||||
`.git/hooks/pre-commit`, that runs `script/precommit`.
|
`.git/hooks/pre-commit`, that runs `script/precommit`.
|
||||||
|
|
||||||
Every `docker build` in `script/` passes a new `CACHEBUST` build argument, which
|
Every `docker build` in `script/` passes a new `CACHEBUST` build argument. In
|
||||||
every stage of the `Dockerfile` declares first, so every `RUN` step runs again
|
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
||||||
each time. On an unchanged tree Docker would otherwise serve the gate steps from
|
that install system packages and download the Go modules, or right after `FROM`
|
||||||
its cache, and the build would pass having run no test and no linter. Go's build
|
in a stage with neither, and every step that lints, tests or compiles comes
|
||||||
cache is kept between builds on the steps that compile, so only what changed is
|
after it, so every `RUN` step after it runs again each time. On an unchanged
|
||||||
compiled again; the tests run with `-count=1`, so none reports a stored result.
|
tree Docker would otherwise serve the gate steps from its cache, and the build
|
||||||
Every run downloads the Go modules again and needs the network.
|
would pass having run no test and no linter. The layers above that line stay in
|
||||||
|
Docker's build cache until the base image, their own lines, `go.mod` or `go.sum`
|
||||||
|
change, or until that cache is cleared; only then are the packages installed and
|
||||||
|
the modules downloaded again. Go's build cache is kept between builds on the
|
||||||
|
steps that compile, so only what changed is compiled again; the tests run with
|
||||||
|
`-count=1`, so none reports a stored result. `golangci-lint run` also keeps
|
||||||
|
golangci-lint's cache, and takes a package's findings from it only when the
|
||||||
|
package's files and those of the packages it imports, `go.mod`, the linters and
|
||||||
|
their settings in `.golangci.yml`, and the golangci-lint version are all
|
||||||
|
unchanged.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -1058,10 +1204,6 @@ All of the following, run in this directory, must pass:
|
|||||||
./sfdupes report
|
./sfdupes report
|
||||||
```
|
```
|
||||||
|
|
||||||
(The database lives in a temp directory of its own: inside `$d`, the scan
|
|
||||||
would record it, and its empty lock file would join the `empty1`/`empty2`
|
|
||||||
group.)
|
|
||||||
|
|
||||||
Expected from the first `report`: `one.bin`/`copy.bin`/`copy2.bin` form one
|
Expected from the first `report`: `one.bin`/`copy.bin`/`copy2.bin` form one
|
||||||
group (two dupe rows, `first` is the lexicographically smallest path);
|
group (two dupe rows, `first` is the lexicographically smallest path);
|
||||||
`t1/f1`/`t2/f1`/`t3/f1` form one group; `t1/sub/f2`/
|
`t1/f1`/`t2/f1`/`t3/f1` form one group; `t1/sub/f2`/
|
||||||
|
|||||||
+68
-35
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-10-15
|
last_modified: 2026-10-18
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -159,8 +159,11 @@ style conventions are in separate documents:
|
|||||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||||
org, in both directions. A container has its own golangci-lint cache, its
|
org, in both directions. A container has its own `TMPDIR`, so the lock is
|
||||||
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
out of reach. The lint phase does keep golangci-lint's cache between check
|
||||||
|
builds (below), but it lints every checkout at `/src`, and a finding is
|
||||||
|
taken from that cache only for the same files, settings and golangci-lint
|
||||||
|
version.
|
||||||
|
|
||||||
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
|
||||||
invalidates a `COPY` layer only when the copied content changes, so on an
|
invalidates a `COPY` layer only when the copied content changes, so on an
|
||||||
@@ -170,15 +173,21 @@ style conventions are in separate documents:
|
|||||||
id, so no two builds share a value, not even the three in one
|
id, so no two builds share a value, not even the three in one
|
||||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||||
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
`script/docker` are the four, and there is no fifth — `script/check` runs the
|
||||||
two gate phases and `script/fmt-check`, and builds no image of its own. Every
|
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
||||||
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
||||||
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
that install system packages and download Go's modules, or right after `FROM`
|
||||||
are compared by content and need nothing. A stage without the `ARG` line is
|
in a stage with neither, and every step that lints, tests or compiles comes
|
||||||
served from cache on an unchanged tree, so a repository re-vendors the scripts
|
after it. A step that installs anything else as well, such as a
|
||||||
and adds the `ARG` lines in one commit. The scripts do not pass `--no-cache`:
|
`script/bootstrap` that also installs node packages, comes after it too. A new
|
||||||
it reruns every step too, but it also empties every cache mount, and Go's
|
value reruns every `RUN` step after that line; `COPY` steps are compared by
|
||||||
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
content and need nothing. Docker keeps the layers above the line until the
|
||||||
packages survive between check builds, would start empty every time. A bare
|
base image, their own lines or the files they copy change. A stage without the
|
||||||
|
`ARG` line is served from cache on an unchanged tree, so a repository
|
||||||
|
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
|
||||||
|
not pass `--no-cache`: it reruns every step too, but it also builds the layers
|
||||||
|
above the line again and empties every cache mount, so Go's build cache and
|
||||||
|
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
|
||||||
|
survive between check builds, would start empty every time. A bare
|
||||||
`docker build .` is not evidence that anything ran: a sub-second build
|
`docker build .` is not evidence that anything ran: a sub-second build
|
||||||
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
reporting success is a cache hit, not a result. Never invalidate by pruning —
|
||||||
`docker builder prune` and friends destroy a build cache shared with every
|
`docker builder prune` and friends destroy a build cache shared with every
|
||||||
@@ -194,19 +203,25 @@ style conventions are in separate documents:
|
|||||||
`Dockerfile`:
|
`Dockerfile`:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||||
# on every build, which reruns every RUN step after it. The steps that
|
# system packages and download Go's modules, or right after FROM in a
|
||||||
# compile mount Go's build cache, which survives from build to build.
|
# stage with neither, and every step that lints, tests or compiles
|
||||||
|
# comes after it. script/ passes a new value on every build, which
|
||||||
|
# reruns every RUN step after it. Docker keeps the layers above it
|
||||||
|
# until the base image, their own lines or go.mod and go.sum change.
|
||||||
|
# The steps that compile mount Go's build cache, and golangci-lint run
|
||||||
|
# also mounts golangci-lint's; both survive from build to build.
|
||||||
|
|
||||||
# Lint phase
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
ARG CACHEBUST
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||||
golangci-lint run --config .golangci.yml ./...
|
golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
@@ -214,10 +229,10 @@ style conventions are in separate documents:
|
|||||||
# cache also holds test results, which go test would otherwise replay.
|
# cache also holds test results, which go test would otherwise replay.
|
||||||
# golang:1.x, YYYY-MM-DD
|
# golang:1.x, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS test
|
FROM golang@sha256:... AS test
|
||||||
ARG CACHEBUST
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||||
go test -count=1 -timeout 90s -race -cover ./... || \
|
go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
@@ -225,19 +240,19 @@ style conventions are in separate documents:
|
|||||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
# Build stage. Nothing is wanted from either phase above; the copies
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
# are what make BuildKit build them first, so this stage cannot run
|
# are what make BuildKit build them first, so nothing after them can
|
||||||
# unless lint and test passed.
|
# run unless lint and test passed.
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
FROM golang@sha256:... AS builder
|
FROM golang@sha256:... AS builder
|
||||||
ARG CACHEBUST
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
COPY --from=test /src/go.sum /dev/null
|
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
|
||||||
RUN git config --system --add safe.directory /src
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
|
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||||
|
RUN git config --system --add safe.directory /src
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# The VERSION build arg when one is given, otherwise
|
# The VERSION build arg when one is given, otherwise
|
||||||
@@ -274,16 +289,26 @@ style conventions are in separate documents:
|
|||||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||||
same two copies. A plain `docker build .` builds the last stage's chain
|
same two copies. A plain `docker build .` builds the last stage's chain
|
||||||
and nothing else.
|
and nothing else.
|
||||||
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
- Check builds keep these, and none of them can hand back a stale result:
|
||||||
it for root), and only on the steps that compile: `golangci-lint run`,
|
- The layers above `ARG CACHEBUST`: the system packages and Go's
|
||||||
both `go test` runs and `go build`. It keeps compiled packages between
|
modules. Docker installs the packages again when the base image or the
|
||||||
check builds, and Go reuses one only when all its inputs are unchanged, so
|
install line changes, and downloads the modules again when `go.mod` or
|
||||||
it saves the compile without skipping a check. Nothing else is kept:
|
`go.sum` changes. Go checks every module against `go.sum` as it
|
||||||
golangci-lint's own cache would replay stored findings in place of a lint
|
downloads it, and no later build can change a kept layer. The modules
|
||||||
run, and Go builds from whatever files a kept module cache holds without
|
are kept in a layer rather than a cache mount because a cache mount is
|
||||||
checking them again (only `go mod verify` does), so a file changed there
|
shared by every build on the host, and Go builds from whatever files
|
||||||
would go unnoticed. Go's build cache also holds test results, which is why
|
it holds without checking them again (only `go mod verify` does), so a
|
||||||
both `go test` runs carry `-count=1`.
|
file changed there would go unnoticed.
|
||||||
|
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
|
||||||
|
root), mounted on the steps that compile: `golangci-lint run`, both
|
||||||
|
`go test` runs and `go build`. Go reuses a compiled package only when
|
||||||
|
all its inputs are unchanged. The cache also holds test results, which
|
||||||
|
is why both `go test` runs carry `-count=1`.
|
||||||
|
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
|
||||||
|
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
|
||||||
|
a package's findings only when the package's files and those of every
|
||||||
|
package it imports, `go.mod`, the linters and their settings in
|
||||||
|
`.golangci.yml`, and the golangci-lint version are all unchanged.
|
||||||
- If the project uses `//go:embed` directives that reference build artifacts
|
- If the project uses `//go:embed` directives that reference build artifacts
|
||||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||||
create placeholder files so the embed directives resolve. Example:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
@@ -292,12 +317,20 @@ style conventions are in separate documents:
|
|||||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
has no `apk`, so install with `apt-get` under the Debian package name
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
lists in the same `RUN`, so the layer does not keep them:
|
lists in the same `RUN`, so the layer does not keep them. The test phase,
|
||||||
|
on the Debian Go image, installs the libraries its tests need the same
|
||||||
|
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
|
||||||
|
Docker keeps it until the base image or the install line changes:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
ARG CACHEBUST
|
||||||
```
|
```
|
||||||
|
|
||||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||||
|
|||||||
@@ -29,6 +29,73 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- re-vendor `REPO_POLICIES.md` and the four scripts that pass `CACHEBUST` from
|
||||||
|
`sneak/prompts` `next` at `0653579`: in the `lint`, `test` and `builder`
|
||||||
|
stages of the `Dockerfile` the module download, and in `builder` the
|
||||||
|
system-package install, now come before `ARG CACHEBUST`, so Docker keeps them
|
||||||
|
until the base image, the install line, `go.mod` or `go.sum` changes, and
|
||||||
|
every step that lints, tests or compiles still comes after it;
|
||||||
|
`golangci-lint run` also keeps golangci-lint's cache. README says the same
|
||||||
|
(2026-10-18, https://git.eeqj.de/sneak/sfdupes/issues/109)
|
||||||
|
|
||||||
|
- releases until 1.0 are `v0.0.N` tags on `main`'s tip, so
|
||||||
|
`go install sneak.berlin/go/sfdupes@latest` installs `main`'s tip once it has
|
||||||
|
passed the build; README "Install" says so, says what `@main` and `v0.0.1`
|
||||||
|
give, and how to check an installed binary. `go.mod` retracts `v0.0.1`, the
|
||||||
|
early build without a database. When the linker set no version,
|
||||||
|
`sfdupes --version` prints the main module's version that Go recorded in the
|
||||||
|
binary, the tag or the pseudo-version of a `go install` build, and `dev` only
|
||||||
|
when neither is known (2026-10-17,
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/116)
|
||||||
|
|
||||||
|
- `scan` creates the database's directory where the database path leads: the
|
||||||
|
path with its last element cut off as text, never cleaned, so a `..` after a
|
||||||
|
symlinked directory is followed through the symlink, as when the file is
|
||||||
|
opened. With `SFDUPES_DATABASE=/srv/link/../new/db.sqlite` and `/srv/link` a
|
||||||
|
symlink to `/data/x`, it creates `/data/new` and the database in it, where 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 the database file, its `-wal` and
|
||||||
|
`-shm` files and its `.lock` file, and the walk passes over a file with one of
|
||||||
|
them wherever the operands reach it, through a symlinked directory included,
|
||||||
|
printing no line and counting 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 that is not on a persistent filesystem
|
||||||
|
(`tmpfs`, `proc`, `sysfs` and the other kernel or memory-backed types),
|
||||||
|
matched by the device ID of each such filesystem, listed as the scan starts
|
||||||
|
from `/proc/self/mountinfo` on Linux and the mounted-filesystem list on macOS,
|
||||||
|
however the walk or the content phase reaches it; no flag turns that off.
|
||||||
|
Below an operand, its 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 `/proc`, `/sys`, `/dev`, `/run`, `/tmp` and
|
||||||
|
`/var/tmp`, on macOS `/dev`, `/private/tmp`, `/private/var/tmp`,
|
||||||
|
`/private/var/vm`, `/System/Volumes/VM` and `/System/Volumes/Data`, and a
|
||||||
|
Windows list, not used yet. 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, and 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)
|
||||||
|
|
||||||
|
- the rate a progress display shows covers the items completed over the last 5
|
||||||
|
seconds, or since the line before when stderr is not a terminal, so it falls
|
||||||
|
to 0 while nothing completes, and a burst after a wait no longer reads as a
|
||||||
|
steady rate of over 1,000 files a second; the ETA still comes from the phase's
|
||||||
|
average. Once no item has completed for 10 seconds, the word `stalled` and
|
||||||
|
where the phase waits take the rate's place, as `stalled disk/db`: `disk`,
|
||||||
|
`db`, `out` for stdout or `cpu`, or two of them for a phase that may wait on
|
||||||
|
either, each phase giving its word where it is defined. A line too wide for
|
||||||
|
the terminal loses its bar first, then its rate or those words, so the phase,
|
||||||
|
progress, elapsed time and ETA stay whole. On an 80-column terminal, at counts
|
||||||
|
like those of README's example, the content phase's checks and reads lose
|
||||||
|
those words at most times in minutes, and every other phase keeps them
|
||||||
|
whenever the elapsed time and the ETA are both in minutes. The content phase's
|
||||||
|
last partial batch is now 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
|
- new subcommand `info` prints the database's details on stdout, one per line as
|
||||||
a name, a tab and the value: its path and where it came from, the sizes of the
|
a name, a tab and the value: its 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
|
database file and of its `-wal` file when there is one, when the database file
|
||||||
@@ -96,7 +163,11 @@
|
|||||||
and the build stage installs `git` and `make` itself; a new push cancels the
|
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;
|
workflow's older run on the same branch, and a run stops after 20 minutes;
|
||||||
`.claude/settings.json` is deleted (2026-10-15,
|
`.claude/settings.json` is deleted (2026-10-15,
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/95)
|
https://git.eeqj.de/sneak/sfdupes/issues/95). Since
|
||||||
|
https://git.eeqj.de/sneak/sfdupes/issues/109 the module download in `lint`,
|
||||||
|
`test` and the build stage, and the install of `git` and `make` in the build
|
||||||
|
stage, come before `ARG CACHEBUST`, and Docker keeps them between builds;
|
||||||
|
every step that lints, tests or compiles still runs on every build
|
||||||
|
|
||||||
- with `-x`, an operand under another operand is walked on its own instead of
|
- with `-x`, an operand under another operand is walked on its own instead of
|
||||||
being dropped when a directory from it (from its directory, for a regular
|
being dropped when a directory from it (from its directory, for a regular
|
||||||
@@ -297,9 +368,9 @@
|
|||||||
a `CHECK_EPOCH` build argument; since
|
a `CHECK_EPOCH` build argument; since
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/`
|
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/`
|
||||||
passes a new `CACHEBUST` value instead, which every stage of the `Dockerfile`
|
passes a new `CACHEBUST` value instead, which every stage of the `Dockerfile`
|
||||||
declares first. Run as root, the tests fail
|
declares before every step that lints, tests or compiles. Run as root, the
|
||||||
`TestScanHardlinkRunFailsTogether`, because root reads through the `chmod(0)`
|
tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the
|
||||||
the test relies on, so the `test` phase runs them as `nobody`
|
`chmod(0)` the test relies on, so the `test` phase runs them as `nobody`
|
||||||
- check the installed golangci-lint version in `script/bootstrap` instead of
|
- check the installed golangci-lint version in `script/bootstrap` instead of
|
||||||
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
||||||
|
|||||||
+15
-10
@@ -196,7 +196,7 @@ func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
|
|||||||
base := baselineGoroutines(t)
|
base := baselineGoroutines(t)
|
||||||
|
|
||||||
st, err := syncScan(newWalkClock(walkCancelAtDone), db,
|
st, err := syncScan(newWalkClock(walkCancelAtDone), db,
|
||||||
[]string{dir}, walkCancelWorkers, false)
|
[]string{dir}, walkCancelWorkers, false, walkRules{})
|
||||||
|
|
||||||
assertWalkGuardAborted(t, st, err)
|
assertWalkGuardAborted(t, st, err)
|
||||||
assertRecordsIntact(t, db, before)
|
assertRecordsIntact(t, db, before)
|
||||||
@@ -268,7 +268,8 @@ func TestSyncScanCancelledBeforeLoadIndex(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
st, err := syncScan(ctx, db, []string{dir}, walkCancelWorkers, false)
|
st, err := syncScan(ctx, db, []string{dir}, walkCancelWorkers, false,
|
||||||
|
walkRules{})
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("syncScan on a cancelled context = %v, want %v",
|
t.Fatalf("syncScan on a cancelled context = %v, want %v",
|
||||||
err, context.Canceled)
|
err, context.Canceled)
|
||||||
@@ -301,7 +302,7 @@ func TestSyncScanCancelledMidHashKeepsHashedRecords(t *testing.T) {
|
|||||||
db := openTestDB(t)
|
db := openTestDB(t)
|
||||||
|
|
||||||
st, err := syncScan(newWalkClock(hashCancelAtDone), db,
|
st, err := syncScan(newWalkClock(hashCancelAtDone), db,
|
||||||
[]string{dir}, walkCancelWorkers, false)
|
[]string{dir}, walkCancelWorkers, false, walkRules{})
|
||||||
if !errors.Is(err, context.Canceled) {
|
if !errors.Is(err, context.Canceled) {
|
||||||
t.Fatalf("syncScan cancelled mid-hash = %v, want %v",
|
t.Fatalf("syncScan cancelled mid-hash = %v, want %v",
|
||||||
err, context.Canceled)
|
err, context.Canceled)
|
||||||
@@ -356,7 +357,8 @@ func TestRunScanInterrupted(t *testing.T) {
|
|||||||
stderr := captureStderr(t)
|
stderr := captureStderr(t)
|
||||||
dir := buildSmokeTree(t)
|
dir := buildSmokeTree(t)
|
||||||
|
|
||||||
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false)
|
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
|
||||||
|
false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -380,7 +382,7 @@ func TestRunScanInterrupted(t *testing.T) {
|
|||||||
defer close(done)
|
defer close(done)
|
||||||
|
|
||||||
err = runScan(cancelledContext(t), []string{dir}, walkCancelWorkers,
|
err = runScan(cancelledContext(t), []string{dir}, walkCancelWorkers,
|
||||||
false)
|
false, false)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
awaitReturn(t, done, "runScan")
|
awaitReturn(t, done, "runScan")
|
||||||
@@ -407,7 +409,8 @@ func TestRunScanInterrupted(t *testing.T) {
|
|||||||
got, before)
|
got, before)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false)
|
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
|
||||||
|
false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -433,7 +436,7 @@ func TestRunScanInterruptedMidHash(t *testing.T) {
|
|||||||
dir := buildWalkCancelTree(t)
|
dir := buildWalkCancelTree(t)
|
||||||
|
|
||||||
err := runScan(newWalkClock(hashCancelAtDone), []string{dir},
|
err := runScan(newWalkClock(hashCancelAtDone), []string{dir},
|
||||||
walkCancelWorkers, false)
|
walkCancelWorkers, false, false)
|
||||||
if !errors.Is(err, errInterrupted) {
|
if !errors.Is(err, errInterrupted) {
|
||||||
t.Fatalf("runScan interrupted mid-hash = %v, want %v",
|
t.Fatalf("runScan interrupted mid-hash = %v, want %v",
|
||||||
err, errInterrupted)
|
err, errInterrupted)
|
||||||
@@ -617,7 +620,8 @@ func TestWalkOneDirStopsWhenCancelled(t *testing.T) {
|
|||||||
// Unbuffered and unread: on a cancelled scan every send gives up.
|
// Unbuffered and unread: on a cancelled scan every send gives up.
|
||||||
events := make(chan walkEvent)
|
events := make(chan walkEvent)
|
||||||
|
|
||||||
subs := walkOneDir(cancelledContext(t), dirJob{path: dir}, false, events)
|
subs := walkOneDir(cancelledContext(t), dirJob{path: dir}, false,
|
||||||
|
walkRules{}, events)
|
||||||
if len(subs) != 0 {
|
if len(subs) != 0 {
|
||||||
t.Errorf("cancelled walkOneDir returned %+v to descend into, "+
|
t.Errorf("cancelled walkOneDir returned %+v to descend into, "+
|
||||||
"want none", subs)
|
"want none", subs)
|
||||||
@@ -637,7 +641,8 @@ func TestWalkWorkersDropQueuedDirs(t *testing.T) {
|
|||||||
|
|
||||||
missing := filepath.Join(t.TempDir(), "missing")
|
missing := filepath.Join(t.TempDir(), "missing")
|
||||||
|
|
||||||
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false)
|
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false,
|
||||||
|
walkRules{})
|
||||||
|
|
||||||
for range 64 {
|
for range 64 {
|
||||||
jobs <- dirJob{path: missing}
|
jobs <- dirJob{path: missing}
|
||||||
@@ -668,7 +673,7 @@ func TestWalkWorkerAbandonsSubdirHandoff(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(t.Context())
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
jobs, subdirs, events := startWalkWorkers(ctx, 1, false)
|
jobs, subdirs, events := startWalkWorkers(ctx, 1, false, walkRules{})
|
||||||
|
|
||||||
// Fill the hand-back channel to its capacity — one slot per worker
|
// Fill the hand-back channel to its capacity — one slot per worker
|
||||||
// — so the worker's own hand-back is certain to block.
|
// — so the worker's own hand-back is certain to block.
|
||||||
|
|||||||
@@ -109,10 +109,8 @@ func databasePath() (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The working directory as the kernel reports it, with no symlink
|
// The working directory as the kernel reports it, with no symlink
|
||||||
// in it. os.Getwd may return PWD instead, which can name it through
|
// in it, as README "Database" says. os.Getwd may return PWD
|
||||||
// a symlink; a ".." after that symlink then leads somewhere else
|
// instead, which can name it through a symlink.
|
||||||
// once the path is cleaned, as scan cleans it to create the
|
|
||||||
// database's directory.
|
|
||||||
wd, err := unix.Getwd()
|
wd, err := unix.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
|
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
|
||||||
@@ -125,6 +123,24 @@ func databasePath() (string, string, error) {
|
|||||||
return strings.TrimSuffix(wd, "/") + "/" + path, source, 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. 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)
|
||||||
|
|
||||||
|
err := os.MkdirAll(dir, dbDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create database directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// scanParams are the connection parameters for scan: read-write, with
|
// scanParams are the connection parameters for scan: read-write, with
|
||||||
// WAL journaling and a busy timeout, so a report can run while a cron
|
// WAL journaling and a busy timeout, so a report can run while a cron
|
||||||
// scan is in progress. closeScanDatabase leaves WAL mode again.
|
// scan is in progress. closeScanDatabase leaves WAL mode again.
|
||||||
@@ -179,9 +195,9 @@ func openDB(path, params string) (*sql.DB, error) {
|
|||||||
// that deleted it would let the next scan lock a new file while
|
// that deleted it would let the next scan lock a new file while
|
||||||
// another still holds the old one.
|
// another still holds the old one.
|
||||||
func lockScanDatabase(path string) (*os.File, error) {
|
func lockScanDatabase(path string) (*os.File, error) {
|
||||||
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
|
err := createDatabaseDirectory(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create database directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Like SQLite, this follows a symlink at path and creates its
|
// Like SQLite, this follows a symlink at path and creates its
|
||||||
@@ -225,9 +241,9 @@ func lockScanDatabase(path string) (*os.File, error) {
|
|||||||
// openScanDatabase opens the database for the scan subcommand, creating
|
// openScanDatabase opens the database for the scan subcommand, creating
|
||||||
// the file, its parent directory, and the schema as needed.
|
// the file, its parent directory, and the schema as needed.
|
||||||
func openScanDatabase(ctx context.Context, path string) (*sql.DB, error) {
|
func openScanDatabase(ctx context.Context, path string) (*sql.DB, error) {
|
||||||
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
|
err := createDatabaseDirectory(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create database directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := openDB(path, scanParams)
|
db, err := openDB(path, scanParams)
|
||||||
@@ -245,6 +261,42 @@ func openScanDatabase(ctx context.Context, path string) (*sql.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// databaseFiles returns, by device and inode, the files of the database
|
||||||
|
// at path that the scan keeps open: the database file, its -wal and
|
||||||
|
// -shm files and its .lock file, each named from the database's path
|
||||||
|
// with symlinks resolved, as SQLite and lockScanDatabase name them. The
|
||||||
|
// scan calls it once the database and the lock file are open, so that
|
||||||
|
// its walk passes over them however it reaches them, through a
|
||||||
|
// symlinked directory included. A file that does not exist then is left
|
||||||
|
// out, since no walk can reach it. There is no -journal file while the
|
||||||
|
// database is in WAL mode.
|
||||||
|
func databaseFiles(path string) ([]fileRec, error) {
|
||||||
|
resolved, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolve database path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []fileRec
|
||||||
|
|
||||||
|
for _, p := range []string{
|
||||||
|
resolved, resolved + "-wal", resolved + "-shm", resolved + ".lock",
|
||||||
|
} {
|
||||||
|
fi, err := os.Lstat(p)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ino := inodeOfInfo(fi)
|
||||||
|
files = append(files, fileRec{path: p, dev: dev, ino: ino})
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
// closeScanDatabase switches the database at path from WAL back to
|
// closeScanDatabase switches the database at path from WAL back to
|
||||||
// rollback-journal mode and closes it. Out of WAL mode the database
|
// rollback-journal mode and closes it. Out of WAL mode the database
|
||||||
// file alone holds the whole database, so a reader needs no -wal or
|
// file alone holds the whole database, so a reader needs no -wal or
|
||||||
|
|||||||
+111
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -118,6 +119,116 @@ func TestOpenScanDatabaseCreates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanCreatesDatabaseDirectoryAfterSymlink has a database path go
|
||||||
|
// through a symlink to a directory, then "..", to a missing directory.
|
||||||
|
// With srv/link a symlink to data/x, srv/link/../new/db.sqlite leads to
|
||||||
|
// data/new/db.sqlite. lockScanDatabase and openScanDatabase, the two
|
||||||
|
// places scan creates the database's directory, must each create the
|
||||||
|
// database there, and nothing at srv/new, where the path leads once
|
||||||
|
// cleaned as text.
|
||||||
|
func TestScanCreatesDatabaseDirectoryAfterSymlink(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
creates := map[string]func(ctx context.Context, path string) error{
|
||||||
|
"lockScanDatabase": func(_ context.Context, path string) error {
|
||||||
|
lock, err := lockScanDatabase(path)
|
||||||
|
if err == nil {
|
||||||
|
_ = lock.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
"openScanDatabase": func(ctx context.Context, path string) error {
|
||||||
|
db, err := openScanDatabase(ctx, path)
|
||||||
|
if err == nil {
|
||||||
|
_ = db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, create := range creates {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Join(dir, "data", "x"), 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Mkdir(filepath.Join(dir, "srv"), 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Symlink(filepath.Join(dir, "data", "x"),
|
||||||
|
filepath.Join(dir, "srv", "link"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = create(t.Context(), dir+"/srv/link/../new/db.sqlite")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(dir, "data", "new", "db.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("database the path leads to: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = os.Lstat(filepath.Join(dir, "srv", "new"))
|
||||||
|
if !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Errorf("srv/new: stat error %v, want it missing", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDatabaseFiles opens a database through a symlink to its file:
|
||||||
|
// databaseFiles finds the database file and its -wal and -shm files
|
||||||
|
// beside the symlink's target, where SQLite keeps them, and leaves out
|
||||||
|
// the .lock file, which only a scan creates.
|
||||||
|
func TestDatabaseFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := testDBPath(t)
|
||||||
|
link := filepath.Join(t.TempDir(), "link.sqlite")
|
||||||
|
|
||||||
|
err := os.Symlink(path, link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := openScanDatabase(t.Context(), link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
files, err := databaseFiles(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make([]string, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
got = append(got, f.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{path, path + "-wal", path + "-shm"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("databaseFiles(%s) = %q, want %q", link, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenDatabaseUnversionedForeign(t *testing.T) {
|
func TestOpenDatabaseUnversionedForeign(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// filesystems that are not persistent, on which the scan never records
|
||||||
|
// or reads anything.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The systems that have default excludes, as runtime.GOOS names them.
|
||||||
|
const (
|
||||||
|
goosLinux = "linux"
|
||||||
|
goosDarwin = "darwin"
|
||||||
|
goosWindows = "windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The fields of a line of mountInfoPath that readMountInfo reads,
|
||||||
|
// counted from 0 (see proc(5)): the device ID, as major:minor, is the
|
||||||
|
// third field, and after the sixth come optional fields, then a field
|
||||||
|
// holding "-", then the filesystem type.
|
||||||
|
const (
|
||||||
|
deviceField = 2
|
||||||
|
optionalFieldsStart = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// typesPerHelpLine is how many filesystem types the scan command's help
|
||||||
|
// lists on each line.
|
||||||
|
const typesPerHelpLine = 6
|
||||||
|
|
||||||
|
// errMountInfoLine reports a line of mountInfoPath without the fields
|
||||||
|
// readMountInfo reads.
|
||||||
|
var errMountInfoLine = errors.New("unexpected line")
|
||||||
|
|
||||||
|
// walkRules holds the rules for the walk that a scan sets up as it
|
||||||
|
// 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 {
|
||||||
|
excludes []string
|
||||||
|
nonPersistent map[uint64]string
|
||||||
|
databaseFiles []fileRec
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultExcludes returns the default excludes of the system goos
|
||||||
|
// names, as runtime.GOOS does: a fixed list on Linux, macOS and
|
||||||
|
// Windows, and none on any other system.
|
||||||
|
func defaultExcludes(goos string) []string {
|
||||||
|
switch goos {
|
||||||
|
case goosLinux:
|
||||||
|
return []string{"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"}
|
||||||
|
case goosDarwin:
|
||||||
|
// A walk of / reaches everything in /System/Volumes/Data through
|
||||||
|
// the firmlinks, so walking it as well would record each of
|
||||||
|
// those files twice.
|
||||||
|
return []string{
|
||||||
|
"/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm",
|
||||||
|
"/System/Volumes/VM", "/System/Volumes/Data",
|
||||||
|
}
|
||||||
|
case goosWindows:
|
||||||
|
// Relative to the root of the drive an operand lies on. The
|
||||||
|
// first three are the page, swap and hibernation files.
|
||||||
|
return []string{
|
||||||
|
"pagefile.sys", "swapfile.sys", "hiberfil.sys",
|
||||||
|
"System Volume Information", "$Recycle.Bin", `Windows\Temp`,
|
||||||
|
`Users\*\AppData\Local\Temp`,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDefaultExclude reports whether path is one of excludes, in which a
|
||||||
|
// * matches one path component.
|
||||||
|
func isDefaultExclude(path string, excludes []string) bool {
|
||||||
|
for _, pattern := range excludes {
|
||||||
|
// The patterns are fixed, so none is malformed.
|
||||||
|
if ok, _ := filepath.Match(pattern, path); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// Linux names.
|
||||||
|
func nonPersistentFilesystemTypes() []string {
|
||||||
|
return []string{
|
||||||
|
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "ramfs",
|
||||||
|
"cgroup", "cgroup2", "debugfs", "tracefs", "securityfs", "pstore",
|
||||||
|
"bpf", "configfs", "fusectl", "mqueue", "hugetlbfs", "efivarfs",
|
||||||
|
"binfmt_misc", "autofs", "nsfs", "rpc_pipefs", "selinuxfs", "devfs",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonPersistentLine returns the line the scan prints for path when fi,
|
||||||
|
// its lstat, puts it on a filesystem in rules.nonPersistent, and ""
|
||||||
|
// when it is on any other.
|
||||||
|
func (rules walkRules) nonPersistentLine(path string, fi fs.FileInfo) string {
|
||||||
|
dev, ok := deviceOfInfo(fi)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fsType, found := rules.nonPersistent[dev]
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("scan: skipped %s (%s, not a persistent filesystem)",
|
||||||
|
path, fsType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMountInfo reads mountinfo, laid out as mountInfoPath is, and
|
||||||
|
// returns the device ID of every mounted filesystem that is not
|
||||||
|
// persistent, with its type. The device ID is the one each line gives,
|
||||||
|
// not that of the file now at its mount point, so a mount that a later
|
||||||
|
// mount hides never adds the device of another filesystem.
|
||||||
|
func readMountInfo(mountinfo io.Reader) (map[uint64]string, error) {
|
||||||
|
types := nonPersistentFilesystemTypes()
|
||||||
|
devices := make(map[uint64]string)
|
||||||
|
lines := bufio.NewScanner(mountinfo)
|
||||||
|
|
||||||
|
for lines.Scan() {
|
||||||
|
fields := strings.Fields(lines.Text())
|
||||||
|
|
||||||
|
sep := slices.Index(fields, "-")
|
||||||
|
if sep < optionalFieldsStart || sep+1 >= len(fields) {
|
||||||
|
return nil, fmt.Errorf("%w: %q", errMountInfoLine, lines.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
var major, minor uint32
|
||||||
|
|
||||||
|
_, err := fmt.Sscanf(fields[deviceField], "%d:%d", &major, &minor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %q", errMountInfoLine, lines.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
fsType := fields[sep+1]
|
||||||
|
if slices.Contains(types, fsType) {
|
||||||
|
devices[unix.Mkdev(major, minor)] = fsType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, lines.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
var help strings.Builder
|
||||||
|
|
||||||
|
dirs := defaultExcludes(goos)
|
||||||
|
if len(dirs) == 0 {
|
||||||
|
help.WriteString("This system has no default excludes.\n")
|
||||||
|
} else {
|
||||||
|
help.WriteString("Below an operand, the walk does not enter these " +
|
||||||
|
"default excludes, unless\n--no-default-excludes is given:\n\n")
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
fmt.Fprintf(&help, " %s\n", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if goos == goosLinux || goos == goosDarwin {
|
||||||
|
help.WriteString("\nWith or without --no-default-excludes, the scan " +
|
||||||
|
"records nothing on a\nfilesystem that is not persistent, " +
|
||||||
|
"one of these types:\n\n")
|
||||||
|
|
||||||
|
types := nonPersistentFilesystemTypes()
|
||||||
|
for line := range slices.Chunk(types, typesPerHelpLine) {
|
||||||
|
fmt.Fprintf(&help, " %s\n", strings.Join(line, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return help.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nonPersistentFilesystems returns the device ID of every mounted
|
||||||
|
// filesystem that is not persistent, with its type, from the system's
|
||||||
|
// list of mounted filesystems. The device ID is read with lstat on the
|
||||||
|
// mount point. Where one filesystem is mounted over another on the same
|
||||||
|
// mount point, the later one in the list counts. A mount point that
|
||||||
|
// lstat cannot reach is left out.
|
||||||
|
func nonPersistentFilesystems() (map[uint64]string, error) {
|
||||||
|
// The first call counts the filesystems, the second lists them.
|
||||||
|
n, err := unix.Getfsstat(nil, unix.MNT_NOWAIT)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]unix.Statfs_t, n)
|
||||||
|
|
||||||
|
n, err = unix.Getfsstat(list, unix.MNT_NOWAIT)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts := make(map[string]string, n)
|
||||||
|
|
||||||
|
for _, st := range list[:n] {
|
||||||
|
mounts[unix.ByteSliceToString(st.Mntonname[:])] =
|
||||||
|
unix.ByteSliceToString(st.Fstypename[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
types := nonPersistentFilesystemTypes()
|
||||||
|
devices := make(map[uint64]string)
|
||||||
|
|
||||||
|
for mountPoint, fsType := range mounts {
|
||||||
|
if !slices.Contains(types, fsType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if dev, ok := deviceOfPath(mountPoint); ok {
|
||||||
|
devices[dev] = fsType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build !darwin
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mountInfoPath is the file in which Linux lists the mounts the reading
|
||||||
|
// process sees.
|
||||||
|
const mountInfoPath = "/proc/self/mountinfo"
|
||||||
|
|
||||||
|
// nonPersistentFilesystems returns the device ID of every mounted
|
||||||
|
// filesystem that is not persistent, with its type: on Linux read from
|
||||||
|
// mountInfoPath (see readMountInfo), and on any other system none.
|
||||||
|
func nonPersistentFilesystems() (map[uint64]string, error) {
|
||||||
|
if runtime.GOOS != goosLinux {
|
||||||
|
return map[uint64]string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(mountInfoPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
devices, err := readMountInfo(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", mountInfoPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
+583
@@ -0,0 +1,583 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultExcludes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// README "Rules for the walk": each system's list.
|
||||||
|
cases := map[string][]string{
|
||||||
|
goosLinux: {"/proc", "/sys", "/dev", "/run", "/tmp", "/var/tmp"},
|
||||||
|
goosDarwin: {
|
||||||
|
"/dev", "/private/tmp", "/private/var/tmp", "/private/var/vm",
|
||||||
|
"/System/Volumes/VM", "/System/Volumes/Data",
|
||||||
|
},
|
||||||
|
goosWindows: {
|
||||||
|
"pagefile.sys", "swapfile.sys", "hiberfil.sys",
|
||||||
|
"System Volume Information", "$Recycle.Bin", `Windows\Temp`,
|
||||||
|
`Users\*\AppData\Local\Temp`,
|
||||||
|
},
|
||||||
|
"freebsd": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
for goos, want := range cases {
|
||||||
|
if got := defaultExcludes(goos); !slices.Equal(got, want) {
|
||||||
|
t.Errorf("defaultExcludes(%q) = %q, want %q", goos, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDefaultExclude(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// A * matches one path component, no more and no fewer.
|
||||||
|
excludes := []string{"/scratch", "/home/*/cache"}
|
||||||
|
|
||||||
|
cases := map[string]bool{
|
||||||
|
"/scratch": true,
|
||||||
|
"/scratch/x": false,
|
||||||
|
"/home/u/cache": true,
|
||||||
|
"/home/u/v/cache": false,
|
||||||
|
"/home/cache": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, want := range cases {
|
||||||
|
if got := isDefaultExclude(path, excludes); got != want {
|
||||||
|
t.Errorf("isDefaultExclude(%q) = %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadMountInfo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The second-to-last line has no optional fields. The tmpfs on
|
||||||
|
// /data/a/b is hidden by the root filesystem's /srv, mounted over
|
||||||
|
// /data/a after it: the tmpfs still gives its own device, and the
|
||||||
|
// root filesystem's device is not listed.
|
||||||
|
mountinfo := strings.Join([]string{
|
||||||
|
"22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw",
|
||||||
|
"23 22 0:21 / /proc rw,nosuid shared:12 - proc none rw",
|
||||||
|
"24 22 0:22 / /sys rw,nosuid shared:7 - sysfs none rw",
|
||||||
|
"25 22 0:23 / /data/a/b rw shared:2 - tmpfs none rw",
|
||||||
|
"26 22 8:1 /srv /data/a rw - ext4 /dev/sda1 rw",
|
||||||
|
"27 22 0:25 / /srv/auto rw shared:4 - autofs systemd-1 rw",
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
got, err := readMountInfo(strings.NewReader(mountinfo))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[uint64]string{
|
||||||
|
unix.Mkdev(0, 21): "proc", unix.Mkdev(0, 22): "sysfs",
|
||||||
|
unix.Mkdev(0, 23): "tmpfs", unix.Mkdev(0, 25): "autofs",
|
||||||
|
}
|
||||||
|
if !maps.Equal(got, want) {
|
||||||
|
t.Errorf("readMountInfo = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range []string{
|
||||||
|
"22 1 8:1 / / rw,relatime\n",
|
||||||
|
"22 1 sda / / rw,relatime shared:1 - ext4 /dev/sda1 rw\n",
|
||||||
|
} {
|
||||||
|
_, err = readMountInfo(strings.NewReader(line))
|
||||||
|
if !errors.Is(err, errMountInfoLine) {
|
||||||
|
t.Errorf("readMountInfo(%q) = %v, want %v", line, err,
|
||||||
|
errMountInfoLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPersistentFilesystems(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if runtime.GOOS != goosLinux {
|
||||||
|
t.Skip("the test knows a mount only on Linux: /proc")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonPersistent, err := nonPersistentFilesystems()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ok := deviceOfPath("/proc")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("no device for /proc")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := nonPersistent[dev]; got != "proc" {
|
||||||
|
t.Errorf("filesystem of /proc = %q, want %q", got, "proc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultExcludesHelp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, goos := range []string{goosLinux, goosDarwin, goosWindows} {
|
||||||
|
help := defaultExcludesHelp(goos)
|
||||||
|
|
||||||
|
for _, dir := range defaultExcludes(goos) {
|
||||||
|
if !strings.Contains(help, "\n "+dir+"\n") {
|
||||||
|
t.Errorf("help for %s does not list %s:\n%s", goos, dir, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Linux and macOS list their mounted filesystems.
|
||||||
|
for _, fsType := range nonPersistentFilesystemTypes() {
|
||||||
|
listed := strings.Contains(help, " "+fsType+",") ||
|
||||||
|
strings.Contains(help, " "+fsType+"\n")
|
||||||
|
if listed != (goos != goosWindows) {
|
||||||
|
t.Errorf("help for %s lists %s: %v", goos, fsType, listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := "This system has no default excludes.\n"
|
||||||
|
if got := defaultExcludesHelp("freebsd"); got != want {
|
||||||
|
t.Errorf("help for freebsd = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scan's help shows those of the system it runs on.
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
run([]string{cmdScan, "-h"}, &stdout, &stderr)
|
||||||
|
|
||||||
|
help := strings.TrimSpace(defaultExcludesHelp(runtime.GOOS))
|
||||||
|
if !strings.Contains(stderr.String(), help) {
|
||||||
|
t.Errorf("scan --help = %q, want it to hold %q", stderr.String(), help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanDoesNotEnterDefaultExclude checks that the walk does not
|
||||||
|
// enter a default exclude below an operand. It says so on a line of its
|
||||||
|
// own and does not count it as skipped. The records below it count as
|
||||||
|
// outside the operands: the record of a file gone from it is kept, and
|
||||||
|
// its size joins the size census, while a file added to it is not
|
||||||
|
// recorded.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestScanDoesNotEnterDefaultExclude(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t)
|
||||||
|
excluded := filepath.Join(dir, "tmp")
|
||||||
|
|
||||||
|
// A scan with no default excludes records the file in excluded.
|
||||||
|
gone := writeFile(t, excluded, "gone", pattern(1, 100))
|
||||||
|
syncTree(t, db, dir)
|
||||||
|
|
||||||
|
err := os.Remove(gone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unseen := writeFile(t, excluded, "new", pattern(2, 10))
|
||||||
|
|
||||||
|
// The same size as gone, so it is hashed only if the census holds the
|
||||||
|
// size of gone's record.
|
||||||
|
added := writeFile(t, dir, "added", pattern(3, 100))
|
||||||
|
|
||||||
|
st, err := syncScan(t.Context(), db, []string{dir}, 1, false,
|
||||||
|
walkRules{excludes: []string{excluded}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.skipped != 0 {
|
||||||
|
t.Errorf("skipped = %d, want 0", st.skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := dbRecords(t, db)
|
||||||
|
recordByPath(t, recs, gone)
|
||||||
|
|
||||||
|
if recordByPath(t, recs, added).head == "" {
|
||||||
|
t.Errorf("%s not hashed: the census lacks the size of %s",
|
||||||
|
added, gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(recordPaths(recs), unseen) {
|
||||||
|
t.Errorf("%s recorded: the walk entered %s", unseen, excluded)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := "\nscan: skipped " + excluded + " (default exclude)\n"
|
||||||
|
if !strings.Contains(stderr(), line) {
|
||||||
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanWalksOperandInDefaultExclude checks that an operand at or
|
||||||
|
// inside a default exclude is walked, also when it lies under another
|
||||||
|
// operand, whose walk does not enter the default exclude.
|
||||||
|
func TestScanWalksOperandInDefaultExclude(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
excluded := filepath.Join(dir, "tmp")
|
||||||
|
f := writeFile(t, excluded, "sub/f", pattern(1, 10))
|
||||||
|
sub := filepath.Dir(f)
|
||||||
|
|
||||||
|
cases := map[string][]string{
|
||||||
|
"at it": {excluded},
|
||||||
|
"inside it": {sub},
|
||||||
|
"inside it under an operand": {dir, sub},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, roots := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
st, err := syncScan(t.Context(), db, roots, 1, false,
|
||||||
|
walkRules{excludes: []string{excluded}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.added != 1 {
|
||||||
|
t.Errorf("added = %d, want %s recorded once", st.added, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordByPath(t, dbRecords(t, db), f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanNamesNoDefaultExcludeThatIsAnOperand checks that a default
|
||||||
|
// exclude given as an operand under another operand, which the walk of
|
||||||
|
// that other operand does not enter, gets no line saying it was
|
||||||
|
// skipped: it is walked as an operand of its own.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestScanNamesNoDefaultExcludeThatIsAnOperand(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t)
|
||||||
|
excluded := filepath.Join(dir, "tmp")
|
||||||
|
f := writeFile(t, excluded, "f", pattern(1, 10))
|
||||||
|
|
||||||
|
st, err := syncScan(t.Context(), db, []string{dir, excluded}, 1, false,
|
||||||
|
walkRules{excludes: []string{excluded}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.added != 1 {
|
||||||
|
t.Errorf("added = %d, want %s recorded once", st.added, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordByPath(t, dbRecords(t, db), f)
|
||||||
|
|
||||||
|
if strings.Contains(stderr(), "scan: skipped") {
|
||||||
|
t.Errorf("stderr = %q, want no line for %s", stderr(), excluded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanTempDirOperand scans a tree in the tests' temporary
|
||||||
|
// directory, which on Linux lies inside the default exclude /tmp unless
|
||||||
|
// TMPDIR names another place, with the system's own default excludes:
|
||||||
|
// the scan walks the whole tree and reports no default exclude.
|
||||||
|
func TestRunScanTempDirOperand(t *testing.T) {
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
if !underAnyRoot(dir, defaultExcludes(runtime.GOOS)) {
|
||||||
|
t.Skip(dir + " is not inside a default exclude")
|
||||||
|
}
|
||||||
|
|
||||||
|
f := writeFile(t, dir, "sub/f", pattern(1, 10))
|
||||||
|
|
||||||
|
stderr := scanOK(t, dir)
|
||||||
|
if strings.Contains(stderr, "scan: skipped") {
|
||||||
|
t.Errorf("stderr = %q, want no line saying a path was skipped",
|
||||||
|
stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRecorded(t, path, f, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanNoDefaultExcludes scans /var through the command. Its
|
||||||
|
// /var/tmp, on a persistent filesystem, is a default exclude on Linux:
|
||||||
|
// without --no-default-excludes the walk does not enter it and says so,
|
||||||
|
// and with that flag the walk records a file the test puts there.
|
||||||
|
func TestRunScanNoDefaultExcludes(t *testing.T) {
|
||||||
|
if runtime.GOOS != goosLinux {
|
||||||
|
t.Skip("only Linux has /var/tmp among its default excludes")
|
||||||
|
}
|
||||||
|
|
||||||
|
nonPersistent, err := nonPersistentFilesystems()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := walkRules{nonPersistent: nonPersistent}
|
||||||
|
|
||||||
|
for _, dir := range []string{"/var", "/var/tmp"} {
|
||||||
|
fi, lstatErr := os.Lstat(dir)
|
||||||
|
if lstatErr != nil || !fi.IsDir() ||
|
||||||
|
rules.nonPersistentLine(dir, fi) != "" {
|
||||||
|
t.Skip(dir + " is not a directory on a persistent filesystem here")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:usetesting // the directory must be inside /var/tmp
|
||||||
|
tmp, err := os.MkdirTemp("/var/tmp", "sfdupes-test-")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("cannot write to /var/tmp here: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tmp) })
|
||||||
|
|
||||||
|
f := writeFile(t, tmp, "f", pattern(1, 10))
|
||||||
|
|
||||||
|
path := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, path)
|
||||||
|
|
||||||
|
line := "scan: skipped /var/tmp (default exclude)\n"
|
||||||
|
|
||||||
|
if stderr := scanOK(t, "/var"); !strings.Contains(stderr, line) {
|
||||||
|
t.Errorf("scan /var: stderr = %q, want %q", stderr, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRecorded(t, path, f, false)
|
||||||
|
|
||||||
|
stderr := scanOK(t, "--no-default-excludes", "/var")
|
||||||
|
if strings.Contains(stderr, line) {
|
||||||
|
t.Errorf("scan --no-default-excludes /var: stderr = %q, want no %q",
|
||||||
|
stderr, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertRecorded(t, path, f, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// so, and nothing in it is recorded.
|
||||||
|
func TestRunScanSkipsNonPersistentOperand(t *testing.T) {
|
||||||
|
shm, fsType, _ := shmTempDir(t)
|
||||||
|
writeFile(t, shm, "f", pattern(1, 10))
|
||||||
|
|
||||||
|
t.Setenv(databaseEnv, testDBPath(t))
|
||||||
|
|
||||||
|
line := "scan: skipped " + shm + " (" + fsType +
|
||||||
|
", not a persistent filesystem)\n"
|
||||||
|
summary := "scan: 0 files seen"
|
||||||
|
|
||||||
|
for _, flags := range [][]string{nil, {"--no-default-excludes"}} {
|
||||||
|
stderr := scanOK(t, append(flags, shm)...)
|
||||||
|
if !strings.Contains(stderr, line) ||
|
||||||
|
!strings.Contains(stderr, summary) {
|
||||||
|
t.Errorf("scan %q: stderr = %q, want %q and %q", flags, stderr,
|
||||||
|
line, summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanKeepsRecordsOnNonPersistent scans a directory on a filesystem
|
||||||
|
// that is not persistent beside one on the tests' temporary directory.
|
||||||
|
// The records an earlier scan left in it count as outside the operands:
|
||||||
|
// the record of a file gone from it is kept, and its size joins the size
|
||||||
|
// census, while a file added to it is not recorded, and its size does
|
||||||
|
// not join the census.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestScanKeepsRecordsOnNonPersistent(t *testing.T) {
|
||||||
|
shm, fsType, rules := shmTempDir(t)
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
// A scan that, without the list of filesystems, records gone.
|
||||||
|
gone := writeFile(t, shm, "gone", pattern(1, 100))
|
||||||
|
syncTree(t, db, shm)
|
||||||
|
|
||||||
|
err := os.Remove(gone)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unseen := writeFile(t, shm, "new", pattern(2, 10))
|
||||||
|
|
||||||
|
// added has the size of gone, and lonely that of unseen: each is
|
||||||
|
// hashed only if the census holds that size.
|
||||||
|
added := writeFile(t, dir, "added", pattern(3, 100))
|
||||||
|
lonely := writeFile(t, dir, "lonely", pattern(4, 10))
|
||||||
|
|
||||||
|
st, err := syncScan(t.Context(), db, []string{shm, dir}, 1, false, rules)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.skipped != 0 {
|
||||||
|
t.Errorf("skipped = %d, want 0", st.skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := dbRecords(t, db)
|
||||||
|
recordByPath(t, recs, gone)
|
||||||
|
|
||||||
|
if recordByPath(t, recs, added).head == "" {
|
||||||
|
t.Errorf("%s not hashed: the census lacks the size of %s",
|
||||||
|
added, gone)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordByPath(t, recs, lonely).head != "" {
|
||||||
|
t.Errorf("%s hashed: the census holds the size of %s", lonely, unseen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(recordPaths(recs), unseen) {
|
||||||
|
t.Errorf("%s recorded", unseen)
|
||||||
|
}
|
||||||
|
|
||||||
|
line := "scan: skipped " + shm + " (" + fsType +
|
||||||
|
", not a persistent filesystem)\n"
|
||||||
|
if !strings.Contains(stderr(), line) {
|
||||||
|
t.Errorf("stderr = %q, want %q", stderr(), line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanContentSkipsNonPersistent checks that the content phase does
|
||||||
|
// not read a file on a filesystem that is not persistent, though an
|
||||||
|
// earlier scan left a record for it: it is treated as a changed file,
|
||||||
|
// so its record is left as it is and its identical copy in another
|
||||||
|
// tree, having no other match, is not read either.
|
||||||
|
func TestScanContentSkipsNonPersistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
shm, _, rules := shmTempDir(t)
|
||||||
|
db := openTestDB(t)
|
||||||
|
|
||||||
|
// A scan that, without the list of filesystems, records a with a
|
||||||
|
// head and tail but no content hash.
|
||||||
|
a := sparseFileWithoutMatch(t, shm, "a", headTailMin)
|
||||||
|
|
||||||
|
syncTree(t, db, shm)
|
||||||
|
|
||||||
|
before := recordByPath(t, dbRecords(t, db), a)
|
||||||
|
if before.head == "" || before.content != "" {
|
||||||
|
t.Fatalf("after scanning %s: %+v, want head and tail only", shm, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := sparseFile(t, t.TempDir(), "b", headTailMin)
|
||||||
|
|
||||||
|
st, err := syncScan(t.Context(), db, []string{filepath.Dir(b)}, 1, false,
|
||||||
|
rules)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.skipped != 0 {
|
||||||
|
t.Errorf("skipped = %d, want 0", st.skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
recs := dbRecords(t, db)
|
||||||
|
if r := recordByPath(t, recs, a); r != before {
|
||||||
|
t.Errorf("record = %+v, want it left as %+v", r, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
if r := recordByPath(t, recs, b); r.content != "" {
|
||||||
|
t.Errorf("%s: content = %q, want none: its only match is on %s",
|
||||||
|
b, r.content, shm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWalkLeavesOutNonPersistent starts the walk itself in a directory
|
||||||
|
// on a filesystem that is not persistent, which a scan never does: it
|
||||||
|
// drops such an operand first. Below a directory on a persistent
|
||||||
|
// filesystem, one on another takes a mount, which a test cannot make.
|
||||||
|
// The walk enters no directory and records no file there, and names
|
||||||
|
// each.
|
||||||
|
func TestWalkLeavesOutNonPersistent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
shm, fsType, rules := shmTempDir(t)
|
||||||
|
f := writeFile(t, shm, "f", pattern(1, 10))
|
||||||
|
sub := filepath.Dir(writeFile(t, shm, "sub/g", pattern(1, 10)))
|
||||||
|
|
||||||
|
var leftOut []string
|
||||||
|
|
||||||
|
for ev := range startWalk(t.Context(), []string{shm}, false, rules, 1) {
|
||||||
|
if ev.rec.path != "" {
|
||||||
|
t.Errorf("walk recorded %s", ev.rec.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.leftOut == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
leftOut = append(leftOut, ev.leftOut)
|
||||||
|
|
||||||
|
want := "scan: skipped " + ev.leftOut + " (" + fsType +
|
||||||
|
", not a persistent filesystem)"
|
||||||
|
if ev.warn != want {
|
||||||
|
t.Errorf("line = %q, want %q", ev.warn, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(leftOut)
|
||||||
|
|
||||||
|
if want := []string{f, sub}; !slices.Equal(leftOut, want) {
|
||||||
|
t.Errorf("walk left out %q, want %q", leftOut, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shmTempDir returns a new directory in /dev/shm, removed when the test
|
||||||
|
// ends, the type of its filesystem, and the rules holding the real
|
||||||
|
// filesystems that are not persistent. It skips the test where /dev/shm
|
||||||
|
// is on a persistent filesystem, or cannot be written to.
|
||||||
|
func shmTempDir(t *testing.T) (string, string, walkRules) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
nonPersistent, err := nonPersistentFilesystems()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ok := deviceOfPath("/dev/shm")
|
||||||
|
|
||||||
|
fsType, found := nonPersistent[dev]
|
||||||
|
if !ok || !found {
|
||||||
|
t.Skip("/dev/shm is not a memory-backed filesystem here")
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:usetesting // the directory must be inside /dev/shm
|
||||||
|
dir, err := os.MkdirTemp("/dev/shm", "sfdupes-test-")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("cannot write to /dev/shm here: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||||
|
|
||||||
|
return dir, fsType, walkRules{nonPersistent: nonPersistent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertRecorded checks that the database at dbPath holds a record for
|
||||||
|
// path when want is set, and none when it is not.
|
||||||
|
func assertRecorded(t *testing.T, dbPath, path string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := openDB(dbPath, reportParams)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
if got := slices.Contains(recordPaths(dbRecords(t, db)), path); got != want {
|
||||||
|
t.Errorf("%s recorded: %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,3 +24,5 @@ require (
|
|||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
retract v0.0.1 // early build without a database; does not work as the README describes
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//
|
//
|
||||||
// Usage:
|
// Usage:
|
||||||
//
|
//
|
||||||
// sfdupes scan [--workers N] [-x] PATH...
|
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
|
||||||
// sfdupes report > dupes.tsv
|
// sfdupes report > dupes.tsv
|
||||||
// sfdupes trees > dupetrees.tsv
|
// sfdupes trees > dupetrees.tsv
|
||||||
// sfdupes info
|
// sfdupes info
|
||||||
@@ -28,6 +28,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -58,10 +59,32 @@ var errNoSubcommand = errors.New("no subcommand")
|
|||||||
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
||||||
|
|
||||||
// Version is the build version, injected at link time via -ldflags
|
// Version is the build version, injected at link time via -ldflags
|
||||||
// (see the Makefile); "dev" for a plain go build.
|
// (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
|
//nolint:gochecknoglobals // written only by the linker
|
||||||
var Version = "dev"
|
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() {
|
func main() {
|
||||||
// Once the reader of a stdout pipe has gone, as in "sfdupes report |
|
// Once the reader of a stdout pipe has gone, as in "sfdupes report |
|
||||||
@@ -116,7 +139,10 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
|||||||
var showVersion bool
|
var showVersion bool
|
||||||
|
|
||||||
printVersion := runE(func(context.Context, []string) error {
|
printVersion := runE(func(context.Context, []string) error {
|
||||||
_, err := fmt.Fprintf(stdout, "sfdupes %s\n", Version)
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|
||||||
|
_, err := fmt.Fprintf(stdout, "sfdupes %s\n",
|
||||||
|
buildVersion(Version, info))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write stdout: %w", err)
|
return fmt.Errorf("write stdout: %w", err)
|
||||||
}
|
}
|
||||||
@@ -182,17 +208,23 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
// newScanCommand builds the scan subcommand, with its --workers and -x
|
// newScanCommand builds the scan subcommand, with its --workers, -x and
|
||||||
// flags.
|
// --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 {
|
func newScanCommand() *cobra.Command {
|
||||||
var (
|
var (
|
||||||
scanWorkers int
|
scanWorkers int
|
||||||
scanOneFS bool
|
scanOneFS bool
|
||||||
|
scanNoDefaultExcludes bool
|
||||||
)
|
)
|
||||||
|
|
||||||
|
short := "Walk trees and synchronize the scan database"
|
||||||
|
|
||||||
scanCmd := &cobra.Command{
|
scanCmd := &cobra.Command{
|
||||||
Use: cmdScan + " [--workers N] [-x] PATH...",
|
Use: cmdScan + " [--workers N] [-x] [--no-default-excludes] PATH...",
|
||||||
Short: "Walk trees and synchronize the scan database",
|
Short: short,
|
||||||
|
Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS),
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return checkScanWorkers(cmd, scanWorkers)
|
return checkScanWorkers(cmd, scanWorkers)
|
||||||
@@ -201,13 +233,16 @@ func newScanCommand() *cobra.Command {
|
|||||||
ctx, stop := interruptContext(ctx)
|
ctx, stop := interruptContext(ctx)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
return runScan(ctx, args, scanWorkers, scanOneFS)
|
return runScan(ctx, args, scanWorkers, scanOneFS,
|
||||||
|
scanNoDefaultExcludes)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
|
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
|
||||||
"concurrent workers for the walk, hash, and content phases")
|
"concurrent workers for the walk, hash, and content phases")
|
||||||
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
|
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
|
||||||
"do not cross filesystem boundaries")
|
"do not cross filesystem boundaries")
|
||||||
|
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
|
||||||
|
false, "enter the default excludes, the paths listed above")
|
||||||
|
|
||||||
return scanCmd
|
return scanCmd
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-2
@@ -4,10 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +20,36 @@ import (
|
|||||||
// error prints and a runtime failure must not.
|
// error prints and a runtime failure must not.
|
||||||
const usageMarker = "Usage:"
|
const usageMarker = "Usage:"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// without saying why.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
nonPersistent, err := nonPersistentFilesystems()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "list the mounted filesystems: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := os.TempDir()
|
||||||
|
|
||||||
|
fi, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ok := deviceOfInfo(fi)
|
||||||
|
if fsType, found := nonPersistent[dev]; ok && found {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s is on %s, not a persistent filesystem, "+
|
||||||
|
"and scan records nothing there: set TMPDIR to a directory on a "+
|
||||||
|
"persistent filesystem\n", dir, fsType)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
// walSuffixes are the SQLite sidecar files a WAL-mode database keeps
|
// walSuffixes are the SQLite sidecar files a WAL-mode database keeps
|
||||||
// while it is open. A clean close checkpoints the WAL and removes
|
// while it is open. A clean close checkpoints the WAL and removes
|
||||||
// both; finding either afterwards means the database was never closed.
|
// both; finding either afterwards means the database was never closed.
|
||||||
@@ -535,7 +567,7 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
cmdScan: {
|
cmdScan: {
|
||||||
run: func() error {
|
run: func() error {
|
||||||
return runScan(t.Context(), []string{missing}, 1, false)
|
return runScan(t.Context(), []string{missing}, 1, false, false)
|
||||||
},
|
},
|
||||||
want: scanWant,
|
want: scanWant,
|
||||||
},
|
},
|
||||||
@@ -683,6 +715,8 @@ func TestRunVersion(t *testing.T) {
|
|||||||
|
|
||||||
// README §Subcommands: the version is one line on stdout, with
|
// README §Subcommands: the version is one line on stdout, with
|
||||||
// nothing on stderr, and exits 0.
|
// nothing on stderr, and exits 0.
|
||||||
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|
||||||
for _, arg := range []string{"--version", "-v"} {
|
for _, arg := range []string{"--version", "-v"} {
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
@@ -691,7 +725,7 @@ func TestRunVersion(t *testing.T) {
|
|||||||
t.Errorf("run(%s) = %d, want %d", arg, code, exitOK)
|
t.Errorf("run(%s) = %d, want %d", arg, code, exitOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
want := "sfdupes " + Version + "\n"
|
want := "sfdupes " + buildVersion(Version, info) + "\n"
|
||||||
if got := stdout.String(); got != want {
|
if got := stdout.String(); got != want {
|
||||||
t.Errorf("run(%s) stdout = %q, want %q", arg, got, want)
|
t.Errorf("run(%s) stdout = %q, want %q", arg, got, want)
|
||||||
}
|
}
|
||||||
@@ -702,6 +736,40 @@ func TestRunVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// README "Install": the linker's version wins; otherwise the main
|
||||||
|
// module's version that Go recorded; "dev" when neither is known.
|
||||||
|
tagged := &debug.BuildInfo{Main: debug.Module{Version: "v0.0.2"}}
|
||||||
|
pseudo := &debug.BuildInfo{Main: debug.Module{
|
||||||
|
Version: "v1.0.0-rc2.0.20261016120000-4e4a94d0c2a1",
|
||||||
|
}}
|
||||||
|
devel := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
linker string
|
||||||
|
info *debug.BuildInfo
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"linker set", "v0.0.2-3-g4e4a94d", tagged, "v0.0.2-3-g4e4a94d"},
|
||||||
|
{"linker set, no build info", "4e4a94d", nil, "4e4a94d"},
|
||||||
|
{"build info with a tag", "", tagged, "v0.0.2"},
|
||||||
|
{"build info with a pseudo-version", "", pseudo,
|
||||||
|
"v1.0.0-rc2.0.20261016120000-4e4a94d0c2a1"},
|
||||||
|
{"(devel)", "", devel, "dev"},
|
||||||
|
{"no build info", "", nil, "dev"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := buildVersion(c.linker, c.info); got != c.want {
|
||||||
|
t.Errorf("%s: buildVersion(%q, ...) = %q, want %q",
|
||||||
|
c.name, c.linker, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
|
func TestRunVersionWriteFailureIsFatal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+103
-41
@@ -22,6 +22,15 @@ const plainInterval = 5 * time.Second
|
|||||||
// a TTY.
|
// a TTY.
|
||||||
const redrawInterval = 100 * time.Millisecond
|
const redrawInterval = 100 * time.Millisecond
|
||||||
|
|
||||||
|
// rateWindow is the time the rate a display shows covers: the items
|
||||||
|
// completed over the last rateWindow, which is the time since the line
|
||||||
|
// before when stderr is not a TTY.
|
||||||
|
const rateWindow = 5 * time.Second
|
||||||
|
|
||||||
|
// stallTime is how long a phase goes with no item completed before its
|
||||||
|
// line says it has stalled, and where it waits, in place of the rate.
|
||||||
|
const stallTime = 10 * time.Second
|
||||||
|
|
||||||
// spinnerType selects the progressbar library's spinner style.
|
// spinnerType selects the progressbar library's spinner style.
|
||||||
const spinnerType = 14
|
const spinnerType = 14
|
||||||
|
|
||||||
@@ -89,6 +98,14 @@ type progressParams struct {
|
|||||||
total int64 // the items the phase will complete; -1 when not known
|
total int64 // the items the phase will complete; -1 when not known
|
||||||
estimate bool // total is an estimate, which the count may pass
|
estimate bool // total is an estimate, which the count may pass
|
||||||
waitingFor string // what the phase waits for until setTotal is called
|
waitingFor string // what the phase waits for until setTotal is called
|
||||||
|
stalledOn string // where the phase waits, as "disk/db"; see stallTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawnCount is the count of items completed as of one draw, and when
|
||||||
|
// that draw was.
|
||||||
|
type drawnCount struct {
|
||||||
|
at time.Time
|
||||||
|
count int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// progress renders one phase's progress on stderr. On a TTY the
|
// progress renders one phase's progress on stderr. On a TTY the
|
||||||
@@ -107,9 +124,10 @@ type progressParams struct {
|
|||||||
// phase's helpers without rendering anything, and its writer writes
|
// phase's helpers without rendering anything, and its writer writes
|
||||||
// with no display to clear.
|
// with no display to clear.
|
||||||
type progress struct {
|
type progress struct {
|
||||||
width func() int // reads the terminal's width; nil when not a TTY
|
now func() time.Time // reads the time
|
||||||
stop chan struct{} // closed by finish to end the draws
|
width func() int // reads the terminal's width; nil when not a TTY
|
||||||
done chan struct{} // closed once the draws have ended
|
stop chan struct{} // closed by finish to end the draws
|
||||||
|
done chan struct{} // closed once the draws have ended
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
bar *progressbar.ProgressBar // what the library draws; nil until then
|
bar *progressbar.ProgressBar // what the library draws; nil until then
|
||||||
@@ -120,33 +138,42 @@ type progress struct {
|
|||||||
total int64
|
total int64
|
||||||
estimate bool
|
estimate bool
|
||||||
waitingFor string
|
waitingFor string
|
||||||
|
stalledOn string
|
||||||
count int64
|
count int64
|
||||||
start time.Time // when the phase started, for the elapsed time
|
start time.Time // when the phase started, for the elapsed time
|
||||||
rateStart time.Time // when the count started, for the rate
|
rateStart time.Time // when the count started, for the ETA
|
||||||
|
lastItem time.Time // when the last item was completed, or the count started
|
||||||
|
// drawn holds the count at each draw over the last rateWindow, oldest
|
||||||
|
// first; the rate counts from the first. It keeps the draw before the
|
||||||
|
// latest even when that is older, as the line before is when lines
|
||||||
|
// are plainInterval apart and a tick comes late.
|
||||||
|
drawn []drawnCount
|
||||||
}
|
}
|
||||||
|
|
||||||
// newProgress starts the display of one phase on stderr.
|
// newProgress starts the display of one phase on stderr.
|
||||||
func newProgress(params progressParams) *progress {
|
func newProgress(params progressParams) *progress {
|
||||||
if stderrIsTTY() {
|
if stderrIsTTY() {
|
||||||
return startProgress(params, terminalWidth, redrawInterval)
|
return startProgress(params, terminalWidth, redrawInterval, time.Now)
|
||||||
}
|
}
|
||||||
|
|
||||||
return startProgress(params, nil, plainInterval)
|
return startProgress(params, nil, plainInterval, time.Now)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startProgress shows the phase params describes at once, and again
|
// startProgress shows the phase params describes at once, and again
|
||||||
// every interval until finish: with the progressbar library on a
|
// every interval until finish: with the progressbar library on a
|
||||||
// terminal, whose width each draw reads by calling width, or as a plain
|
// terminal, whose width each draw reads by calling width, or as a plain
|
||||||
// line when width is nil.
|
// line when width is nil. The display reads the time by calling now.
|
||||||
func startProgress(params progressParams, width func() int,
|
func startProgress(params progressParams, width func() int,
|
||||||
interval time.Duration,
|
interval time.Duration, now func() time.Time,
|
||||||
) *progress {
|
) *progress {
|
||||||
now := time.Now()
|
start := now()
|
||||||
p := &progress{
|
p := &progress{
|
||||||
width: width, stop: make(chan struct{}), done: make(chan struct{}),
|
now: now, width: width,
|
||||||
|
stop: make(chan struct{}), done: make(chan struct{}),
|
||||||
label: params.label, unit: params.unit, total: params.total,
|
label: params.label, unit: params.unit, total: params.total,
|
||||||
estimate: params.estimate, waitingFor: params.waitingFor,
|
estimate: params.estimate, waitingFor: params.waitingFor,
|
||||||
start: now, rateStart: now,
|
stalledOn: params.stalledOn, start: start, rateStart: start,
|
||||||
|
lastItem: start, drawn: []drawnCount{{at: start}},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the zero state at once: the first item may take minutes,
|
// Show the zero state at once: the first item may take minutes,
|
||||||
@@ -173,32 +200,42 @@ func startProgress(params progressParams, width func() int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// draw shows the phase's progress: on a TTY in place of the line
|
// draw shows the phase's progress: on a TTY in place of the line
|
||||||
// before, and otherwise as a plain line.
|
// before, and otherwise as a plain line. It first adds the count to
|
||||||
|
// drawn, then drops from drawn the draws older than rateWindow, except
|
||||||
|
// the one before this draw.
|
||||||
func (p *progress) draw() {
|
func (p *progress) draw() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
now := p.now()
|
||||||
|
|
||||||
|
p.drawn = append(p.drawn, drawnCount{at: now, count: p.count})
|
||||||
|
for len(p.drawn) > 2 && now.Sub(p.drawn[0].at) > rateWindow {
|
||||||
|
p.drawn = p.drawn[1:]
|
||||||
|
}
|
||||||
|
|
||||||
if p.width == nil {
|
if p.width == nil {
|
||||||
text, _ := p.line(math.MaxInt, true)
|
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, true))
|
||||||
fmt.Fprintln(os.Stderr, text)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
width := p.width()
|
width := p.width()
|
||||||
|
|
||||||
// A phase with an exact total gets the library's bar when its line,
|
// A phase with an exact total gets the library's bar when its whole
|
||||||
// without the percent the bar shows, fits whole beside the bar. A
|
// line, without the percent the bar shows, fits beside the bar: the
|
||||||
// total of 0 leaves the bar nothing to fill.
|
// bar gives way before any part of the line. A total of 0 leaves the
|
||||||
|
// bar nothing to fill.
|
||||||
text, withBar := "", false
|
text, withBar := "", false
|
||||||
if p.total > 0 && !p.estimate {
|
if p.total > 0 && !p.estimate {
|
||||||
text, withBar = p.line(width-barMargin, false)
|
text = p.line(now, math.MaxInt, false)
|
||||||
|
withBar = len(text) <= width-barMargin
|
||||||
}
|
}
|
||||||
|
|
||||||
margin := barMargin
|
margin := barMargin
|
||||||
if !withBar {
|
if !withBar {
|
||||||
margin = spinnerMargin
|
margin = spinnerMargin
|
||||||
text, _ = p.line(width-margin, true)
|
text = p.line(now, width-margin, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The library makes a bar or a spinner, and before each draw erases
|
// The library makes a bar or a spinner, and before each draw erases
|
||||||
@@ -255,18 +292,22 @@ func (p *progress) newBar(withBar bool, width int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setTotal gives a phase that started without a total its total, once
|
// setTotal gives a phase that started without a total its total, once
|
||||||
// it is known. The rate, and so the ETA, count from then on: the time
|
// it is known. The rate, the ETA and the time with no item completed
|
||||||
// spent waiting for the total says nothing about how fast items go.
|
// count from then on: the time spent waiting for the total says nothing
|
||||||
|
// about how fast items go.
|
||||||
func (p *progress) setTotal(total int64) {
|
func (p *progress) setTotal(total int64) {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := p.now()
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
p.total = total
|
p.total = total
|
||||||
p.rateStart = time.Now()
|
p.rateStart, p.lastItem = now, now
|
||||||
|
p.drawn = []drawnCount{{at: now, count: p.count}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// increment records one completed item, which the display shows at its
|
// increment records one completed item, which the display shows at its
|
||||||
@@ -276,10 +317,13 @@ func (p *progress) increment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := p.now()
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
p.count++
|
p.count++
|
||||||
|
p.lastItem = now
|
||||||
}
|
}
|
||||||
|
|
||||||
// warnf prints a one-line warning to stderr without corrupting the
|
// warnf prints a one-line warning to stderr without corrupting the
|
||||||
@@ -391,50 +435,68 @@ func (p *progress) finish() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// line formats the phase's progress in at most room columns, as
|
// line formats the phase's progress as of now in at most room columns,
|
||||||
|
// as
|
||||||
//
|
//
|
||||||
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
|
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
|
||||||
//
|
//
|
||||||
// where "files" is the phase's unit, without the percent when percent
|
// where "files" is the phase's unit, without the percent when percent
|
||||||
// is false, as before the library's bar, which shows its own. A phase
|
// is false, as before the library's bar, which shows its own. A phase
|
||||||
// with no total shows the items done instead, or what it waits for.
|
// with no total shows the items done instead, or what it waits for. The
|
||||||
|
// rate is that since the first draw in drawn, and the ETA comes from
|
||||||
|
// the rate since the count started. Once no item has been completed for
|
||||||
|
// stallTime, the rate, which would read 0, gives way to the word
|
||||||
|
// "stalled" and where the phase waits, as "stalled disk/db".
|
||||||
//
|
//
|
||||||
// The phase and its place, the progress, the elapsed time and the ETA
|
// The phase and its place, the progress, the elapsed time and the ETA
|
||||||
// are kept whole. A line wider than room leaves out the rate, or what
|
// are kept whole. A line wider than room leaves out the rate, the words
|
||||||
// the phase waits for; only if it is still wider is it cut at room, and
|
// of a stalled phase, or what the phase waits for; only if it is still
|
||||||
// line reports that it does not fit. Every part is ASCII, so its length
|
// wider is it cut at room. Every part is ASCII, so its length is its
|
||||||
// is its width in columns. p.mu must be held.
|
// width in columns. p.mu must be held.
|
||||||
func (p *progress) line(room int, percent bool) (string, bool) {
|
func (p *progress) line(now time.Time, room int, percent bool) string {
|
||||||
elapsed := time.Since(p.start).Round(time.Second)
|
elapsed := now.Sub(p.start).Round(time.Second)
|
||||||
|
|
||||||
// head and tail hold the parts kept whole; extra, between them, is
|
// head and tail hold the parts kept whole; extra, between them, is
|
||||||
// the part left out.
|
// the part left out.
|
||||||
var head, extra, tail string
|
var head, extra, tail string
|
||||||
|
|
||||||
if p.total < 0 && p.waitingFor != "" {
|
if p.total < 0 && p.waitingFor != "" {
|
||||||
head, extra = p.label+": ", p.waitingFor
|
head, extra = p.label+": ", p.waitingFor+", "
|
||||||
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
|
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
|
||||||
} else {
|
} else {
|
||||||
rate := 0.0
|
since := p.drawn[0]
|
||||||
if s := time.Since(p.rateStart).Seconds(); s > 0 {
|
rate := perSecond(p.count-since.count, now.Sub(since.at))
|
||||||
rate = float64(p.count) / s
|
average := perSecond(p.count, now.Sub(p.rateStart))
|
||||||
}
|
|
||||||
|
|
||||||
head = p.label + ": " + p.itemsDone(percent) + ", "
|
head = p.label + ": " + p.itemsDone(percent) + ", "
|
||||||
extra = fmt.Sprintf("%.0f %s/s", rate, p.unit)
|
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
|
||||||
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(rate))
|
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(average))
|
||||||
|
|
||||||
|
if now.Sub(p.lastItem) >= stallTime {
|
||||||
|
extra = "stalled " + p.stalledOn + ", "
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
full, kept := head+extra+", "+tail, head+tail
|
full, kept := head+extra+tail, head+tail
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(full) <= room:
|
case len(full) <= room:
|
||||||
return full, true
|
return full
|
||||||
case len(kept) <= room:
|
case len(kept) <= room:
|
||||||
return kept, true
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
return kept[:max(room, 0)], false
|
return kept[:max(room, 0)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// perSecond returns count items over d as items a second, or 0 when no
|
||||||
|
// time has passed.
|
||||||
|
func perSecond(count int64, d time.Duration) float64 {
|
||||||
|
if d <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return float64(count) / d.Seconds()
|
||||||
}
|
}
|
||||||
|
|
||||||
// itemsDone formats the items done, as "[1234/5000] 25%", with "~"
|
// itemsDone formats the items done, as "[1234/5000] 25%", with "~"
|
||||||
|
|||||||
+316
-48
@@ -6,7 +6,9 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -20,6 +22,33 @@ const testRedraw = time.Millisecond
|
|||||||
// it expects.
|
// it expects.
|
||||||
const testDeadline = 5 * time.Second
|
const testDeadline = 5 * time.Second
|
||||||
|
|
||||||
|
// hashStalledOn is where the hash phase says it waits once it has
|
||||||
|
// stalled, as readRuns gives it.
|
||||||
|
const hashStalledOn = "disk/db"
|
||||||
|
|
||||||
|
// testClock is a clock a test moves by hand, for a display that reads
|
||||||
|
// it from its own goroutine too.
|
||||||
|
type testClock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
now time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now returns the clock's time.
|
||||||
|
func (c *testClock) Now() time.Time {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
return c.now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add moves the clock on by d.
|
||||||
|
func (c *testClock) Add(d time.Duration) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.now = c.now.Add(d)
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // replaces the process-wide os.Stderr
|
//nolint:paralleltest // replaces the process-wide os.Stderr
|
||||||
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
|
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
|
||||||
r, pipe, err := os.Pipe()
|
r, pipe, err := os.Pipe()
|
||||||
@@ -72,11 +101,13 @@ func TestPhaseLabel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestProgressLine checks the line a display shows in each state, ten
|
// TestProgressLine checks the line a display shows in each state, ten
|
||||||
// seconds into the phase.
|
// seconds into the phase, with no draw since the first and the last
|
||||||
|
// item just completed.
|
||||||
func TestProgressLine(t *testing.T) {
|
func TestProgressLine(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ago := time.Now().Add(-10 * time.Second)
|
now := time.Now()
|
||||||
|
ago := now.Add(-10 * time.Second)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
p *progress
|
p *progress
|
||||||
@@ -129,90 +160,120 @@ func TestProgressLine(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
tc.p.start, tc.p.rateStart = ago, ago
|
tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now
|
||||||
|
tc.p.drawn = []drawnCount{{at: ago}}
|
||||||
|
|
||||||
if got, _ := tc.p.line(math.MaxInt, true); got != tc.want {
|
if got := tc.p.line(now, math.MaxInt, true); got != tc.want {
|
||||||
t.Errorf("line = %q, want %q", got, tc.want)
|
t.Errorf("line = %q, want %q", got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLineFitsRoom checks how a line is fitted to the room it has, ten
|
// TestLineFitsRoom checks how a line is fitted to the room it has, ten
|
||||||
// seconds into the phase: it leaves out the rate, or what the phase
|
// seconds into the phase, and fifty into a phase with no file read for
|
||||||
// waits for, and is cut only when what is left is still too wide.
|
// the last 42: it leaves out the rate, what the phase waits for, or the
|
||||||
|
// words of a stalled phase, and is cut only when what is left is still
|
||||||
|
// too wide.
|
||||||
func TestLineFitsRoom(t *testing.T) {
|
func TestLineFitsRoom(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
ago := time.Now().Add(-10 * time.Second)
|
now := time.Now()
|
||||||
|
ago := now.Add(-10 * time.Second)
|
||||||
hash := &progress{
|
hash := &progress{
|
||||||
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
|
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
|
||||||
start: ago, rateStart: ago,
|
start: ago, rateStart: ago, lastItem: now,
|
||||||
|
drawn: []drawnCount{{at: ago}},
|
||||||
}
|
}
|
||||||
content := &progress{
|
content := &progress{
|
||||||
label: scanPhase("content"), total: -1,
|
label: scanPhase("content"), total: -1,
|
||||||
waitingFor: "searching the database", start: ago, rateStart: ago,
|
waitingFor: "searching the database", start: ago, rateStart: ago,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
started := now.Add(-50 * time.Second)
|
||||||
|
stalled := &progress{
|
||||||
|
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
|
||||||
|
stalledOn: hashStalledOn, start: started, rateStart: started,
|
||||||
|
lastItem: now.Add(-42 * time.Second),
|
||||||
|
drawn: []drawnCount{{at: now.Add(-rateWindow), count: 20}},
|
||||||
|
}
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
p *progress
|
p *progress
|
||||||
room int
|
room int
|
||||||
percent bool
|
percent bool
|
||||||
want string
|
want string
|
||||||
fits bool
|
|
||||||
}{{
|
}{{
|
||||||
p: hash, room: 57, percent: true,
|
p: hash, room: 57, percent: true,
|
||||||
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
||||||
fits: true,
|
|
||||||
}, {
|
}, {
|
||||||
p: hash, room: 56, percent: true,
|
p: hash, room: 56, percent: true,
|
||||||
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s",
|
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s",
|
||||||
fits: true,
|
|
||||||
}, {
|
}, {
|
||||||
p: hash, room: 45, percent: true,
|
p: hash, room: 45, percent: true,
|
||||||
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40",
|
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40",
|
||||||
fits: false,
|
|
||||||
}, {
|
}, {
|
||||||
// Before the library's bar, which shows the percent.
|
// Before the library's bar, which shows the percent.
|
||||||
p: hash, room: 57, percent: false,
|
p: hash, room: 57, percent: false,
|
||||||
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
|
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
|
||||||
fits: true,
|
|
||||||
}, {
|
}, {
|
||||||
p: content, room: 40, percent: true,
|
p: content, room: 40, percent: true,
|
||||||
want: "[5/5] content: elapsed 10s, eta unknown",
|
want: "[5/5] content: elapsed 10s, eta unknown",
|
||||||
fits: true,
|
}, {
|
||||||
|
p: stalled, room: 65, percent: true,
|
||||||
|
want: "[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
|
||||||
|
"eta 3m20s",
|
||||||
|
}, {
|
||||||
|
p: stalled, room: 64, percent: true,
|
||||||
|
want: "[3/5] hash: [20/100] 20%, elapsed 50s, eta 3m20s",
|
||||||
}}
|
}}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
got, fits := tc.p.line(tc.room, tc.percent)
|
if got := tc.p.line(now, tc.room, tc.percent); got != tc.want {
|
||||||
if got != tc.want || fits != tc.fits {
|
t.Errorf("line(%d, %t) = %q, want %q",
|
||||||
t.Errorf("line(%d, %t) = %q, %t, want %q, %t",
|
tc.room, tc.percent, got, tc.want)
|
||||||
tc.room, tc.percent, got, fits, tc.want, tc.fits)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSetTotalStartsRate checks that setTotal starts a phase's rate,
|
// TestSetTotalStartsRate checks that setTotal starts a phase's rate,
|
||||||
// and so its ETA, from when the total becomes known, not from when the
|
// its ETA and its time with no record read from when the total becomes
|
||||||
// phase started waiting for it.
|
// known, not from when the phase started waiting for it: after 20
|
||||||
|
// seconds of waiting for the total, the phase has not stalled 5 seconds
|
||||||
|
// after it, and has 10 seconds after it with no record read.
|
||||||
func TestSetTotalStartsRate(t *testing.T) {
|
func TestSetTotalStartsRate(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
waited := time.Now().Add(-20 * time.Second)
|
clock := &testClock{now: time.Now()}
|
||||||
|
waited := clock.Now().Add(-20 * time.Second)
|
||||||
p := &progress{
|
p := &progress{
|
||||||
label: scanPhase("load"), unit: scanUnit, total: -1, start: waited,
|
now: clock.Now, label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||||
rateStart: waited,
|
stalledOn: "db", start: waited, rateStart: waited,
|
||||||
|
lastItem: waited, drawn: []drawnCount{{at: waited}},
|
||||||
}
|
}
|
||||||
|
|
||||||
p.setTotal(100)
|
p.setTotal(100)
|
||||||
|
|
||||||
// Ten seconds after the total came, without waiting ten seconds,
|
clock.Add(5 * time.Second)
|
||||||
// 20 records are read.
|
|
||||||
p.start = p.start.Add(-10 * time.Second)
|
|
||||||
p.rateStart = p.rateStart.Add(-10 * time.Second)
|
|
||||||
p.count = 20
|
|
||||||
|
|
||||||
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
|
want := "[1/5] load: [0/100] 0%, 0 files/s, elapsed 25s, eta unknown"
|
||||||
if got, _ := p.line(math.MaxInt, true); got != want {
|
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
|
||||||
|
t.Errorf("line = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Add(5 * time.Second)
|
||||||
|
|
||||||
|
want = "[1/5] load: [0/100] 0%, stalled db, elapsed 30s, eta unknown"
|
||||||
|
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
|
||||||
|
t.Errorf("line = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then 20 records are read.
|
||||||
|
for range 20 {
|
||||||
|
p.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
want = "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
|
||||||
|
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
|
||||||
t.Errorf("line = %q, want %q", got, want)
|
t.Errorf("line = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,13 +333,137 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
|
|||||||
p := startProgress(progressParams{
|
p := startProgress(progressParams{
|
||||||
label: scanPhase("load"), total: -1,
|
label: scanPhase("load"), total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords,
|
||||||
}, nil, testRedraw)
|
}, nil, testRedraw, time.Now)
|
||||||
|
|
||||||
awaitOutput(t, stderr,
|
awaitOutput(t, stderr,
|
||||||
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
||||||
p.finish()
|
p.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPlainProgressStalledProducer checks that a phase whose files stop
|
||||||
|
// coming, as when one large file is being read or a disk is slow, goes
|
||||||
|
// on printing its line every interval: the rate falls to 0 once no file
|
||||||
|
// has been read for rateWindow, and once none has for stallTime the
|
||||||
|
// line says the phase has stalled, and where it waits, in place of the
|
||||||
|
// rate. The test moves the display's clock by hand.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestPlainProgressStalledProducer(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
p := startProgress(progressParams{
|
||||||
|
label: scanPhase("hash"), unit: scanUnit, total: 100,
|
||||||
|
stalledOn: hashStalledOn,
|
||||||
|
}, nil, testRedraw, clock.Now)
|
||||||
|
|
||||||
|
// Pass or fail, the display ends before stderr is put back.
|
||||||
|
t.Cleanup(p.finish)
|
||||||
|
|
||||||
|
// 20 files read as the phase starts, then none.
|
||||||
|
for range 20 {
|
||||||
|
p.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Add(5 * time.Second)
|
||||||
|
awaitOutput(t, stderr,
|
||||||
|
"[3/5] hash: [20/100] 20%, 4 files/s, elapsed 5s, eta 20s\n", 3)
|
||||||
|
|
||||||
|
clock.Add(time.Second)
|
||||||
|
awaitOutput(t, stderr,
|
||||||
|
"[3/5] hash: [20/100] 20%, 0 files/s, elapsed 6s, eta 24s\n", 3)
|
||||||
|
|
||||||
|
clock.Add(4 * time.Second)
|
||||||
|
awaitOutput(t, stderr,
|
||||||
|
"[3/5] hash: [20/100] 20%, stalled disk/db, elapsed 10s, eta 40s\n",
|
||||||
|
3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlainProgressRateAfterStall draws a phase's plain line by hand, a
|
||||||
|
// little over plainInterval after the line before, as a late tick
|
||||||
|
// does, through a stall and a burst of files after it: the rate covers
|
||||||
|
// the files read since the line before, while the ETA comes from the
|
||||||
|
// phase's average.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestPlainProgressRateAfterStall(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
|
||||||
|
// No timed draws: the test draws.
|
||||||
|
p := startProgress(progressParams{
|
||||||
|
label: scanPhase("hash"), unit: scanUnit, total: 1000,
|
||||||
|
stalledOn: hashStalledOn,
|
||||||
|
}, nil, time.Hour, clock.Now)
|
||||||
|
|
||||||
|
late := plainInterval + time.Millisecond
|
||||||
|
|
||||||
|
clock.Add(late)
|
||||||
|
p.draw()
|
||||||
|
clock.Add(late)
|
||||||
|
p.draw()
|
||||||
|
|
||||||
|
for range 100 {
|
||||||
|
p.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
clock.Add(late)
|
||||||
|
p.finish()
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown",
|
||||||
|
"[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 5s, eta unknown",
|
||||||
|
"[3/5] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, eta unknown",
|
||||||
|
"[3/5] hash: [100/1000] 10%, 20 files/s, elapsed 15s, eta 2m15s",
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
|
||||||
|
if !slices.Equal(lines, want) {
|
||||||
|
t.Errorf("stderr lines = %q, want %q", lines, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProgressRateAfterStallRedrawn draws a phase by hand every
|
||||||
|
// redrawInterval, as a terminal does, through a stall of stallTime and
|
||||||
|
// a burst of files after it: the rate covers the files read over the
|
||||||
|
// last rateWindow, and falls to 0 once the burst is older than that,
|
||||||
|
// while the ETA comes from the phase's average. Plain lines show each
|
||||||
|
// draw.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestProgressRateAfterStallRedrawn(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
|
||||||
|
// No timed draws: the test draws.
|
||||||
|
p := startProgress(progressParams{
|
||||||
|
label: scanPhase("hash"), unit: scanUnit, total: 1000,
|
||||||
|
stalledOn: hashStalledOn,
|
||||||
|
}, nil, time.Hour, clock.Now)
|
||||||
|
|
||||||
|
// redraw draws the display every redrawInterval for d.
|
||||||
|
redraw := func(d time.Duration) {
|
||||||
|
for range d / redrawInterval {
|
||||||
|
clock.Add(redrawInterval)
|
||||||
|
p.draw()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw(stallTime)
|
||||||
|
|
||||||
|
for range 50 {
|
||||||
|
p.increment()
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw(rateWindow + redrawInterval)
|
||||||
|
p.finish()
|
||||||
|
|
||||||
|
want := "[3/5] hash: [50/1000] 5%, 10 files/s, elapsed 15s, eta 4m45s\n" +
|
||||||
|
"[3/5] hash: [50/1000] 5%, 0 files/s, elapsed 15s, eta 4m47s\n"
|
||||||
|
if !strings.Contains(stderr(), want) {
|
||||||
|
t.Errorf("stderr = %q, want the lines %q", stderr(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// lastFrame returns what a terminal shows of the frames a display drew:
|
// lastFrame returns what a terminal shows of the frames a display drew:
|
||||||
// the last one. The library starts each frame with a carriage return
|
// the last one. The library starts each frame with a carriage return
|
||||||
// and erases the previous one with spaces first.
|
// and erases the previous one with spaces first.
|
||||||
@@ -321,7 +506,7 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
before := len(stderr())
|
before := len(stderr())
|
||||||
since := func() string { return stderr()[before:] }
|
since := func() string { return stderr()[before:] }
|
||||||
p := startProgress(tc.params, terminalWidth, testRedraw)
|
p := startProgress(tc.params, terminalWidth, testRedraw, time.Now)
|
||||||
|
|
||||||
for range 50 {
|
for range 50 {
|
||||||
p.increment()
|
p.increment()
|
||||||
@@ -343,21 +528,23 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
|||||||
|
|
||||||
// drawLate draws the display params describes on a terminal width
|
// drawLate draws the display params describes on a terminal width
|
||||||
// columns wide as it starts and once more as it ends, with count items
|
// columns wide as it starts and once more as it ends, with count items
|
||||||
// done two and a half hours into the phase, and returns what it wrote
|
// done two and a half hours into the phase, the last just now, and
|
||||||
// to stderr.
|
// returns what it wrote to stderr.
|
||||||
func drawLate(stderr func() string, params progressParams, width int,
|
func drawLate(stderr func() string, params progressParams, width int,
|
||||||
count int64,
|
count int64,
|
||||||
) string {
|
) string {
|
||||||
before := len(stderr())
|
before := len(stderr())
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
|
||||||
// No timed draws come between the two.
|
// No timed draws come between the two.
|
||||||
p := startProgress(params, func() int { return width }, time.Hour)
|
p := startProgress(params, func() int { return width }, time.Hour,
|
||||||
|
clock.Now)
|
||||||
|
|
||||||
// Without counting the items one by one or waiting the hours.
|
// Without counting the items one by one or waiting the hours.
|
||||||
|
clock.Add(150 * time.Minute)
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.count = count
|
p.count, p.lastItem = count, clock.Now()
|
||||||
p.start = p.start.Add(-150 * time.Minute)
|
|
||||||
p.rateStart = p.start
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
p.finish()
|
p.finish()
|
||||||
@@ -413,6 +600,85 @@ func TestTerminalLinesKeepETA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTerminalStalledLines draws every phase's line on an 80-column
|
||||||
|
// terminal once the phase has stalled, its last item done 42 seconds
|
||||||
|
// before. At an ordinary scan's counts and times, 12345 of 45678 items
|
||||||
|
// done four and a half minutes in, the line shows where the phase waits
|
||||||
|
// whole, beside the phase, progress, elapsed time and ETA. At a large
|
||||||
|
// scan's, 1234567 of 9876543 two and a half hours in, those four still
|
||||||
|
// show whole, as the words give way first where they do not fit.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
|
func TestTerminalStalledLines(t *testing.T) {
|
||||||
|
stderr := captureStderr(t)
|
||||||
|
|
||||||
|
// drawStalled draws the display params describes as it starts and
|
||||||
|
// once more as it ends, with count of total items done elapsed into
|
||||||
|
// the phase, and returns what the terminal shows.
|
||||||
|
drawStalled := func(params progressParams, total, count int64,
|
||||||
|
elapsed time.Duration,
|
||||||
|
) string {
|
||||||
|
before := len(stderr())
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
params.total = total
|
||||||
|
|
||||||
|
// No timed draws come between the two.
|
||||||
|
p := startProgress(params, func() int { return 80 }, time.Hour,
|
||||||
|
clock.Now)
|
||||||
|
|
||||||
|
clock.Add(elapsed - 42*time.Second)
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.count, p.lastItem = count, clock.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
clock.Add(42 * time.Second)
|
||||||
|
p.finish()
|
||||||
|
|
||||||
|
return lastFrame(stderr()[before:])
|
||||||
|
}
|
||||||
|
|
||||||
|
phases := []progressParams{
|
||||||
|
{label: scanPhase("load"), stalledOn: "db"},
|
||||||
|
{label: scanPhase("walk"), estimate: true, stalledOn: "disk"},
|
||||||
|
{label: scanPhase("hash"), stalledOn: hashStalledOn},
|
||||||
|
{label: scanPhase("update"), stalledOn: "db"},
|
||||||
|
// The content phase's checks and reads, then its last write.
|
||||||
|
{label: scanPhase("content"), stalledOn: "disk/db"},
|
||||||
|
{label: scanPhase("content"), stalledOn: "db"},
|
||||||
|
{label: treesPhase("read"), stalledOn: "db"},
|
||||||
|
{label: treesPhase("group"), stalledOn: "cpu"},
|
||||||
|
{label: treesPhase("write"), stalledOn: "out"},
|
||||||
|
{label: reportPhase("read"), stalledOn: "db/out"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, params := range phases {
|
||||||
|
approx := ""
|
||||||
|
if params.estimate {
|
||||||
|
approx = "~"
|
||||||
|
}
|
||||||
|
|
||||||
|
shown := drawStalled(params, 45678, 12345,
|
||||||
|
4*time.Minute+30*time.Second)
|
||||||
|
want := params.label + ": [12345/" + approx + "45678] 27%, stalled " +
|
||||||
|
params.stalledOn + ", elapsed 4m30s, eta 12m9s"
|
||||||
|
|
||||||
|
if !strings.Contains(shown, want) {
|
||||||
|
t.Errorf("terminal shows %q, want %q", shown, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
shown = drawStalled(params, 9876543, 1234567, 150*time.Minute)
|
||||||
|
for _, want := range []string{
|
||||||
|
params.label + ": [1234567/" + approx + "9876543] 12%, ",
|
||||||
|
", elapsed 2h30m0s, eta 17h30m0s",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(shown, want) {
|
||||||
|
t.Errorf("terminal shows %q, want %q", shown, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestTerminalLinesFitWidth draws a large scan's bar display and a small
|
// TestTerminalLinesFitWidth draws a large scan's bar display and a small
|
||||||
// scan's, two and a half hours into the phase, on terminals from 120
|
// scan's, two and a half hours into the phase, on terminals from 120
|
||||||
// columns down to 40, and checks that every line stays clear of the
|
// columns down to 40, and checks that every line stays clear of the
|
||||||
@@ -455,7 +721,7 @@ func TestTerminalLinesFitWidth(t *testing.T) {
|
|||||||
// times. Nothing written after that, the erase before each redraw
|
// times. Nothing written after that, the erase before each redraw
|
||||||
// included, may reach the narrower terminal's last column: it would
|
// included, may reach the narrower terminal's last column: it would
|
||||||
// wrap, and every redraw would leave a line behind. At these widths the
|
// wrap, and every redraw would leave a line behind. At these widths the
|
||||||
// spinner stays a spinner and the bar stays a bar.
|
// spinner stays a spinner and the bar gives way to the spinner.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||||
func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
||||||
@@ -479,14 +745,16 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
width := 120
|
width := 120
|
||||||
|
clock := &testClock{now: time.Now()}
|
||||||
|
|
||||||
// No timed draws: the test draws.
|
// No timed draws: the test draws.
|
||||||
p := startProgress(tc.params, func() int { return width }, time.Hour)
|
p := startProgress(tc.params, func() int { return width }, time.Hour,
|
||||||
|
clock.Now)
|
||||||
|
|
||||||
|
clock.Add(150 * time.Minute)
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
p.count = 1234567
|
p.count, p.lastItem = 1234567, clock.Now()
|
||||||
p.start = p.start.Add(-150 * time.Minute)
|
|
||||||
p.rateStart = p.start
|
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
p.draw()
|
p.draw()
|
||||||
@@ -528,7 +796,7 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
|||||||
issued := 0
|
issued := 0
|
||||||
|
|
||||||
for _, params := range cases {
|
for _, params := range cases {
|
||||||
p := startProgress(params, terminalWidth, testRedraw)
|
p := startProgress(params, terminalWidth, testRedraw, time.Now)
|
||||||
|
|
||||||
// No pause between warnings: one written straight to stderr is
|
// No pause between warnings: one written straight to stderr is
|
||||||
// garbled only if a redraw lands while it is being written.
|
// garbled only if a redraw lands while it is being written.
|
||||||
@@ -581,7 +849,7 @@ func TestProgressWriterRowsOnOwnLines(t *testing.T) {
|
|||||||
var want []string
|
var want []string
|
||||||
|
|
||||||
for _, params := range cases {
|
for _, params := range cases {
|
||||||
p := startProgress(params, terminalWidth, testRedraw)
|
p := startProgress(params, terminalWidth, testRedraw, time.Now)
|
||||||
out := p.writer(os.Stderr)
|
out := p.writer(os.Stderr)
|
||||||
|
|
||||||
// No pause between rows: one is garbled only if a redraw lands
|
// No pause between rows: one is garbled only if a redraw lands
|
||||||
@@ -644,7 +912,7 @@ func TestProgressWriterStalledStdoutKeepsLines(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
p := startProgress(progressParams{label: treesPhase("write"), total: 1},
|
p := startProgress(progressParams{label: treesPhase("write"), total: 1},
|
||||||
nil, testRedraw)
|
nil, testRedraw, time.Now)
|
||||||
out := p.writer(w)
|
out := p.writer(w)
|
||||||
written := make(chan error, 1)
|
written := make(chan error, 1)
|
||||||
|
|
||||||
@@ -687,7 +955,7 @@ func TestProgressWriterStalledStdoutKeepsLines(t *testing.T) {
|
|||||||
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||||
stderr := captureStderr(t)
|
stderr := captureStderr(t)
|
||||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
|
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
|
||||||
terminalWidth, redrawInterval)
|
terminalWidth, redrawInterval, time.Now)
|
||||||
|
|
||||||
p.increment()
|
p.increment()
|
||||||
p.increment()
|
p.increment()
|
||||||
@@ -706,7 +974,7 @@ func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
|||||||
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
||||||
stderr := captureStderr(t)
|
stderr := captureStderr(t)
|
||||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
||||||
terminalWidth, testRedraw)
|
terminalWidth, testRedraw, time.Now)
|
||||||
|
|
||||||
p.increment()
|
p.increment()
|
||||||
p.increment()
|
p.increment()
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ func runReport(ctx context.Context, stdout io.Writer) error {
|
|||||||
|
|
||||||
defer func() { _ = db.Close() }()
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
// The read phase writes the report to stdout while it reads the
|
||||||
|
// records, so it may wait on either.
|
||||||
prog = newProgress(progressParams{
|
prog = newProgress(progressParams{
|
||||||
label: reportPhase("read"), unit: "records", total: -1,
|
label: reportPhase("read"), unit: "records", total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords, stalledOn: "db/out",
|
||||||
})
|
})
|
||||||
report, err := readReport(ctx, db, dbPath, stdout, prog)
|
report, err := readReport(ctx, db, dbPath, stdout, prog)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -156,7 +156,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Counts the records read, and draws nothing.
|
// Counts the records read, and draws nothing.
|
||||||
prog := &progress{}
|
prog := &progress{now: time.Now}
|
||||||
|
|
||||||
_, err = readReport(t.Context(), db, "", failingWriter{}, prog)
|
_, err = readReport(t.Context(), db, "", failingWriter{}, prog)
|
||||||
if !errors.Is(err, errWriteFailed) ||
|
if !errors.Is(err, errWriteFailed) ||
|
||||||
@@ -250,7 +250,7 @@ func TestReadReportOnTerminal(t *testing.T) {
|
|||||||
p := startProgress(progressParams{
|
p := startProgress(progressParams{
|
||||||
label: reportPhase("read"), unit: "records", total: -1,
|
label: reportPhase("read"), unit: "records", total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords,
|
||||||
}, terminalWidth, time.Hour)
|
}, terminalWidth, time.Hour, time.Now)
|
||||||
|
|
||||||
_, err = readReport(t.Context(), db, "", os.Stderr, p)
|
_, err = readReport(t.Context(), db, "", os.Stderr, p)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -114,9 +115,13 @@ func scanPhase(name string) string {
|
|||||||
// SIGTERM that interruptContext catches, the scan keeps what it has
|
// SIGTERM that interruptContext catches, the scan keeps what it has
|
||||||
// hashed (see syncScan), prints how many files its walk reached, and
|
// hashed (see syncScan), prints how many files its walk reached, and
|
||||||
// returns errInterrupted. workers must be at least 1; the scan command
|
// returns errInterrupted. workers must be at least 1; the scan command
|
||||||
// rejects anything less.
|
// 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,
|
func runScan(ctx context.Context, roots []string, workers int,
|
||||||
oneFS bool,
|
oneFS, noDefaultExcludes bool,
|
||||||
) error {
|
) error {
|
||||||
dbPath, source, err := databasePath()
|
dbPath, source, err := databasePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,6 +144,16 @@ func runScan(ctx context.Context, roots []string, workers int,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nonPersistent, err := nonPersistentFilesystems()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list the mounted filesystems: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := walkRules{nonPersistent: nonPersistent}
|
||||||
|
if !noDefaultExcludes {
|
||||||
|
rules.excludes = defaultExcludes(runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
lock, err := lockScanDatabase(dbPath)
|
lock, err := lockScanDatabase(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -159,7 +174,12 @@ func runScan(ctx context.Context, roots []string, workers int,
|
|||||||
|
|
||||||
defer closeScanDatabase(ctx, db, dbPath)
|
defer closeScanDatabase(ctx, db, dbPath)
|
||||||
|
|
||||||
st, err := syncScan(ctx, db, roots, workers, oneFS)
|
rules.databaseFiles, err = databaseFiles(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := syncScan(ctx, db, roots, workers, oneFS, rules)
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
return interrupted(st.walked)
|
return interrupted(st.walked)
|
||||||
}
|
}
|
||||||
@@ -231,14 +251,12 @@ func resolveRoots(roots []string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pruneRoots drops operands already covered by another operand:
|
// pruneRoots drops operands already covered by another operand:
|
||||||
// duplicates and any operand lying under another one. With -x, an
|
// duplicates and any operand lying under another one. An operand under
|
||||||
// operand under another one is kept when the walk of the deepest kept
|
// another one is kept, though, when the walk of the deepest kept operand
|
||||||
// operand above it cannot reach it: when device puts any directory from
|
// above it does not reach it (see walkReaches). Every remaining file is
|
||||||
// it (from its directory, for a regular file) up to but not including
|
// then reachable through exactly one root, so walked paths are unique
|
||||||
// that operand on a different filesystem than that operand. Every
|
// without keeping a scan-wide set of every path seen.
|
||||||
// remaining file is then reachable through exactly one root, so walked
|
func pruneRoots(roots []string, oneFS bool, rules walkRules,
|
||||||
// paths are unique without keeping a scan-wide set of every path seen.
|
|
||||||
func pruneRoots(roots []string, oneFS bool,
|
|
||||||
device func(path string) (uint64, bool),
|
device func(path string) (uint64, bool),
|
||||||
) []string {
|
) []string {
|
||||||
// Shorter-first ordering guarantees an ancestor is kept before any
|
// Shorter-first ordering guarantees an ancestor is kept before any
|
||||||
@@ -262,40 +280,57 @@ func pruneRoots(roots []string, oneFS bool,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without -x, and always for a duplicate, r is dropped.
|
// A duplicate is dropped, and so is an operand the walk of above
|
||||||
if !oneFS || above == r {
|
// reaches.
|
||||||
continue
|
if above != r && !walkReaches(above, r, oneFS, rules, device) {
|
||||||
}
|
kept = append(kept, r)
|
||||||
|
|
||||||
aboveDev, aboveOK := device(above)
|
|
||||||
if !aboveOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The walk of above enters a directory only when it is on
|
|
||||||
// above's filesystem, and then reaches every regular file in it
|
|
||||||
// whatever device the file reports, so for a regular-file
|
|
||||||
// operand the directories checked start at its directory.
|
|
||||||
dir := r
|
|
||||||
|
|
||||||
fi, err := os.Lstat(r)
|
|
||||||
if err == nil && fi.Mode().IsRegular() {
|
|
||||||
dir = filepath.Dir(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
for ; dir != above; dir = filepath.Dir(dir) {
|
|
||||||
dev, ok := device(dir)
|
|
||||||
if ok && dev != aboveDev {
|
|
||||||
kept = append(kept, r)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return kept
|
return kept
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// walkReaches reports whether the walk of operand above reaches r, an
|
||||||
|
// operand under it. That walk does not enter a default exclude in
|
||||||
|
// rules, nor a directory that device puts on a filesystem in rules that
|
||||||
|
// is not persistent, nor, with -x, one that device puts on a different
|
||||||
|
// filesystem than above, when device knows above's. It reaches every
|
||||||
|
// regular file in a directory it enters, so for a regular-file operand
|
||||||
|
// the directories checked start at its directory; they end below above.
|
||||||
|
func walkReaches(above, r string, oneFS bool, rules walkRules,
|
||||||
|
device func(path string) (uint64, bool),
|
||||||
|
) bool {
|
||||||
|
aboveDev, aboveDevOK := device(above)
|
||||||
|
|
||||||
|
dir := r
|
||||||
|
|
||||||
|
fi, err := os.Lstat(r)
|
||||||
|
if err == nil && fi.Mode().IsRegular() {
|
||||||
|
dir = filepath.Dir(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ; dir != above; dir = filepath.Dir(dir) {
|
||||||
|
if isDefaultExclude(dir, rules.excludes) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ok := device(dir)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, found := rules.nonPersistent[dev]; found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if oneFS && aboveDevOK && dev != aboveDev {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// deviceOfPath returns the filesystem device ID of the file at path,
|
// deviceOfPath returns the filesystem device ID of the file at path,
|
||||||
// when lstat succeeds and the platform exposes one.
|
// when lstat succeeds and the platform exposes one.
|
||||||
func deviceOfPath(path string) (uint64, bool) {
|
func deviceOfPath(path string) (uint64, bool) {
|
||||||
@@ -337,11 +372,11 @@ type scanState struct {
|
|||||||
// the hashed records still waiting in the batch, starts no other write
|
// the hashed records still waiting in the batch, starts no other write
|
||||||
// or deletion, and returns the cancellation.
|
// or deletion, and returns the cancellation.
|
||||||
func syncScan(ctx context.Context, db *sql.DB, roots []string,
|
func syncScan(ctx context.Context, db *sql.DB, roots []string,
|
||||||
workers int, oneFS bool,
|
workers int, oneFS bool, rules walkRules,
|
||||||
) (scanStats, error) {
|
) (scanStats, error) {
|
||||||
s := &scanState{db: db}
|
s := &scanState{db: db}
|
||||||
|
|
||||||
err := s.runPhases(ctx, roots, workers, oneFS)
|
err := s.runPhases(ctx, roots, workers, oneFS, rules)
|
||||||
if err == nil || ctx.Err() == nil {
|
if err == nil || ctx.Err() == nil {
|
||||||
return s.st, err
|
return s.st, err
|
||||||
}
|
}
|
||||||
@@ -365,15 +400,18 @@ func syncScan(ctx context.Context, db *sql.DB, roots []string,
|
|||||||
// and content (fill in the content hash of every record of headTailMin
|
// and content (fill in the content hash of every record of headTailMin
|
||||||
// or more whose size, head, and tail match another record's). Records
|
// or more whose size, head, and tail match another record's). Records
|
||||||
// outside the roots are never touched, except that the content phase
|
// outside the roots are never touched, except that the content phase
|
||||||
// fills in their content hash. Operands the walk cannot start from are
|
// fills in their content hash. Operands the walk cannot start from, or
|
||||||
// dropped first, so the records beneath them are treated as if those
|
// that are not on a persistent filesystem, are dropped first, so the
|
||||||
// operands had not been given.
|
// records beneath them are treated as if those operands had not been
|
||||||
|
// given. Below an operand, the walk follows rules.
|
||||||
func (s *scanState) runPhases(ctx context.Context, roots []string,
|
func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||||
workers int, oneFS bool,
|
workers int, oneFS bool, rules walkRules,
|
||||||
) error {
|
) error {
|
||||||
// Types are checked before pruning so that an operand under a
|
// Types and filesystems are checked before pruning so that an
|
||||||
// dropped one is still scanned, not dropped as lying under it.
|
// operand under a dropped one is still scanned, not dropped as lying
|
||||||
roots = pruneRoots(s.walkableRoots(roots), oneFS, deviceOfPath)
|
// under it.
|
||||||
|
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
|
||||||
|
deviceOfPath)
|
||||||
|
|
||||||
err := s.loadIndex(ctx, roots)
|
err := s.loadIndex(ctx, roots)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -381,7 +419,7 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
changed, unhashed := s.walkPhase(
|
changed, unhashed := s.walkPhase(
|
||||||
startWalk(ctx, roots, oneFS, workers), roots)
|
startWalk(ctx, roots, oneFS, rules, workers), roots)
|
||||||
|
|
||||||
// A cancelled walk stops early, so its size census covers only part
|
// A cancelled walk stops early, so its size census covers only part
|
||||||
// of the roots, and every file it never reached would look vanished
|
// of the roots, and every file it never reached would look vanished
|
||||||
@@ -403,7 +441,7 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.contentPhase(ctx, workers)
|
return s.contentPhase(ctx, workers, rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
// walkableRoots returns the operands the walk can start from: regular
|
// walkableRoots returns the operands the walk can start from: regular
|
||||||
@@ -411,9 +449,11 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
// about, counted as skipped, and dropped. A dropped operand is no
|
// 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
|
// 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
|
// had not been given: those under no other root count as outside the
|
||||||
// roots and are not deleted as unverified. An operand that fails lstat
|
// roots and are not deleted as unverified. An operand on a filesystem
|
||||||
// here is kept, and the walk warns about it.
|
// in rules that is not persistent is dropped the same way, with a line
|
||||||
func (s *scanState) walkableRoots(roots []string) []string {
|
// 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.
|
||||||
|
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
|
||||||
kept := make([]string, 0, len(roots))
|
kept := make([]string, 0, len(roots))
|
||||||
|
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
@@ -427,6 +467,13 @@ func (s *scanState) walkableRoots(roots []string) []string {
|
|||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
line := rules.nonPersistentLine(root, fi)
|
||||||
|
if line != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, escapePath(line))
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kept = append(kept, root)
|
kept = append(kept, root)
|
||||||
@@ -446,7 +493,7 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
|||||||
// first gives the display its total.
|
// first gives the display its total.
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("load"), unit: scanUnit, total: -1,
|
label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords, stalledOn: "db",
|
||||||
})
|
})
|
||||||
defer prog.finish()
|
defer prog.finish()
|
||||||
|
|
||||||
@@ -481,10 +528,12 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
|||||||
// (the content phase may still fill in its content hash). It returns
|
// (the content phase may still fill in its content hash). It returns
|
||||||
// the new-or-changed files and the unchanged files whose records lack
|
// the new-or-changed files and the unchanged files whose records lack
|
||||||
// hashes; both remain candidates until the census decides whether
|
// hashes; both remain candidates until the census decides whether
|
||||||
// their sizes are shared. A record below a directory -x did not enter
|
// their sizes are shared. A record at or below a path the walk left
|
||||||
// counts as outside the roots: it is not deleted, and its size joins
|
// out, because -x stopped at it, it is a default exclude, or it is not
|
||||||
// the census. That does not hold below a root at or inside that
|
// on a persistent filesystem, counts as outside the roots: it is not
|
||||||
// directory, since that root's own walk verifies the records below it.
|
// deleted, and its size joins the census. 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(
|
func (s *scanState) walkPhase(
|
||||||
events <-chan walkEvent, roots []string,
|
events <-chan walkEvent, roots []string,
|
||||||
) ([]fileRec, []fileRec) {
|
) ([]fileRec, []fileRec) {
|
||||||
@@ -497,17 +546,30 @@ func (s *scanState) walkPhase(
|
|||||||
|
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("walk"), unit: scanUnit, total: estimate,
|
label: scanPhase("walk"), unit: scanUnit, total: estimate,
|
||||||
estimate: true,
|
estimate: true, stalledOn: "disk",
|
||||||
})
|
})
|
||||||
|
|
||||||
var (
|
var (
|
||||||
changed, unhashed []fileRec
|
changed, unhashed []fileRec
|
||||||
otherFSDirs []string
|
leftOut []string
|
||||||
)
|
)
|
||||||
|
|
||||||
for ev := range events {
|
for ev := range events {
|
||||||
if ev.otherFSDir != "" {
|
if ev.otherFSDir != "" {
|
||||||
otherFSDirs = append(otherFSDirs, ev.otherFSDir)
|
leftOut = append(leftOut, ev.otherFSDir)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// The line saying so is printed as a warning is, but what the
|
||||||
|
// walk leaves out is not counted as skipped. A default exclude
|
||||||
|
// that is itself a root is walked as one, so it gets no line.
|
||||||
|
if ev.leftOut != "" {
|
||||||
|
leftOut = append(leftOut, ev.leftOut)
|
||||||
|
|
||||||
|
if !slices.Contains(roots, ev.leftOut) {
|
||||||
|
prog.warnf("%s", ev.warn)
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -542,12 +604,13 @@ func (s *scanState) walkPhase(
|
|||||||
prog.finish()
|
prog.finish()
|
||||||
|
|
||||||
for path, old := range s.existing {
|
for path, old := range s.existing {
|
||||||
// Kept only when a directory -x did not enter lies below the
|
// Kept only when a path the walk left out lies below the deepest
|
||||||
// deepest root above the record. A root at or below that
|
// root above the record, or is the record's own. A root at or
|
||||||
// directory was walked on its own and did not find the file.
|
// below that path was walked on its own and did not find the
|
||||||
// Both contain the record, so the longer path is the deeper.
|
// file. Both contain the record, so the longer path is the
|
||||||
otherFSDir := deepestRoot(path, otherFSDirs)
|
// deeper.
|
||||||
if len(otherFSDir) > len(deepestRoot(path, roots)) {
|
left := deepestRoot(path, leftOut)
|
||||||
|
if len(left) > len(deepestRoot(path, roots)) {
|
||||||
delete(s.existing, path)
|
delete(s.existing, path)
|
||||||
|
|
||||||
s.sizes = append(s.sizes, old.size)
|
s.sizes = append(s.sizes, old.size)
|
||||||
@@ -653,6 +716,15 @@ func sameInode(a, b fileRec) bool {
|
|||||||
return (a.dev != 0 || a.ino != 0) && a.dev == b.dev && a.ino == b.ino
|
return (a.dev != 0 || a.ino != 0) && a.dev == b.dev && a.ino == b.ino
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDatabaseFile reports whether f, a file the walk reached, is one of
|
||||||
|
// the scan's own database files in rules, which the walk passes over
|
||||||
|
// without a line and without counting it.
|
||||||
|
func isDatabaseFile(f fileRec, rules walkRules) bool {
|
||||||
|
return slices.ContainsFunc(rules.databaseFiles, func(d fileRec) bool {
|
||||||
|
return sameInode(d, f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// hashPhase hashes every queued file with hashSignature — the head and
|
// hashPhase hashes every queued file with hashSignature — the head and
|
||||||
// tail of a file of headTailMin or more, the whole file below that —
|
// tail of a file of headTailMin or more, the whole file below that —
|
||||||
// committing completed records to the database in batches as results
|
// committing completed records to the database in batches as results
|
||||||
@@ -687,8 +759,11 @@ func (s *scanState) readRuns(ctx context.Context, workers int,
|
|||||||
pool := startHashPool(ctx, runs, workers, hash)
|
pool := startHashPool(ctx, runs, workers, hash)
|
||||||
defer pool.stop()
|
defer pool.stop()
|
||||||
|
|
||||||
|
// Between results the phase may wait on a file being read or on a
|
||||||
|
// batch of records being written.
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase(label), unit: scanUnit, total: int64(len(runs)),
|
label: scanPhase(label), unit: scanUnit, total: int64(len(runs)),
|
||||||
|
stalledOn: "disk/db",
|
||||||
})
|
})
|
||||||
defer prog.finish()
|
defer prog.finish()
|
||||||
|
|
||||||
@@ -778,6 +853,7 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
|||||||
total := len(s.batch) + len(s.sentinels) + len(deletes)
|
total := len(s.batch) + len(s.sentinels) + len(deletes)
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
label: scanPhase("update"), unit: scanUnit, total: int64(total),
|
||||||
|
stalledOn: "db",
|
||||||
})
|
})
|
||||||
|
|
||||||
defer prog.finish()
|
defer prog.finish()
|
||||||
@@ -817,9 +893,12 @@ func (s *scanState) updatePhase(ctx context.Context) error {
|
|||||||
// hash phase's worker pool and their records written back in batches. A
|
// hash phase's worker pool and their records written back in batches. A
|
||||||
// failed read is warned about and counted as skipped; the record keeps
|
// failed read is warned about and counted as skipped; the record keeps
|
||||||
// its empty content, so it is never grouped, and a later scan tries
|
// its empty content, so it is never grouped, and a later scan tries
|
||||||
// again.
|
// again. No file on a filesystem in rules that is not persistent is
|
||||||
func (s *scanState) contentPhase(ctx context.Context, workers int) error {
|
// read.
|
||||||
toRead, recs, err := s.contentCandidates(ctx)
|
func (s *scanState) contentPhase(ctx context.Context, workers int,
|
||||||
|
rules walkRules,
|
||||||
|
) error {
|
||||||
|
toRead, recs, err := s.contentCandidates(ctx, rules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -840,33 +919,43 @@ func (s *scanState) contentPhase(ctx context.Context, workers int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return applyChanges(ctx, s.db, s.batch, nil, nil)
|
// The last partial batch is written under a display of its own, as
|
||||||
|
// the update phase writes the hash phase's.
|
||||||
|
prog := newProgress(progressParams{
|
||||||
|
label: scanPhase("content"), unit: scanUnit,
|
||||||
|
total: int64(len(s.batch)), stalledOn: "db",
|
||||||
|
})
|
||||||
|
defer prog.finish()
|
||||||
|
|
||||||
|
return applyChanges(ctx, s.db, s.batch, nil, prog)
|
||||||
}
|
}
|
||||||
|
|
||||||
// contentCandidates returns the files the content phase reads, and
|
// contentCandidates returns the files the content phase reads, and
|
||||||
// their records by path. Every record contentCandidatesSQL returns has
|
// their records by path. Every record contentCandidatesSQL returns has
|
||||||
// its file checked with lstat, whether or not it already has a content
|
// its file checked with lstat, whether or not it already has a content
|
||||||
// hash: a file that is gone, is no longer a regular file, or has
|
// hash: a file that is gone, is no longer a regular file, has changed
|
||||||
// changed by the walk's rule keeps its record as it is and does not
|
// by the walk's rule, or is on a filesystem in rules that is not
|
||||||
// count as a match for the others, and any other lstat error is warned
|
// persistent keeps its record as it is and does not count as a match
|
||||||
// about and counted as skipped, with the same result. If such a record
|
// for the others, and any other lstat error is warned about and counted
|
||||||
// has no content hash, it stays out of duplicate groups; if it has one,
|
// as skipped, with the same result. If such a record has no content
|
||||||
// it is still reported until a scan covering its own tree updates or
|
// hash, it stays out of duplicate groups; if it has one, it is still
|
||||||
// removes it. The files of a group that pass and have no content hash
|
// reported until a scan covering its own tree updates or removes it.
|
||||||
// are read only if at least minGroupSize of the group's files pass, so
|
// The files of a group that pass and have no content hash are read only
|
||||||
// a group whose other members are all stale costs no reads. Only the
|
// if at least minGroupSize of the group's files pass, so a group whose
|
||||||
// records to be read are kept.
|
// other members are all stale costs no reads. Only the records to be
|
||||||
|
// read are kept.
|
||||||
func (s *scanState) contentCandidates(
|
func (s *scanState) contentCandidates(
|
||||||
ctx context.Context,
|
ctx context.Context, rules walkRules,
|
||||||
) ([]fileRec, map[string]scanRec, error) {
|
) ([]fileRec, map[string]scanRec, error) {
|
||||||
// The search and the checks take real time on a large database;
|
// The search and the checks take real time on a large database;
|
||||||
// without a display the scan looks hung before the reads begin.
|
// without a display the scan looks hung before the reads begin.
|
||||||
// The display says it is searching until SQLite returns the first
|
// The display says it is searching until SQLite returns the first
|
||||||
// record. The checks start there, against a count of the records
|
// record. The checks start there, against a count of the records
|
||||||
// the search returns, and so do their rate and ETA.
|
// the search returns, and so do their rate and ETA. Between records
|
||||||
|
// they may wait on a file's lstat or on the search.
|
||||||
prog := newProgress(progressParams{
|
prog := newProgress(progressParams{
|
||||||
label: scanPhase("content"), unit: scanUnit, total: -1,
|
label: scanPhase("content"), unit: scanUnit, total: -1,
|
||||||
waitingFor: "searching the database",
|
waitingFor: "searching the database", stalledOn: "disk/db",
|
||||||
})
|
})
|
||||||
defer prog.finish()
|
defer prog.finish()
|
||||||
|
|
||||||
@@ -914,7 +1003,7 @@ func (s *scanState) contentCandidates(
|
|||||||
first = r
|
first = r
|
||||||
}
|
}
|
||||||
|
|
||||||
f, ok, err := unchangedFile(r)
|
f, ok, err := unchangedFile(r, rules)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.st.skipped++
|
s.st.skipped++
|
||||||
|
|
||||||
@@ -948,9 +1037,10 @@ func (s *scanState) contentCandidates(
|
|||||||
|
|
||||||
// unchangedFile lstats the file r names and returns it for reading if
|
// unchangedFile lstats the file r names and returns it for reading if
|
||||||
// it is still the regular file r records: the same size, and an mtime
|
// it is still the regular file r records: the same size, and an mtime
|
||||||
// no newer than recorded (the walk's change rule). A file that is gone
|
// no newer than recorded (the walk's change rule). A file that is gone,
|
||||||
// or has changed reports false; any other lstat error is returned.
|
// has changed, or is on a filesystem in rules that is not persistent
|
||||||
func unchangedFile(r scanRec) (fileRec, bool, error) {
|
// reports false; any other lstat error is returned.
|
||||||
|
func unchangedFile(r scanRec, rules walkRules) (fileRec, bool, error) {
|
||||||
fi, err := os.Lstat(r.path)
|
fi, err := os.Lstat(r.path)
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return fileRec{}, false, nil
|
return fileRec{}, false, nil
|
||||||
@@ -967,6 +1057,12 @@ func unchangedFile(r scanRec) (fileRec, bool, error) {
|
|||||||
|
|
||||||
dev, ino := inodeOfInfo(fi)
|
dev, ino := inodeOfInfo(fi)
|
||||||
|
|
||||||
|
// The scan reads nothing on a filesystem that is not persistent,
|
||||||
|
// whatever record an earlier scan left for the file.
|
||||||
|
if _, found := rules.nonPersistent[dev]; found {
|
||||||
|
return fileRec{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
return fileRec{
|
return fileRec{
|
||||||
path: r.path, size: r.size, mtime: r.mtime, dev: dev, ino: ino,
|
path: r.path, size: r.size, mtime: r.mtime, dev: dev, ino: ino,
|
||||||
}, true, nil
|
}, true, nil
|
||||||
@@ -1033,32 +1129,36 @@ type dirJob struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// walkEvent is one walk result delivered to the walk phase: a regular
|
// walkEvent is one walk result delivered to the walk phase: a regular
|
||||||
// file's statted record, a warning when fail is set, or, when
|
// file's statted record, a warning when fail is set, or a path the walk
|
||||||
// otherFSDir is set, a directory -x did not enter because it is on
|
// left out: when otherFSDir is set, a directory -x did not enter
|
||||||
// another filesystem than its operand.
|
// because it is on another filesystem than its operand, and when
|
||||||
|
// 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 {
|
type walkEvent struct {
|
||||||
rec fileRec
|
rec fileRec
|
||||||
warn string
|
warn string
|
||||||
fail bool
|
fail bool
|
||||||
otherFSDir string
|
otherFSDir string
|
||||||
|
leftOut string
|
||||||
}
|
}
|
||||||
|
|
||||||
// startWalk seeds every root into the shared walk worker pool and
|
// startWalk seeds every root into the shared walk worker pool and
|
||||||
// returns the event stream: one record per regular file, one warning
|
// returns the event stream: one record per regular file, one warning
|
||||||
// event per per-path error, and one event per directory -x did not
|
// event per per-path error, and one event per path -x or rules kept the
|
||||||
// enter. The channel is closed when the walk completes, and also when
|
// walk from entering or recording. The channel is closed when the walk
|
||||||
// ctx is cancelled — every goroutine in the pool abandons its blocking
|
// completes, and also when ctx is cancelled — every goroutine in the
|
||||||
// send in that case, so the consumer sees a truncated but properly
|
// pool abandons its blocking send in that case, so the consumer sees a
|
||||||
// terminated stream instead of a stalled one.
|
// truncated but properly terminated stream instead of a stalled one.
|
||||||
func startWalk(ctx context.Context, roots []string, oneFS bool,
|
func startWalk(ctx context.Context, roots []string, oneFS bool,
|
||||||
workers int,
|
rules walkRules, workers int,
|
||||||
) <-chan walkEvent {
|
) <-chan walkEvent {
|
||||||
jobs, subdirs, events := startWalkWorkers(ctx, workers, oneFS)
|
jobs, subdirs, events := startWalkWorkers(ctx, workers, oneFS, rules)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
initial := make([]dirJob, 0, len(roots))
|
initial := make([]dirJob, 0, len(roots))
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
initial = append(initial, seedRoot(ctx, root, events)...)
|
initial = append(initial, seedRoot(ctx, root, rules, events)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatchDirs(ctx, initial, jobs, subdirs)
|
dispatchDirs(ctx, initial, jobs, subdirs)
|
||||||
@@ -1112,13 +1212,14 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedRoot turns one PATH operand into the walk's starting state: a
|
// seedRoot turns one PATH operand into the walk's starting state: a
|
||||||
// regular-file operand is statted and emitted directly, and a directory
|
// regular-file operand is statted and emitted directly, unless it is
|
||||||
|
// one of the scan's own database files in rules, and a directory
|
||||||
// operand becomes an initial job. walkableRoots has already dropped
|
// operand becomes an initial job. walkableRoots has already dropped
|
||||||
// every other operand. One that has changed into something else since
|
// every other operand. One that has changed into something else since
|
||||||
// is warned about and skipped here; it is still a root, so the records
|
// is warned about and skipped here; it is still a root, so the records
|
||||||
// stored beneath it, other than those under another root, are deleted
|
// stored beneath it, other than those under another root, are deleted
|
||||||
// as unverified.
|
// as unverified.
|
||||||
func seedRoot(ctx context.Context, root string,
|
func seedRoot(ctx context.Context, root string, rules walkRules,
|
||||||
events chan<- walkEvent,
|
events chan<- walkEvent,
|
||||||
) []dirJob {
|
) []dirJob {
|
||||||
fi, err := os.Lstat(root)
|
fi, err := os.Lstat(root)
|
||||||
@@ -1145,14 +1246,17 @@ func seedRoot(ctx context.Context, root string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
dev, ino := inodeOfInfo(fi)
|
dev, ino := inodeOfInfo(fi)
|
||||||
|
rec := fileRec{
|
||||||
sendEvent(ctx, events, walkEvent{rec: fileRec{
|
|
||||||
path: root,
|
path: root,
|
||||||
size: fi.Size(),
|
size: fi.Size(),
|
||||||
mtime: fi.ModTime(),
|
mtime: fi.ModTime(),
|
||||||
dev: dev,
|
dev: dev,
|
||||||
ino: ino,
|
ino: ino,
|
||||||
}})
|
}
|
||||||
|
|
||||||
|
if !isDatabaseFile(rec, rules) {
|
||||||
|
sendEvent(ctx, events, walkEvent{rec: rec})
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1164,7 +1268,7 @@ func seedRoot(ctx context.Context, root string,
|
|||||||
// workers drop the directories still queued rather than stop reading
|
// workers drop the directories still queued rather than stop reading
|
||||||
// jobs, so the range always runs out and the pool always tears down.
|
// jobs, so the range always runs out and the pool always tears down.
|
||||||
func startWalkWorkers(ctx context.Context, workers int,
|
func startWalkWorkers(ctx context.Context, workers int,
|
||||||
oneFS bool,
|
oneFS bool, rules walkRules,
|
||||||
) (chan dirJob, chan []dirJob, chan walkEvent) {
|
) (chan dirJob, chan []dirJob, chan walkEvent) {
|
||||||
jobs := make(chan dirJob, workQueueDepth)
|
jobs := make(chan dirJob, workQueueDepth)
|
||||||
subdirs := make(chan []dirJob, workers)
|
subdirs := make(chan []dirJob, workers)
|
||||||
@@ -1180,7 +1284,7 @@ func startWalkWorkers(ctx context.Context, workers int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case subdirs <- walkOneDir(ctx, job, oneFS, events):
|
case subdirs <- walkOneDir(ctx, job, oneFS, rules, events):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1238,7 +1342,7 @@ func dispatchDirs(ctx context.Context, initial []dirJob,
|
|||||||
// entry and a warning event per unreadable one, and returns the
|
// entry and a warning event per unreadable one, and returns the
|
||||||
// subdirectories to descend into.
|
// subdirectories to descend into.
|
||||||
func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
||||||
events chan<- walkEvent,
|
rules walkRules, events chan<- walkEvent,
|
||||||
) []dirJob {
|
) []dirJob {
|
||||||
entries, err := os.ReadDir(job.path)
|
entries, err := os.ReadDir(job.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1262,7 +1366,8 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
|||||||
p := filepath.Join(job.path, e.Name())
|
p := filepath.Join(job.path, e.Name())
|
||||||
|
|
||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
if sub, ok := subdirJob(ctx, p, e, job, oneFS, events); ok {
|
sub, ok := subdirJob(ctx, p, e, job, oneFS, rules, events)
|
||||||
|
if ok {
|
||||||
subs = append(subs, sub)
|
subs = append(subs, sub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1274,7 +1379,7 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
emitFile(ctx, p, e, events)
|
emitFile(ctx, p, e, rules, events)
|
||||||
}
|
}
|
||||||
|
|
||||||
return subs
|
return subs
|
||||||
@@ -1284,9 +1389,12 @@ func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
|
|||||||
// The lstat happens here in the walk worker, while the directory's
|
// The lstat happens here in the walk worker, while the directory's
|
||||||
// metadata is still hot; a path that fails to stat (or stops being a
|
// metadata is still hot; a path that fails to stat (or stops being a
|
||||||
// regular file) between the directory read and the lstat is warned
|
// regular file) between the directory read and the lstat is warned
|
||||||
// about and skipped.
|
// about and 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. 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,
|
func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
||||||
events chan<- walkEvent,
|
rules walkRules, events chan<- walkEvent,
|
||||||
) {
|
) {
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1302,35 +1410,43 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dev, ino := inodeOfInfo(info)
|
line := rules.nonPersistentLine(p, info)
|
||||||
|
if line != "" {
|
||||||
|
sendEvent(ctx, events, walkEvent{leftOut: p, warn: line})
|
||||||
|
|
||||||
sendEvent(ctx, events, walkEvent{rec: fileRec{
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, ino := inodeOfInfo(info)
|
||||||
|
rec := fileRec{
|
||||||
path: p,
|
path: p,
|
||||||
size: info.Size(),
|
size: info.Size(),
|
||||||
mtime: info.ModTime(),
|
mtime: info.ModTime(),
|
||||||
dev: dev,
|
dev: dev,
|
||||||
ino: ino,
|
ino: ino,
|
||||||
}})
|
}
|
||||||
|
|
||||||
|
if !isDatabaseFile(rec, rules) {
|
||||||
|
sendEvent(ctx, events, walkEvent{rec: rec})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// subdirJob applies the descent rules to directory p: never enter
|
// subdirJob applies the descent rules to directory p: never enter
|
||||||
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
|
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
|
||||||
// snapshot), and with -x never enter a directory on a different
|
// snapshot), never enter a directory on a filesystem in rules that is
|
||||||
// filesystem than its operand, sending an event that names it so the
|
// not persistent, nor a default exclude in rules, and with -x never
|
||||||
// scan keeps the records below it, except those below an operand at or
|
// 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.
|
// inside it.
|
||||||
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
||||||
parent dirJob, oneFS bool, events chan<- walkEvent,
|
parent dirJob, oneFS bool, rules walkRules,
|
||||||
|
events chan<- walkEvent,
|
||||||
) (dirJob, bool) {
|
) (dirJob, bool) {
|
||||||
if e.Name() == ".zfs" {
|
if e.Name() == ".zfs" {
|
||||||
return dirJob{}, false
|
return dirJob{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
job := dirJob{path: p, rootDev: parent.rootDev, rootDevOK: parent.rootDevOK}
|
|
||||||
if !oneFS || !parent.rootDevOK {
|
|
||||||
return job, true
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendEvent(ctx, events, walkEvent{
|
sendEvent(ctx, events, walkEvent{
|
||||||
@@ -1341,6 +1457,27 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
return dirJob{}, false
|
return dirJob{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
line := rules.nonPersistentLine(p, info)
|
||||||
|
if line != "" {
|
||||||
|
sendEvent(ctx, events, walkEvent{leftOut: p, warn: line})
|
||||||
|
|
||||||
|
return dirJob{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDefaultExclude(p, rules.excludes) {
|
||||||
|
sendEvent(ctx, events, walkEvent{
|
||||||
|
leftOut: p,
|
||||||
|
warn: fmt.Sprintf("scan: skipped %s (default exclude)", p),
|
||||||
|
})
|
||||||
|
|
||||||
|
return dirJob{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
job := dirJob{path: p, rootDev: parent.rootDev, rootDevOK: parent.rootDevOK}
|
||||||
|
if !oneFS || !parent.rootDevOK {
|
||||||
|
return job, true
|
||||||
|
}
|
||||||
|
|
||||||
if dev, ok := deviceOfInfo(info); ok && dev != parent.rootDev {
|
if dev, ok := deviceOfInfo(info); ok && dev != parent.rootDev {
|
||||||
sendEvent(ctx, events, walkEvent{otherFSDir: p})
|
sendEvent(ctx, events, walkEvent{otherFSDir: p})
|
||||||
|
|
||||||
|
|||||||
+126
-9
@@ -782,7 +782,8 @@ func collectWalk(t *testing.T, roots []string, oneFS bool,
|
|||||||
errs int
|
errs int
|
||||||
)
|
)
|
||||||
|
|
||||||
for ev := range startWalk(t.Context(), roots, oneFS, workers) {
|
for ev := range startWalk(t.Context(), roots, oneFS, walkRules{},
|
||||||
|
workers) {
|
||||||
if ev.fail {
|
if ev.fail {
|
||||||
errs++
|
errs++
|
||||||
|
|
||||||
@@ -1004,7 +1005,8 @@ func callSubdirJob(t *testing.T, p string, e fs.DirEntry,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
events := make(chan walkEvent, 1)
|
events := make(chan walkEvent, 1)
|
||||||
job, ok := subdirJob(t.Context(), p, e, parent, oneFS, events)
|
job, ok := subdirJob(t.Context(), p, e, parent, oneFS, walkRules{},
|
||||||
|
events)
|
||||||
close(events)
|
close(events)
|
||||||
|
|
||||||
var evs []walkEvent
|
var evs []walkEvent
|
||||||
@@ -1152,7 +1154,7 @@ func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
|||||||
return dev, ok
|
return dev, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
roots = pruneRoots(roots, true, device)
|
roots = pruneRoots(roots, true, walkRules{}, device)
|
||||||
|
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
s := &scanState{db: db}
|
s := &scanState{db: db}
|
||||||
@@ -1162,14 +1164,14 @@ func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs, subdirs, events := startWalkWorkers(ctx, 2, true)
|
jobs, subdirs, events := startWalkWorkers(ctx, 2, true, walkRules{})
|
||||||
|
|
||||||
// seedRoot emits a regular-file operand itself and returns a job
|
// seedRoot emits a regular-file operand itself and returns a job
|
||||||
// only for a directory operand.
|
// only for a directory operand.
|
||||||
var initial []dirJob
|
var initial []dirJob
|
||||||
|
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
for _, job := range seedRoot(ctx, root, events) {
|
for _, job := range seedRoot(ctx, root, walkRules{}, events) {
|
||||||
job.rootDev = devices[root]
|
job.rootDev = devices[root]
|
||||||
initial = append(initial, job)
|
initial = append(initial, job)
|
||||||
}
|
}
|
||||||
@@ -1463,7 +1465,7 @@ const smokeTreeFiles = 15
|
|||||||
func syncTree(t *testing.T, db *sql.DB, roots ...string) scanStats {
|
func syncTree(t *testing.T, db *sql.DB, roots ...string) scanStats {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
st, err := syncScan(t.Context(), db, roots, 4, false)
|
st, err := syncScan(t.Context(), db, roots, 4, false, walkRules{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("syncScan: %v", err)
|
t.Fatalf("syncScan: %v", err)
|
||||||
}
|
}
|
||||||
@@ -2412,7 +2414,7 @@ func TestPruneRoots(t *testing.T) {
|
|||||||
// Duplicates and operands under other operands are dropped; /cc is
|
// Duplicates and operands under other operands are dropped; /cc is
|
||||||
// not under /c (sibling with a shared prefix).
|
// not under /c (sibling with a shared prefix).
|
||||||
got := pruneRoots([]string{"/a/b", "/a", "/c", "/a", "/a/b/c", "/cc"},
|
got := pruneRoots([]string{"/a/b", "/a", "/c", "/a", "/a/b/c", "/cc"},
|
||||||
false, device)
|
false, walkRules{}, device)
|
||||||
|
|
||||||
want := []string{"/a", "/c", "/cc"}
|
want := []string{"/a", "/c", "/cc"}
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
@@ -2421,7 +2423,7 @@ func TestPruneRoots(t *testing.T) {
|
|||||||
|
|
||||||
nested := []string{"/p/q/r", pqRoot, "/p", "/p", "/p/s", "/p/t"}
|
nested := []string{"/p/q/r", pqRoot, "/p", "/p", "/p/s", "/p/t"}
|
||||||
|
|
||||||
got = pruneRoots(nested, false, device)
|
got = pruneRoots(nested, false, walkRules{}, device)
|
||||||
|
|
||||||
want = []string{"/p"}
|
want = []string{"/p"}
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
@@ -2429,12 +2431,31 @@ func TestPruneRoots(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// With -x, /p/q, on another filesystem than /p, is kept as well.
|
// With -x, /p/q, on another filesystem than /p, is kept as well.
|
||||||
got = pruneRoots(nested, true, device)
|
got = pruneRoots(nested, true, walkRules{}, device)
|
||||||
|
|
||||||
want = []string{"/p", pqRoot}
|
want = []string{"/p", pqRoot}
|
||||||
if !slices.Equal(got, want) {
|
if !slices.Equal(got, want) {
|
||||||
t.Fatalf("pruneRoots with -x = %q, want %q", got, want)
|
t.Fatalf("pruneRoots with -x = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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{excludes: []string{pqRoot}},
|
||||||
|
device)
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("pruneRoots excluding %s = %q, want %q", pqRoot, got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nor does it enter /p/q when the filesystem of /p/q is not
|
||||||
|
// persistent, so /p/q/r/m, of no known device, is kept.
|
||||||
|
got = pruneRoots([]string{"/p", "/p/q/r/m"}, false,
|
||||||
|
walkRules{nonPersistent: map[uint64]string{2: "tmpfs"}}, device)
|
||||||
|
|
||||||
|
want = []string{"/p", "/p/q/r/m"}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
|
||||||
|
pqRoot, got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
func TestReportsNeverTouchFilesystem(t *testing.T) {
|
||||||
@@ -2480,3 +2501,99 @@ func TestUnderRoot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunScanPassesOverDatabase scans a tree that holds the scan's own
|
||||||
|
// 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 _, 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)
|
||||||
|
|
||||||
|
db, err := openScanDatabase(t.Context(), dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = applyChanges(t.Context(), db, []scanRec{
|
||||||
|
{path: filepath.Join(operand, "db.sqlite.lock")},
|
||||||
|
}, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeScanDatabase(t.Context(), db, dbPath)
|
||||||
|
|
||||||
|
assertScanSummary(t,
|
||||||
|
"4 files seen (4 added, 0 updated, 1 removed, 0 unchanged)",
|
||||||
|
operand)
|
||||||
|
assertScanSummary(t,
|
||||||
|
"4 files seen (0 added, 0 updated, 0 removed, 4 unchanged)",
|
||||||
|
operand)
|
||||||
|
|
||||||
|
report := runStdout(t, cmdReport)
|
||||||
|
if strings.Contains(report, "db.sqlite") ||
|
||||||
|
!strings.Contains(report, filepath.Join(operand, "empty2")) {
|
||||||
|
t.Errorf("report = %q, want the empty files and none of "+
|
||||||
|
"the database's files", report)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunScanPassesOverDatabaseOperands scans the database file and its
|
||||||
|
// .lock file given as operands themselves: neither is recorded or
|
||||||
|
// counted.
|
||||||
|
func TestRunScanPassesOverDatabaseOperands(t *testing.T) {
|
||||||
|
dbPath := testDBPath(t)
|
||||||
|
t.Setenv(databaseEnv, dbPath)
|
||||||
|
|
||||||
|
// The first scan creates both files.
|
||||||
|
scanOK(t, t.TempDir())
|
||||||
|
|
||||||
|
assertScanSummary(t,
|
||||||
|
"0 files seen (0 added, 0 updated, 0 removed, 0 unchanged)",
|
||||||
|
dbPath, dbPath+".lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func assertScanSummary(t *testing.T, counts string, operands ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stderr := scanOK(t, operands...)
|
||||||
|
|
||||||
|
want := "scan: " + counts + ", 0 skipped\n"
|
||||||
|
if !strings.Contains(stderr, want) {
|
||||||
|
t.Errorf("scan %q: stderr = %q, want %q", operands, stderr, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Count(stderr, "db.sqlite") != 1 {
|
||||||
|
t.Errorf("scan %q: stderr = %q, want only the line naming the "+
|
||||||
|
"database to name one of its files", operands, stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-4
@@ -2,11 +2,12 @@
|
|||||||
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
# script/cibuild: run the CI build. It bootstraps first: a CI runner
|
||||||
# checks out and runs this and nothing else, and script/fmt-check runs
|
# checks out and runs this and nothing else, and script/fmt-check runs
|
||||||
# the formatter on the host, which a pristine checkout cannot do.
|
# the formatter on the host, which a pristine checkout cannot do.
|
||||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
||||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
# line, since a cached gate phase is a check that did not run; unlike
|
||||||
|
# --no-cache, it keeps the layers above that line and the cache mounts.
|
||||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||||
# first; a stage without that line is served from cache on an unchanged
|
# before every step that lints, tests or compiles; a stage without that
|
||||||
# tree, with no warning.
|
# line is served from cache on an unchanged tree, with no warning.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+5
-4
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# Identical in all repos; the tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
||||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
# line, since a cached gate phase is a check that did not run; unlike
|
||||||
|
# --no-cache, it keeps the layers above that line and the cache mounts.
|
||||||
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
|
||||||
# first; a stage without that line is served from cache on an unchanged
|
# before every step that lints, tests or compiles; a stage without that
|
||||||
# tree, with no warning.
|
# line is served from cache on an unchanged tree, with no warning.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
+7
-5
@@ -5,11 +5,13 @@
|
|||||||
# make its answer untrustworthy.
|
# make its answer untrustworthy.
|
||||||
#
|
#
|
||||||
# The phase is not the last stage in the file, so it is built only when
|
# The phase is not the last stage in the file, so it is built only when
|
||||||
# --target names it. A new CACHEBUST value reruns every RUN step, since
|
# --target names it. A new CACHEBUST value reruns every RUN step after
|
||||||
# a cached lint layer is a lint that did not run; unlike --no-cache, it
|
# the ARG CACHEBUST line, since a cached lint layer is a lint that did
|
||||||
# keeps Go's build cache. This relies on every stage of the Dockerfile
|
# not run; unlike --no-cache, it keeps the layers above that line and
|
||||||
# declaring ARG CACHEBUST first; a stage without that line is served
|
# the cache mounts. This relies on every stage of the Dockerfile
|
||||||
# from cache on an unchanged tree, with no warning.
|
# declaring ARG CACHEBUST before every step that lints, tests or
|
||||||
|
# compiles; a stage without that line is served from cache on an
|
||||||
|
# unchanged tree, with no warning.
|
||||||
# --output type=cacheonly writes no image, since nothing uses one.
|
# --output type=cacheonly writes no image, since nothing uses one.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
|||||||
+6
-5
@@ -3,11 +3,12 @@
|
|||||||
# and this builds that phase alone, on the same terms as script/lint:
|
# and this builds that phase alone, on the same terms as script/lint:
|
||||||
# --target because a phase that is not the last stage is built only when
|
# --target because a phase that is not the last stage is built only when
|
||||||
# named, and a new CACHEBUST value because a cached test layer is a test
|
# named, and a new CACHEBUST value because a cached test layer is a test
|
||||||
# that did not run; unlike --no-cache, it keeps Go's build cache. This
|
# that did not run; unlike --no-cache, it keeps the layers above the ARG
|
||||||
# relies on every stage of the Dockerfile declaring ARG CACHEBUST first;
|
# CACHEBUST line and the cache mounts. This relies on every stage of the
|
||||||
# a stage without that line is served from cache on an unchanged tree,
|
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
|
||||||
# with no warning. --output type=cacheonly writes no image, since
|
# or compiles; a stage without that line is served from cache on an
|
||||||
# nothing uses one.
|
# unchanged tree, with no warning. --output type=cacheonly writes no
|
||||||
|
# image, since nothing uses one.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
|
|||||||
|
|
||||||
prog = newProgress(progressParams{
|
prog = newProgress(progressParams{
|
||||||
label: treesPhase("read"), unit: "records", total: -1,
|
label: treesPhase("read"), unit: "records", total: -1,
|
||||||
waitingFor: countingRecords,
|
waitingFor: countingRecords, stalledOn: "db",
|
||||||
})
|
})
|
||||||
records, super, allDirs, err := readTree(ctx, db, prog)
|
records, super, allDirs, err := readTree(ctx, db, prog)
|
||||||
|
|
||||||
@@ -82,9 +82,11 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
|
|||||||
return fmt.Errorf("database %s: %w", dbPath, err)
|
return fmt.Errorf("database %s: %w", dbPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Grouping works in memory, so it waits on nothing but the
|
||||||
|
// processor.
|
||||||
prog = newProgress(progressParams{
|
prog = newProgress(progressParams{
|
||||||
label: treesPhase("group"), unit: "directories",
|
label: treesPhase("group"), unit: "directories",
|
||||||
total: int64(len(allDirs)),
|
total: int64(len(allDirs)), stalledOn: "cpu",
|
||||||
})
|
})
|
||||||
dupes := collectTreeGroups(allDirs, super, prog)
|
dupes := collectTreeGroups(allDirs, super, prog)
|
||||||
|
|
||||||
@@ -92,6 +94,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
|
|||||||
|
|
||||||
prog = newProgress(progressParams{
|
prog = newProgress(progressParams{
|
||||||
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
||||||
|
stalledOn: "out",
|
||||||
})
|
})
|
||||||
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog)
|
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -279,7 +279,7 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
|
|||||||
// No timed draws: the display is drawn as it starts and as it ends.
|
// No timed draws: the display is drawn as it starts and as it ends.
|
||||||
p := startProgress(progressParams{
|
p := startProgress(progressParams{
|
||||||
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
||||||
}, terminalWidth, time.Hour)
|
}, terminalWidth, time.Hour, time.Now)
|
||||||
|
|
||||||
_, _, err := writeTreeGroups(os.Stderr, dupes, p)
|
_, _, err := writeTreeGroups(os.Stderr, dupes, p)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user