Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2fdb0b55d | ||
|
|
ee09cda355 | ||
|
|
aa99b37b99 | ||
|
|
68881f04f2 | ||
|
|
f7a746f912 | ||
|
|
f4ddb58ae1 | ||
|
|
6346319701 | ||
|
|
4e4a94db2b | ||
|
|
dcfe0ea080 | ||
|
|
003e184f71 | ||
|
|
68f6bde5ee |
+20
-12
@@ -1,16 +1,23 @@
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||
# system packages and download Go's modules, or right after FROM in a
|
||||
# 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
|
||||
# rather than through `make lint`, which runs docker itself and so cannot
|
||||
# run inside a build step.
|
||||
# golangci/golangci-lint:v2.14.0, 2026-10-07
|
||||
FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
ARG CACHEBUST
|
||||
COPY . .
|
||||
|
||||
# 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.
|
||||
RUN golangci-lint config verify --config .golangci.yml
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# 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.
|
||||
# golang:1.25-trixie, 2026-10-04
|
||||
FROM golang@sha256:2c4c60ef415fbfa5e90300722293bef36c5e63fae17570ce18f580af933dbd73 AS test
|
||||
ARG CACHEBUST
|
||||
USER nobody
|
||||
ENV HOME=/tmp
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
ARG CACHEBUST
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/tmp/.cache/go-build,uid=65534,gid=65534 \
|
||||
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; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies are
|
||||
# what make BuildKit build them first, so this stage cannot run unless
|
||||
# lint and test passed.
|
||||
# what make BuildKit build them first, so nothing after them can run
|
||||
# unless lint and test passed.
|
||||
# golang:1.25-alpine, 2026-07-23
|
||||
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
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
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 . .
|
||||
|
||||
# The version stamped into the binary: the VERSION build argument when
|
||||
|
||||
+68
-35
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-10-15
|
||||
last_modified: 2026-10-18
|
||||
---
|
||||
|
||||
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
|
||||
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
|
||||
org, in both directions. A container has its own golangci-lint cache, its
|
||||
own `TMPDIR` and a digest-pinned binary, so neither is reachable.
|
||||
org, in both directions. A container has its own `TMPDIR`, so the lock is
|
||||
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
|
||||
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
|
||||
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
|
||||
`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
|
||||
stage of the `Dockerfile` declares `ARG CACHEBUST` as its first line after
|
||||
`FROM`, and a new value reruns every `RUN` step after that line; `COPY` steps
|
||||
are compared by content and need nothing. 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 empties every cache mount, and Go's
|
||||
build cache, which the canonical Go `Dockerfile` below mounts so that compiled
|
||||
packages survive between check builds, would start empty every time. A bare
|
||||
two gate phases and `script/fmt-check`, and builds no image of its own. In
|
||||
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
|
||||
that install system packages and download Go's modules, or right after `FROM`
|
||||
in a stage with neither, and every step that lints, tests or compiles comes
|
||||
after it. A step that installs anything else as well, such as a
|
||||
`script/bootstrap` that also installs node packages, comes after it too. A new
|
||||
value reruns every `RUN` step after that line; `COPY` steps are compared by
|
||||
content and need nothing. Docker keeps the layers above the line until the
|
||||
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
|
||||
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
|
||||
@@ -194,19 +203,25 @@ style conventions are in separate documents:
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
# Every stage declares ARG CACHEBUST first: script/ passes a new value
|
||||
# on every build, which reruns every RUN step after it. The steps that
|
||||
# compile mount Go's build cache, which survives from build to build.
|
||||
# In every stage ARG CACHEBUST comes right after the steps that install
|
||||
# system packages and download Go's modules, or right after FROM in a
|
||||
# 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
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
ARG CACHEBUST
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/root/.cache/golangci-lint \
|
||||
golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# 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.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
ARG CACHEBUST
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
ARG CACHEBUST
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
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; }
|
||||
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
# unless lint and test passed.
|
||||
# are what make BuildKit build them first, so nothing after them can
|
||||
# run unless lint and test passed.
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
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
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
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 . .
|
||||
|
||||
# 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
|
||||
same two copies. A plain `docker build .` builds the last stage's chain
|
||||
and nothing else.
|
||||
- Only Go's build cache is mounted (`/root/.cache/go-build`, where Go keeps
|
||||
it for root), and only on the steps that compile: `golangci-lint run`,
|
||||
both `go test` runs and `go build`. It keeps compiled packages between
|
||||
check builds, and Go reuses one only when all its inputs are unchanged, so
|
||||
it saves the compile without skipping a check. Nothing else is kept:
|
||||
golangci-lint's own cache would replay stored findings in place of a lint
|
||||
run, and Go builds from whatever files a kept module cache holds without
|
||||
checking them again (only `go mod verify` does), so a file changed there
|
||||
would go unnoticed. Go's build cache also holds test results, which is why
|
||||
both `go test` runs carry `-count=1`.
|
||||
- Check builds keep these, and none of them can hand back a stale result:
|
||||
- The layers above `ARG CACHEBUST`: the system packages and Go's
|
||||
modules. Docker installs the packages again when the base image or the
|
||||
install line changes, and downloads the modules again when `go.mod` or
|
||||
`go.sum` changes. Go checks every module against `go.sum` as it
|
||||
downloads it, and no later build can change a kept layer. The modules
|
||||
are kept in a layer rather than a cache mount because a cache mount is
|
||||
shared by every build on the host, and Go builds from whatever files
|
||||
it holds without checking them again (only `go mod verify` does), so a
|
||||
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
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
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
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`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
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||
&& 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
|
||||
|
||||
@@ -29,6 +29,111 @@
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- `README.md` rewritten from scratch, sentence by sentence, as a newcomer reads
|
||||
it. It leads with what the program is and how to use it. A reference section
|
||||
covers every setting and every subcommand. Development and testing come last.
|
||||
Every statement was checked against the code on `next`. Five comments in
|
||||
`main_test.go` cite "Settings", where their rules now live. One comment each
|
||||
in `scan_test.go`, `trees_test.go` and `exclude_test.go` cites what the README
|
||||
now says (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/135)
|
||||
|
||||
- an operand at or inside a directory named `.zfs` is never scanned, whether
|
||||
given alone or under another operand. `scan` warns about it, counts it as
|
||||
skipped and does not walk it (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/142)
|
||||
|
||||
- a file or directory removed while the scan runs is not an error, in the walk
|
||||
or in the hash phase. The scan prints no line for it and does not count it as
|
||||
skipped. When the first of several hard links to a file is gone, the hash
|
||||
phase reads the next one instead. On a terminal every progress display starts
|
||||
with the spinner, the hash phase's included. A phase with an exact total ends
|
||||
its line with its percent and a 10-column bar at the terminal's right edge,
|
||||
which no longer moves as the line's length changes. A line too wide for the
|
||||
terminal now loses its rate before its bar. The time remaining is labelled
|
||||
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133)
|
||||
|
||||
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32
|
||||
bytes of a SHA-256, half the size of the hex text they held. The hashes stay
|
||||
raw from the hash phase through the database to `report` and `trees`. Reading
|
||||
a record whose hash is not 32 bytes is an error (2026-10-26,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/134)
|
||||
|
||||
- the hash phase gives every file the walk records three hashes: `head` and
|
||||
`tail`, the SHA-256 of its first and last 64 KiB, and `content`, the SHA-256
|
||||
of the whole file under 100 MiB and of 1 MiB at each gigabyte from 100 MiB up.
|
||||
The 10 MiB and 50 MiB thresholds, the size census, the records stored without
|
||||
hashes and the content phase are gone, so a scan has four phases and a rescan
|
||||
reads only new and changed files. `info` no longer counts the records with
|
||||
hashes, since every record has them (2026-10-25,
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/132)
|
||||
|
||||
- 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
|
||||
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
|
||||
@@ -96,7 +201,11 @@
|
||||
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;
|
||||
`.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
|
||||
being dropped when a directory from it (from its directory, for a regular
|
||||
@@ -297,9 +406,9 @@
|
||||
a `CHECK_EPOCH` build argument; since
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/95 every `docker build` in `script/`
|
||||
passes a new `CACHEBUST` value instead, which every stage of the `Dockerfile`
|
||||
declares first. Run as root, the tests fail
|
||||
`TestScanHardlinkRunFailsTogether`, because root reads through the `chmod(0)`
|
||||
the test relies on, so the `test` phase runs them as `nobody`
|
||||
declares before every step that lints, tests or compiles. Run as root, the
|
||||
tests fail `TestScanHardlinkRunFailsTogether`, because root reads through the
|
||||
`chmod(0)` the test relies on, so the `test` phase runs them as `nobody`
|
||||
- check the installed golangci-lint version in `script/bootstrap` instead of
|
||||
only its presence (2026-08-09, branch `bootstrap-version-check`, closes
|
||||
https://git.eeqj.de/sneak/sfdupes/issues/24): the version lives only in
|
||||
|
||||
+34
-24
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -168,8 +169,8 @@ func assertRecordsIntact(t *testing.T, db *sql.DB, before []string) {
|
||||
|
||||
// TestSyncScanCancelledMidWalkKeepsRecords is the regression net under
|
||||
// the post-walk guard. The scan is cancelled part-way through the
|
||||
// walk, so it reaches the guard holding a genuinely partial size
|
||||
// census and a still-populated index of records the walk never got to.
|
||||
// walk, so it reaches the guard having walked a genuinely partial tree
|
||||
// and holding a still-populated index of records the walk never got to.
|
||||
// Every one of those records would look vanished to the update phase.
|
||||
// The guard is what stops the scan there, and this test is what
|
||||
// notices if it stops doing so: deleting the guard, or making it
|
||||
@@ -196,7 +197,7 @@ func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
|
||||
base := baselineGoroutines(t)
|
||||
|
||||
st, err := syncScan(newWalkClock(walkCancelAtDone), db,
|
||||
[]string{dir}, walkCancelWorkers, false)
|
||||
[]string{dir}, walkCancelWorkers, false, walkRules{})
|
||||
|
||||
assertWalkGuardAborted(t, st, err)
|
||||
assertRecordsIntact(t, db, before)
|
||||
@@ -208,10 +209,10 @@ func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
|
||||
}
|
||||
|
||||
// assertWalkGuardAborted checks that the scan stopped at the post-walk
|
||||
// guard: with a census that is neither empty (the walk really ran)
|
||||
// nor complete (it really was cut short), and with no record counted
|
||||
// for removal. A removal count means the partial census was carried
|
||||
// past the guard into the update phase, which is the failure this test
|
||||
// guard: with a walk that reached some files (it really ran) but not
|
||||
// all of them (it really was cut short), and with no record counted for
|
||||
// removal. A removal count means the partial walk was carried past
|
||||
// the guard into the update phase, which is the failure this test
|
||||
// exists to catch.
|
||||
func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
|
||||
t.Helper()
|
||||
@@ -222,30 +223,30 @@ func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
|
||||
}
|
||||
|
||||
if st.unchanged == 0 {
|
||||
t.Fatalf("stats = %+v: the census is empty, so the walk never "+
|
||||
t.Fatalf("stats = %+v: the walk reached no file, so it never "+
|
||||
"ran and the guard was reached for the wrong reason", st)
|
||||
}
|
||||
|
||||
if st.unchanged >= walkCancelFiles {
|
||||
t.Fatalf("stats = %+v: the census covers the whole tree, so the "+
|
||||
"walk was not cut short", st)
|
||||
t.Fatalf("stats = %+v: the walk reached the whole tree, so it "+
|
||||
"was not cut short", st)
|
||||
}
|
||||
|
||||
// The workers drop every directory still queued once the scan is
|
||||
// cancelled, so only the files in the directories already in flight
|
||||
// can add to the census after the fact. A census beyond that bound
|
||||
// can still be reached after the fact. A walk beyond that bound
|
||||
// would mean the cancellation was not observed where it should have
|
||||
// been.
|
||||
limit := walkCancelAtDone + walkCancelInFlightFiles
|
||||
if st.unchanged > limit {
|
||||
t.Errorf("census covers %d files, want at most %d: the walk kept "+
|
||||
t.Errorf("the walk reached %d files, want at most %d: it kept "+
|
||||
"taking directories off the queue after cancellation",
|
||||
st.unchanged, limit)
|
||||
}
|
||||
|
||||
if st.removed != 0 {
|
||||
t.Errorf("stats = %+v: the scan counted records for removal from "+
|
||||
"a partial census", st)
|
||||
"a partial walk", st)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +269,8 @@ func TestSyncScanCancelledBeforeLoadIndex(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
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) {
|
||||
t.Fatalf("syncScan on a cancelled context = %v, want %v",
|
||||
err, context.Canceled)
|
||||
@@ -301,7 +303,7 @@ func TestSyncScanCancelledMidHashKeepsHashedRecords(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
st, err := syncScan(newWalkClock(hashCancelAtDone), db,
|
||||
[]string{dir}, walkCancelWorkers, false)
|
||||
[]string{dir}, walkCancelWorkers, false, walkRules{})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("syncScan cancelled mid-hash = %v, want %v",
|
||||
err, context.Canceled)
|
||||
@@ -356,7 +358,8 @@ func TestRunScanInterrupted(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
dir := buildSmokeTree(t)
|
||||
|
||||
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false)
|
||||
err := runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
|
||||
false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -380,7 +383,7 @@ func TestRunScanInterrupted(t *testing.T) {
|
||||
defer close(done)
|
||||
|
||||
err = runScan(cancelledContext(t), []string{dir}, walkCancelWorkers,
|
||||
false)
|
||||
false, false)
|
||||
}()
|
||||
|
||||
awaitReturn(t, done, "runScan")
|
||||
@@ -407,7 +410,8 @@ func TestRunScanInterrupted(t *testing.T) {
|
||||
got, before)
|
||||
}
|
||||
|
||||
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false)
|
||||
err = runScan(t.Context(), []string{dir}, walkCancelWorkers, false,
|
||||
false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -433,7 +437,7 @@ func TestRunScanInterruptedMidHash(t *testing.T) {
|
||||
dir := buildWalkCancelTree(t)
|
||||
|
||||
err := runScan(newWalkClock(hashCancelAtDone), []string{dir},
|
||||
walkCancelWorkers, false)
|
||||
walkCancelWorkers, false, false)
|
||||
if !errors.Is(err, errInterrupted) {
|
||||
t.Fatalf("runScan interrupted mid-hash = %v, want %v",
|
||||
err, errInterrupted)
|
||||
@@ -617,7 +621,8 @@ func TestWalkOneDirStopsWhenCancelled(t *testing.T) {
|
||||
// Unbuffered and unread: on a cancelled scan every send gives up.
|
||||
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 {
|
||||
t.Errorf("cancelled walkOneDir returned %+v to descend into, "+
|
||||
"want none", subs)
|
||||
@@ -637,7 +642,8 @@ func TestWalkWorkersDropQueuedDirs(t *testing.T) {
|
||||
|
||||
missing := filepath.Join(t.TempDir(), "missing")
|
||||
|
||||
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false)
|
||||
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false,
|
||||
walkRules{})
|
||||
|
||||
for range 64 {
|
||||
jobs <- dirJob{path: missing}
|
||||
@@ -668,7 +674,7 @@ func TestWalkWorkerAbandonsSubdirHandoff(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
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
|
||||
// — so the worker's own hand-back is certain to block.
|
||||
@@ -757,7 +763,9 @@ func TestHashWorkerDropsQueuedRuns(t *testing.T) {
|
||||
|
||||
var hashed atomic.Bool
|
||||
|
||||
hash := func(path string, size int64) (string, string, string, error) {
|
||||
hash := func(path string, size int64) (
|
||||
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
|
||||
) {
|
||||
hashed.Store(true)
|
||||
|
||||
return hashSignature(path, size)
|
||||
@@ -797,7 +805,9 @@ func TestHashWorkerAbandonsBlockedSend(t *testing.T) {
|
||||
|
||||
// The scan is cancelled while the worker hashes, so the worker has
|
||||
// already passed the check that drops queued runs.
|
||||
hash := func(path string, size int64) (string, string, string, error) {
|
||||
hash := func(path string, size int64) (
|
||||
[sha256.Size]byte, [sha256.Size]byte, [sha256.Size]byte, error,
|
||||
) {
|
||||
cancel()
|
||||
|
||||
return hashSignature(path, size)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -48,16 +49,17 @@ const lockFilePerm = 0o600
|
||||
// createTableSQL is the schema applied to a fresh database. Paths are
|
||||
// BLOBs because Unix paths are raw bytes, not guaranteed UTF-8. mtime
|
||||
// holds whole Unix seconds and mtime_nsec the nanoseconds within that
|
||||
// second.
|
||||
// second. head, tail and content each hold the raw 32 bytes of a
|
||||
// SHA-256.
|
||||
const createTableSQL = `
|
||||
CREATE TABLE files (
|
||||
path BLOB PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
mtime INTEGER NOT NULL,
|
||||
mtime_nsec INTEGER NOT NULL,
|
||||
head TEXT NOT NULL,
|
||||
tail TEXT NOT NULL,
|
||||
content TEXT NOT NULL
|
||||
head BLOB NOT NULL,
|
||||
tail BLOB NOT NULL,
|
||||
content BLOB NOT NULL
|
||||
) WITHOUT ROWID
|
||||
`
|
||||
|
||||
@@ -92,6 +94,10 @@ var errSchemaVersion = errors.New("unsupported database schema version")
|
||||
// database.
|
||||
var errScanRunning = errors.New("another scan is running")
|
||||
|
||||
// errHashSize reports a record whose head, tail or content is not the
|
||||
// 32 bytes of a SHA-256.
|
||||
var errHashSize = errors.New("hash is not 32 bytes")
|
||||
|
||||
// databasePath resolves the database location: SFDUPES_DATABASE when
|
||||
// set and non-empty, the compiled-in default otherwise. It returns the
|
||||
// path, with the working directory, its symlinks resolved, put in front
|
||||
@@ -109,10 +115,8 @@ func databasePath() (string, string, error) {
|
||||
}
|
||||
|
||||
// The working directory as the kernel reports it, with no symlink
|
||||
// in it. os.Getwd may return PWD instead, which can name it through
|
||||
// a symlink; a ".." after that symlink then leads somewhere else
|
||||
// once the path is cleaned, as scan cleans it to create the
|
||||
// database's directory.
|
||||
// in it, as README "Database" says. os.Getwd may return PWD
|
||||
// instead, which can name it through a symlink.
|
||||
wd, err := unix.Getwd()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("resolve database path %s: %w", path, err)
|
||||
@@ -125,6 +129,24 @@ func databasePath() (string, string, error) {
|
||||
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
|
||||
// WAL journaling and a busy timeout, so a report can run while a cron
|
||||
// scan is in progress. closeScanDatabase leaves WAL mode again.
|
||||
@@ -179,9 +201,9 @@ func openDB(path, params string) (*sql.DB, error) {
|
||||
// that deleted it would let the next scan lock a new file while
|
||||
// another still holds the old one.
|
||||
func lockScanDatabase(path string) (*os.File, error) {
|
||||
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
|
||||
err := createDatabaseDirectory(path)
|
||||
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
|
||||
@@ -225,9 +247,9 @@ func lockScanDatabase(path string) (*os.File, error) {
|
||||
// openScanDatabase opens the database for the scan subcommand, creating
|
||||
// the file, its parent directory, and the schema as needed.
|
||||
func openScanDatabase(ctx context.Context, path string) (*sql.DB, error) {
|
||||
err := os.MkdirAll(filepath.Dir(path), dbDirPerm)
|
||||
err := createDatabaseDirectory(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := openDB(path, scanParams)
|
||||
@@ -245,6 +267,42 @@ func openScanDatabase(ctx context.Context, path string) (*sql.DB, error) {
|
||||
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
|
||||
// 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
|
||||
@@ -440,19 +498,24 @@ func loadFileRows(ctx context.Context, db *sql.DB, total func(n int64),
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
sec, nsec int64
|
||||
r scanRec
|
||||
path, head, tail, content []byte
|
||||
sec, nsec int64
|
||||
r scanRec
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
|
||||
&r.content)
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &head, &tail, &content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
r.path = string(path)
|
||||
r.mtime = time.Unix(sec, nsec)
|
||||
|
||||
err = r.setHashes(head, tail, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
|
||||
}
|
||||
|
||||
fn(r)
|
||||
}
|
||||
|
||||
@@ -509,17 +572,22 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
r scanRec
|
||||
path, head, tail, content []byte
|
||||
r scanRec
|
||||
)
|
||||
|
||||
err = rows.Scan(&r.size, &r.head, &r.tail, &r.content, &path)
|
||||
err = rows.Scan(&r.size, &head, &tail, &content, &path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
r.path = string(path)
|
||||
|
||||
err = r.setHashes(head, tail, content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record %s: %w", escapePath(r.path), err)
|
||||
}
|
||||
|
||||
err = fn(r)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -534,6 +602,22 @@ func loadSignatureRows(ctx context.Context, db *sql.DB,
|
||||
return nil
|
||||
}
|
||||
|
||||
// setHashes sets r's head, tail and content to the values read from
|
||||
// those columns. Each must be the raw 32 bytes of a SHA-256.
|
||||
func (r *scanRec) setHashes(head, tail, content []byte) error {
|
||||
for _, b := range [][]byte{head, tail, content} {
|
||||
if len(b) != sha256.Size {
|
||||
return fmt.Errorf("%w: found %d bytes", errHashSize, len(b))
|
||||
}
|
||||
}
|
||||
|
||||
r.head = [sha256.Size]byte(head)
|
||||
r.tail = [sha256.Size]byte(tail)
|
||||
r.content = [sha256.Size]byte(content)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// countRecords returns the number of records in the database.
|
||||
func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var n int64
|
||||
@@ -546,15 +630,11 @@ func countRecords(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// databaseCountsSQL counts the records, those with head and tail
|
||||
// hashes and those with a content hash, and adds up their sizes, in one
|
||||
// databaseCountsSQL counts the records and adds up their sizes, in one
|
||||
// pass over the table. A SUM over no rows is NULL, so on an empty table
|
||||
// each SUM falls back to 0.
|
||||
// the SUM falls back to 0.
|
||||
const databaseCountsSQL = `
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(head <> '' AND tail <> ''), 0),
|
||||
COALESCE(SUM(content <> ''), 0),
|
||||
COALESCE(SUM(size), 0)
|
||||
SELECT COUNT(*), COALESCE(SUM(size), 0)
|
||||
FROM files
|
||||
`
|
||||
|
||||
@@ -563,8 +643,6 @@ FROM files
|
||||
type databaseCounts struct {
|
||||
schemaVersion int
|
||||
records int64 // every record
|
||||
headTail int64 // records with head and tail hashes
|
||||
content int64 // records with a content hash
|
||||
size int64 // the sizes of all records added up
|
||||
}
|
||||
|
||||
@@ -592,7 +670,7 @@ func readDatabaseCounts(ctx context.Context,
|
||||
}
|
||||
|
||||
err = tx.QueryRowContext(ctx, databaseCountsSQL).
|
||||
Scan(&c.records, &c.headTail, &c.content, &c.size)
|
||||
Scan(&c.records, &c.size)
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("count records: %w", err)
|
||||
}
|
||||
@@ -600,15 +678,15 @@ func readDatabaseCounts(ctx context.Context,
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// loadFileMeta streams every record's path, size, mtime, and whether
|
||||
// it carries hashes to fn. Scan change detection needs no hash
|
||||
// values, and skipping the hash columns keeps the scan's in-memory
|
||||
// index small on multi-million-file databases.
|
||||
// loadFileMeta streams every record's path, size and mtime to fn. Scan
|
||||
// change detection needs no hash values, and skipping the hash columns
|
||||
// keeps the scan's in-memory index small on multi-million-file
|
||||
// databases.
|
||||
func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
fn func(path string, size int64, mtime time.Time, hashed bool),
|
||||
fn func(path string, size int64, mtime time.Time),
|
||||
) error {
|
||||
rows, err := db.QueryContext(ctx,
|
||||
"SELECT path, size, mtime, mtime_nsec, head <> '' FROM files")
|
||||
"SELECT path, size, mtime, mtime_nsec FROM files")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
@@ -619,94 +697,14 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
|
||||
var (
|
||||
path []byte
|
||||
size, sec, nsec int64
|
||||
hashed int64
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &size, &sec, &nsec, &hashed)
|
||||
err = rows.Scan(&path, &size, &sec, &nsec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
fn(string(path), size, time.Unix(sec, nsec), hashed != 0)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentGroupsSQL selects each group (the records sharing a size,
|
||||
// head, and tail) of records of at least headTailMin bytes that has more
|
||||
// than one record and at least one record without a content hash, with
|
||||
// the number of its records.
|
||||
const contentGroupsSQL = `
|
||||
SELECT size, head, tail, COUNT(*) AS records
|
||||
FROM files
|
||||
WHERE size >= ? AND head <> ''
|
||||
GROUP BY size, head, tail
|
||||
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
|
||||
`
|
||||
|
||||
// contentCandidatesSQL selects every record of the groups
|
||||
// contentGroupsSQL selects, with whether each record has a content
|
||||
// hash. SQLite does the grouping, so no other record's hashes are
|
||||
// loaded into memory; the rows come ordered by size, head, and tail, so
|
||||
// each group's rows arrive together.
|
||||
const contentCandidatesSQL = `
|
||||
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
|
||||
FROM files AS f
|
||||
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
|
||||
ORDER BY size, head, tail
|
||||
`
|
||||
|
||||
// countContentCandidates returns the number of rows of
|
||||
// contentCandidatesSQL, adding up the records of the groups
|
||||
// contentGroupsSQL selects, so it neither joins nor sorts.
|
||||
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
|
||||
var n int64
|
||||
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
|
||||
headTailMin).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count content candidates: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// loadContentCandidates streams the rows of contentCandidatesSQL to fn:
|
||||
// each record, without its content hash, and whether it has one.
|
||||
func loadContentCandidates(ctx context.Context, db *sql.DB,
|
||||
fn func(r scanRec, hashed bool),
|
||||
) error {
|
||||
rows, err := db.QueryContext(ctx, contentCandidatesSQL, headTailMin)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read records: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
path []byte
|
||||
sec, nsec int64
|
||||
r scanRec
|
||||
hashed int64
|
||||
)
|
||||
|
||||
err = rows.Scan(&path, &r.size, &sec, &nsec, &r.head, &r.tail,
|
||||
&hashed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
|
||||
r.path = string(path)
|
||||
r.mtime = time.Unix(sec, nsec)
|
||||
fn(r, hashed != 0)
|
||||
fn(string(path), size, time.Unix(sec, nsec))
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
@@ -790,7 +788,8 @@ func execUpserts(ctx context.Context, tx *sql.Tx, upserts []scanRec,
|
||||
|
||||
for _, r := range upserts {
|
||||
_, err = st.ExecContext(ctx, []byte(r.path), r.size,
|
||||
r.mtime.Unix(), r.mtime.Nanosecond(), r.head, r.tail, r.content)
|
||||
r.mtime.Unix(), r.mtime.Nanosecond(),
|
||||
r.head[:], r.tail[:], r.content[:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert %s: %w", r.path, err)
|
||||
}
|
||||
|
||||
+182
-67
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -118,6 +120,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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -362,6 +474,67 @@ func TestLoadRowsCountAgreeDuringScan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRowsRefuseWrongSizeHash stores a record whose head, tail or
|
||||
// content is not the 32 bytes of a SHA-256: either one byte, or the 64
|
||||
// bytes of a SHA-256 written as hex text. loadFileRows and
|
||||
// loadSignatureRows must each fail on it with an error naming its path.
|
||||
func TestLoadRowsRefuseWrongSizeHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hexText := strings.Repeat("ab", sha256.Size)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
update string
|
||||
value any
|
||||
}{
|
||||
{"one-byte head", "UPDATE files SET head = ?", []byte{0}},
|
||||
{"hex-text head", "UPDATE files SET head = ?", hexText},
|
||||
{"one-byte tail", "UPDATE files SET tail = ?", []byte{0}},
|
||||
{"hex-text tail", "UPDATE files SET tail = ?", hexText},
|
||||
{"one-byte content", "UPDATE files SET content = ?", []byte{0}},
|
||||
{"hex-text content", "UPDATE files SET content = ?", hexText},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t)
|
||||
|
||||
err := applyChanges(t.Context(), db,
|
||||
[]scanRec{{path: "/a/f"}}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(t.Context(), c.update, c.value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loads := map[string]func() error{
|
||||
"loadFileRows": func() error {
|
||||
return loadFileRows(t.Context(), db, func(int64) {},
|
||||
func(scanRec) {})
|
||||
},
|
||||
"loadSignatureRows": func() error {
|
||||
return loadSignatureRows(t.Context(), db, func(int64) {},
|
||||
func(scanRec) error { return nil })
|
||||
},
|
||||
}
|
||||
|
||||
for name, load := range loads {
|
||||
err := load()
|
||||
if !errors.Is(err, errHashSize) ||
|
||||
!strings.Contains(err.Error(), "/a/f") {
|
||||
t.Errorf("%s: err = %v, want %v naming /a/f",
|
||||
name, err, errHashSize)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignatureRowsNeedNoSort checks that SQLite reads signatureRowsSQL
|
||||
// from the files_signature index alone, with nothing to sort: the
|
||||
// first record comes at once, so report's read phase counts each
|
||||
@@ -416,12 +589,13 @@ func TestApplyChangesRoundTrip(t *testing.T) {
|
||||
// written.
|
||||
recs := []scanRec{
|
||||
{
|
||||
size: 2, mtime: time.Unix(20, 999_999_999), head: "h2", tail: "t2",
|
||||
content: "c2", path: "/a/tab\tnew\nline",
|
||||
size: 2, mtime: time.Unix(20, 999_999_999), head: testHash("h2"),
|
||||
tail: testHash("t2"), content: testHash("c2"),
|
||||
path: "/a/tab\tnew\nline",
|
||||
},
|
||||
{
|
||||
size: 1, mtime: time.Unix(10, 0), head: "h1", tail: "t1",
|
||||
content: "c1", path: "/a/x",
|
||||
size: 1, mtime: time.Unix(10, 0), head: testHash("h1"),
|
||||
tail: testHash("t1"), content: testHash("c1"), path: "/a/x",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -442,8 +616,8 @@ func TestApplyChangesRoundTrip(t *testing.T) {
|
||||
// An upsert for an existing path updates in place; a delete
|
||||
// removes exactly its path.
|
||||
upd := scanRec{
|
||||
size: 3, mtime: time.Unix(30, 0), head: "h3", tail: "t3", content: "c3",
|
||||
path: "/a/x",
|
||||
size: 3, mtime: time.Unix(30, 0), head: testHash("h3"),
|
||||
tail: testHash("t3"), content: testHash("c3"), path: "/a/x",
|
||||
}
|
||||
|
||||
err = applyChanges(t.Context(), db, []scanRec{upd},
|
||||
@@ -470,8 +644,8 @@ func TestApplyChangesBatching(t *testing.T) {
|
||||
recs := make([]scanRec, 0, n)
|
||||
for i := range n {
|
||||
recs = append(recs, scanRec{
|
||||
size: int64(i), mtime: time.Unix(1, 0), head: "h", tail: "t",
|
||||
path: fmt.Sprintf("/batch/%07d", i),
|
||||
size: int64(i), mtime: time.Unix(1, 0), head: testHash("h"),
|
||||
tail: testHash("t"), path: fmt.Sprintf("/batch/%07d", i),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,62 +675,3 @@ func TestApplyChangesBatching(t *testing.T) {
|
||||
t.Fatalf("records = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountContentCandidates checks that countContentCandidates counts
|
||||
// the records the content phase's search returns: those of each group
|
||||
// of two or more records of headTailMin or more with a head, where at
|
||||
// least one has no content hash.
|
||||
func TestCountContentCandidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := openTestDB(t)
|
||||
|
||||
// Each group's records: size, head, and content, one per record.
|
||||
groups := []struct {
|
||||
size int64
|
||||
head string
|
||||
contents []string
|
||||
}{
|
||||
{headTailMin, "h", []string{"", "c", "c"}}, // returned
|
||||
{headTailMin + 1, "h", []string{"", ""}}, // returned
|
||||
{headTailMin + 2, "h", []string{"c", "c"}}, // all hashed
|
||||
{headTailMin + 3, "h", []string{""}}, // one record
|
||||
{headTailMin - 1, "h", []string{"", ""}}, // too small
|
||||
{headTailMin + 4, "", []string{"", ""}}, // no head
|
||||
}
|
||||
|
||||
var recs []scanRec
|
||||
|
||||
for _, g := range groups {
|
||||
for _, content := range g.contents {
|
||||
recs = append(recs, scanRec{
|
||||
size: g.size, mtime: time.Unix(1, 0), head: g.head,
|
||||
tail: "t", content: content,
|
||||
path: fmt.Sprintf("/r/%d", len(recs)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
err := applyChanges(t.Context(), db, recs, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("applyChanges: %v", err)
|
||||
}
|
||||
|
||||
got, err := countContentCandidates(t.Context(), db)
|
||||
if err != nil {
|
||||
t.Fatalf("countContentCandidates: %v", err)
|
||||
}
|
||||
|
||||
var returned int64
|
||||
|
||||
err = loadContentCandidates(t.Context(), db,
|
||||
func(scanRec, bool) { returned++ })
|
||||
if err != nil {
|
||||
t.Fatalf("loadContentCandidates: %v", err)
|
||||
}
|
||||
|
||||
if got != 5 || returned != 5 {
|
||||
t.Errorf("count = %d, records returned = %d, want 5 and 5",
|
||||
got, returned)
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
+515
@@ -0,0 +1,515 @@
|
||||
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": the Linux and macOS lists, and none
|
||||
// on any other system. The Windows list exists in the code only: the
|
||||
// binary does not build for Windows, so the README leaves it out.
|
||||
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,
|
||||
// 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))
|
||||
|
||||
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 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, while a file added to it
|
||||
// is not recorded.
|
||||
//
|
||||
//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))
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
retract v0.0.1 // early build without a database; does not work as the README describes
|
||||
|
||||
@@ -113,9 +113,6 @@ func writeInfo(stdout io.Writer, dbPath, source string,
|
||||
dbFile.ModTime().UTC().Format(time.RFC3339))
|
||||
fmt.Fprintf(&b, "schema version\t%d\n", counts.schemaVersion)
|
||||
fmt.Fprintf(&b, "records\t%d\n", counts.records)
|
||||
fmt.Fprintf(&b, "records with head and tail hashes\t%d\n",
|
||||
counts.headTail)
|
||||
fmt.Fprintf(&b, "records with content hash\t%d\n", counts.content)
|
||||
fmt.Fprintf(&b, "size of recorded files\t%d (%s)\n",
|
||||
counts.size, humanBytes(counts.size))
|
||||
|
||||
|
||||
+11
-13
@@ -12,23 +12,25 @@ import (
|
||||
// infoOpenLine is the plain line of info's open phase when it opens the
|
||||
// database at once; it prints it as the phase starts and as it ends.
|
||||
const infoOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
"remain unknown"
|
||||
|
||||
// infoCountLine is the plain line of info's count phase when the count
|
||||
// takes under a second; it prints it as the phase starts and as it
|
||||
// ends.
|
||||
const infoCountLine = "[2/2] count: counting the records, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
"remain unknown"
|
||||
|
||||
// infoRecs are four records: a duplicate pair with every hash, a file
|
||||
// of 20 MiB with head and tail hashes but no content hash yet, and a
|
||||
// file never hashed.
|
||||
// infoRecs are four records: a duplicate pair, a file of 20 MiB and
|
||||
// another small file.
|
||||
func infoRecs() []scanRec {
|
||||
h, tl, c, u := testHash("h"), testHash("t"), testHash("c"), testHash("u")
|
||||
bigH, bigT, bigC := testHash("bh"), testHash("bt"), testHash("bc")
|
||||
|
||||
return []scanRec{
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: "/d/a"},
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: "/d/b"},
|
||||
{size: 20 << 20, head: "bh", tail: "bt", path: "/d/big"},
|
||||
{size: 7, path: "/d/unique"},
|
||||
{size: 5, head: h, tail: tl, content: c, path: "/d/a"},
|
||||
{size: 5, head: h, tail: tl, content: c, path: "/d/b"},
|
||||
{size: 20 << 20, head: bigH, tail: bigT, content: bigC, path: "/d/big"},
|
||||
{size: 7, head: u, tail: u, content: u, path: "/d/unique"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,16 +47,12 @@ func TestRunInfo(t *testing.T) {
|
||||
"empty": {
|
||||
want: "schema version\t1\n" +
|
||||
"records\t0\n" +
|
||||
"records with head and tail hashes\t0\n" +
|
||||
"records with content hash\t0\n" +
|
||||
"size of recorded files\t0 (0 B)\n",
|
||||
},
|
||||
"filled": {
|
||||
recs: infoRecs(),
|
||||
want: "schema version\t1\n" +
|
||||
"records\t4\n" +
|
||||
"records with head and tail hashes\t3\n" +
|
||||
"records with content hash\t2\n" +
|
||||
"size of recorded files\t20971537 (20.0 MiB)\n",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
// Command sfdupes quickly identifies candidate duplicate files across
|
||||
// very large filesystems without reading every byte of every file.
|
||||
// Files are considered duplicates when their sizes are equal and they
|
||||
// agree on a short ladder of SHA-256 hashes. A file under 10 MiB is
|
||||
// hashed in full. A larger file is compared on the hashes of its first
|
||||
// and last 64 KiB, and only when those match another file's is its
|
||||
// content hash computed and compared: of the whole file when it is
|
||||
// under 50 MiB, or of gigabyte-spaced 1 MiB samples when it is 50 MiB
|
||||
// or larger. scan maintains a persistent SQLite database of file
|
||||
// signatures (SFDUPES_DATABASE, default /var/lib/sfdupes/db.sqlite)
|
||||
// that the other subcommands read.
|
||||
// agree on three SHA-256 hashes: of the first 64 KiB, of the last 64
|
||||
// KiB, and of the content. The content hash covers the whole file under
|
||||
// 100 MiB, and 1 MiB at each gigabyte at 100 MiB or more, so a large
|
||||
// file is never read in full. scan maintains a persistent SQLite
|
||||
// database of file signatures (SFDUPES_DATABASE, default
|
||||
// /var/lib/sfdupes/db.sqlite) that the other subcommands read.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// sfdupes scan [--workers N] [-x] PATH...
|
||||
// sfdupes scan [--workers N] [-x] [--no-default-excludes] PATH...
|
||||
// sfdupes report > dupes.tsv
|
||||
// sfdupes trees > dupetrees.tsv
|
||||
// sfdupes info
|
||||
@@ -28,6 +26,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -58,10 +57,32 @@ var errNoSubcommand = errors.New("no subcommand")
|
||||
var errWorkersBelowOne = errors.New("--workers must be at least 1")
|
||||
|
||||
// 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
|
||||
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() {
|
||||
// Once the reader of a stdout pipe has gone, as in "sfdupes report |
|
||||
@@ -116,7 +137,10 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
||||
var showVersion bool
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("write stdout: %w", err)
|
||||
}
|
||||
@@ -182,17 +206,23 @@ func newRootCommand(stdout, stderr io.Writer) *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
// newScanCommand builds the scan subcommand, with its --workers and -x
|
||||
// flags.
|
||||
// newScanCommand builds the scan subcommand, with its --workers, -x and
|
||||
// --no-default-excludes flags. Its help lists the default excludes of
|
||||
// the system it runs on, and the filesystem types it never records
|
||||
// anything on.
|
||||
func newScanCommand() *cobra.Command {
|
||||
var (
|
||||
scanWorkers int
|
||||
scanOneFS bool
|
||||
scanWorkers int
|
||||
scanOneFS bool
|
||||
scanNoDefaultExcludes bool
|
||||
)
|
||||
|
||||
short := "Walk trees and synchronize the scan database"
|
||||
|
||||
scanCmd := &cobra.Command{
|
||||
Use: cmdScan + " [--workers N] [-x] PATH...",
|
||||
Short: "Walk trees and synchronize the scan database",
|
||||
Use: cmdScan + " [--workers N] [-x] [--no-default-excludes] PATH...",
|
||||
Short: short,
|
||||
Long: short + ".\n\n" + defaultExcludesHelp(runtime.GOOS),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return checkScanWorkers(cmd, scanWorkers)
|
||||
@@ -201,13 +231,16 @@ func newScanCommand() *cobra.Command {
|
||||
ctx, stop := interruptContext(ctx)
|
||||
defer stop()
|
||||
|
||||
return runScan(ctx, args, scanWorkers, scanOneFS)
|
||||
return runScan(ctx, args, scanWorkers, scanOneFS,
|
||||
scanNoDefaultExcludes)
|
||||
}),
|
||||
}
|
||||
scanCmd.Flags().IntVar(&scanWorkers, "workers", runtime.NumCPU(),
|
||||
"concurrent workers for the walk, hash, and content phases")
|
||||
"concurrent workers for the walk and hash phases")
|
||||
scanCmd.Flags().BoolVarP(&scanOneFS, "one-file-system", "x", false,
|
||||
"do not cross filesystem boundaries")
|
||||
scanCmd.Flags().BoolVar(&scanNoDefaultExcludes, "no-default-excludes",
|
||||
false, "enter the default excludes, the paths listed above")
|
||||
|
||||
return scanCmd
|
||||
}
|
||||
|
||||
+199
-19
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,6 +20,36 @@ import (
|
||||
// error prints and a runtime failure must not.
|
||||
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
|
||||
// while it is open. A clean close checkpoints the WAL and removes
|
||||
// both; finding either afterwards means the database was never closed.
|
||||
@@ -361,7 +393,7 @@ func TestRunNamesRelativeDatabase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunUsesDatabaseThroughSymlinkedDirectory(t *testing.T) {
|
||||
// README §Database: the path names the file exactly. With link a
|
||||
// README §Settings: the path names the file exactly. With link a
|
||||
// symlink to the directory real/sub, link/../db.sqlite names
|
||||
// real/db.sqlite, not db.sqlite beside link. Every subcommand must
|
||||
// use that file, relative or absolute, and its first line must name
|
||||
@@ -535,7 +567,7 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
|
||||
}{
|
||||
cmdScan: {
|
||||
run: func() error {
|
||||
return runScan(t.Context(), []string{missing}, 1, false)
|
||||
return runScan(t.Context(), []string{missing}, 1, false, false)
|
||||
},
|
||||
want: scanWant,
|
||||
},
|
||||
@@ -616,7 +648,7 @@ func TestRunUsageErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
// README §scan mode: --workers below 1 is a usage error reported in
|
||||
// README §Settings: --workers below 1 is a usage error reported in
|
||||
// one line on stderr, before the scan opens the database.
|
||||
for _, workers := range []string{"0", "-1"} {
|
||||
t.Run(workers, func(t *testing.T) {
|
||||
@@ -654,8 +686,8 @@ func TestRunScanRejectsWorkersBelowOne(t *testing.T) {
|
||||
func TestRunHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Subcommands: help goes to stderr, exits 0, and leaves
|
||||
// stdout empty.
|
||||
// README §Settings: help goes to stderr, exits 0, and leaves stdout
|
||||
// empty.
|
||||
cases := [][]string{{"--help"}, {"-h"}, {cmdScan, "--help"}}
|
||||
|
||||
for _, args := range cases {
|
||||
@@ -681,8 +713,10 @@ func TestRunHelp(t *testing.T) {
|
||||
func TestRunVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// README §Subcommands: the version is one line on stdout, with
|
||||
// nothing on stderr, and exits 0.
|
||||
// README §Settings: the version is one line on stdout, with nothing
|
||||
// on stderr, and exits 0.
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
|
||||
for _, arg := range []string{"--version", "-v"} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
@@ -691,7 +725,7 @@ func TestRunVersion(t *testing.T) {
|
||||
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 {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -860,24 +928,21 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunScanSkipsZFSOperand(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
zfs := filepath.Join(t.TempDir(), ".zfs")
|
||||
snapshot := filepath.Join(zfs, "snapshot", "hourly")
|
||||
f := writeFile(t, snapshot, "f", pattern(1, 10))
|
||||
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
|
||||
|
||||
// An operand beneath a .zfs directory is walked, because it is not
|
||||
// itself named .zfs.
|
||||
scanOK(t, snapshot)
|
||||
// No scan records a file inside a .zfs directory, so the record
|
||||
// beneath the operand is written straight into the database.
|
||||
path := seedDatabase(t, []scanRec{{size: 10, path: f}})
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
assertOperandSkipped(t, path, zfs, ".zfs directory", f)
|
||||
}
|
||||
|
||||
// assertOperandSkipped scans operand alone and checks that it is skipped
|
||||
// as kind: a warning naming it, one skip in the summary, exit 0, and the
|
||||
// record for kept, which an earlier scan stored beneath operand, still
|
||||
// in the database at dbPath.
|
||||
// record for kept, stored beneath operand before this scan, still in the
|
||||
// database at dbPath.
|
||||
func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||
kept string,
|
||||
) {
|
||||
@@ -906,6 +971,121 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
|
||||
recordByPath(t, dbRecords(t, db), kept)
|
||||
}
|
||||
|
||||
// TestRunScanSkipsOperandInsideZFS scans a directory inside a .zfs
|
||||
// directory, first alone and then under another operand. Each time it
|
||||
// is warned about and counted as skipped, and nothing in it is walked.
|
||||
// The other operand is scanned as usual.
|
||||
func TestRunScanSkipsOperandInsideZFS(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
dir := t.TempDir()
|
||||
hourly := filepath.Join(dir, ".zfs", "snapshot", "hourly")
|
||||
writeFile(t, hourly, "f", pattern(1, 10))
|
||||
f := writeFile(t, dir, "f", pattern(2, 10))
|
||||
|
||||
warning := "walk " + hourly + ": skipping operand inside a .zfs directory\n"
|
||||
|
||||
for _, tc := range []struct {
|
||||
operands []string
|
||||
summary string
|
||||
}{
|
||||
{
|
||||
[]string{hourly},
|
||||
"scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n",
|
||||
},
|
||||
{
|
||||
[]string{dir, hourly},
|
||||
"scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n",
|
||||
},
|
||||
} {
|
||||
stderr := scanOK(t, tc.operands...)
|
||||
if !strings.Contains(stderr, warning) ||
|
||||
!strings.Contains(stderr, tc.summary) {
|
||||
t.Errorf("scan %q: stderr = %q, want %q and %q", tc.operands,
|
||||
stderr, warning, tc.summary)
|
||||
}
|
||||
}
|
||||
|
||||
db, err := openDB(path, reportParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if got := recordPaths(dbRecords(t, db)); !slices.Equal(got, []string{f}) {
|
||||
t.Errorf("records = %q, want %q", got, []string{f})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScanSkipsOperandThroughSymlinkIntoZFS scans snaps/daily, where
|
||||
// snaps is a symlink to pool/.zfs/snapshot. The operand is warned about
|
||||
// by the path given, counted as skipped, and not walked.
|
||||
func TestRunScanSkipsOperandThroughSymlinkIntoZFS(t *testing.T) {
|
||||
t.Setenv(databaseEnv, testDBPath(t))
|
||||
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
|
||||
|
||||
snaps := filepath.Join(dir, "snaps")
|
||||
|
||||
err := os.Symlink(filepath.Join(dir, "pool", ".zfs", "snapshot"), snaps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
daily := filepath.Join(snaps, "daily")
|
||||
stderr := scanOK(t, daily)
|
||||
|
||||
warning := "walk " + daily + ": skipping operand inside a .zfs directory\n"
|
||||
summary := "scan: 0 files seen (0 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 1 skipped\n"
|
||||
|
||||
if !strings.Contains(stderr, warning) ||
|
||||
!strings.Contains(stderr, summary) {
|
||||
t.Errorf("stderr = %q, want %q and %q", stderr, warning, summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScanWalksOperandThroughSymlink scans data/daily, where data is
|
||||
// a symlink to a directory outside any .zfs directory. The operand is
|
||||
// scanned as usual, and its file is recorded by the path given.
|
||||
func TestRunScanWalksOperandThroughSymlink(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
|
||||
dir := t.TempDir()
|
||||
writeFile(t, dir, "pool/data/daily/f", pattern(1, 10))
|
||||
|
||||
data := filepath.Join(dir, "data")
|
||||
|
||||
err := os.Symlink(filepath.Join(dir, "pool", "data"), data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
daily := filepath.Join(data, "daily")
|
||||
stderr := scanOK(t, daily)
|
||||
|
||||
summary := "scan: 1 files seen (1 added, 0 updated, 0 removed, " +
|
||||
"0 unchanged), 0 skipped\n"
|
||||
if !strings.Contains(stderr, summary) {
|
||||
t.Errorf("stderr = %q, want %q", stderr, summary)
|
||||
}
|
||||
|
||||
db, err := openDB(path, reportParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
recordByPath(t, dbRecords(t, db), filepath.Join(daily, "f"))
|
||||
}
|
||||
|
||||
func TestRunReportSucceeds(t *testing.T) {
|
||||
path := testDBPath(t)
|
||||
t.Setenv(databaseEnv, path)
|
||||
@@ -1054,7 +1234,7 @@ func assertRunsUseDatabase(t *testing.T, dir, name string) {
|
||||
}
|
||||
|
||||
func TestRunDatabasePathUsedAsGiven(t *testing.T) {
|
||||
// README §Database: the path names the database file exactly. In
|
||||
// README §Settings: the path names the database file exactly. In
|
||||
// SQLite's connection string an unescaped ? or # would end the file
|
||||
// name and % would start an escape, and a path starting with //
|
||||
// could be read as a host name. %25 is a valid escape, so unescaped
|
||||
|
||||
+183
-109
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"golang.org/x/term"
|
||||
@@ -22,13 +23,29 @@ const plainInterval = 5 * time.Second
|
||||
// a TTY.
|
||||
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.
|
||||
const spinnerType = 14
|
||||
|
||||
// barWidth is the width of the progressbar library's bar between its
|
||||
// two ends, as in the example line in README "Progress".
|
||||
// barWidth is the width of a phase's bar between its two ends, as in
|
||||
// the example line in README "Progress".
|
||||
const barWidth = 10
|
||||
|
||||
// barCell is one column of the filled part of a bar.
|
||||
const barCell = "█"
|
||||
|
||||
// barColumns is the width of what percentBar returns: the percent in
|
||||
// four columns and its sign, a space, and the bar with its two ends.
|
||||
const barColumns = barWidth + 8
|
||||
|
||||
// percentScale converts a fraction to a percentage.
|
||||
const percentScale = 100
|
||||
|
||||
@@ -36,17 +53,12 @@ const percentScale = 100
|
||||
// read, as the progressbar library takes it.
|
||||
const fallbackWidth = 80
|
||||
|
||||
// spinnerMargin is the columns of a spinner's line besides its text:
|
||||
// the spinner and the three spaces the library draws around the text,
|
||||
// and the terminal's last column, which every line stays clear of so
|
||||
// that no terminal wraps it.
|
||||
// spinnerMargin is the columns of a line besides its text: the spinner
|
||||
// and the three spaces the library draws around the text, and the
|
||||
// terminal's last column, which every line stays clear of so that no
|
||||
// terminal wraps it.
|
||||
const spinnerMargin = 5
|
||||
|
||||
// barMargin is the columns of a bar's line besides its text: the
|
||||
// percent and the bar the library draws after the text, the spaces
|
||||
// around them, and the terminal's last column.
|
||||
const barMargin = barWidth + 11
|
||||
|
||||
// countingRecords is what the display of a phase that reads the records
|
||||
// says while it counts them, before it has its total.
|
||||
const countingRecords = "counting the records"
|
||||
@@ -76,7 +88,7 @@ func terminalWidth() int {
|
||||
return width
|
||||
}
|
||||
|
||||
// phaseLabel names phase with its place among phases, as "[2/5] walk".
|
||||
// phaseLabel names phase with its place among phases, as "[2/4] walk".
|
||||
func phaseLabel(phases []string, phase string) string {
|
||||
return fmt.Sprintf("[%d/%d] %s",
|
||||
slices.Index(phases, phase)+1, len(phases), phase)
|
||||
@@ -89,16 +101,24 @@ type progressParams struct {
|
||||
total int64 // the items the phase will complete; -1 when not known
|
||||
estimate bool // total is an estimate, which the count may pass
|
||||
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
|
||||
// progressbar library draws line, fitted to the terminal's width: after
|
||||
// the library's spinner, or, for a phase with an exact total, before the
|
||||
// library's bar when the terminal has room for the bar beside it. It is
|
||||
// drawn again every redrawInterval, so its elapsed time keeps moving
|
||||
// while the phase waits for its next item. When stderr is not a TTY it
|
||||
// emits no ANSI redraws: it prints line as the phase starts, every
|
||||
// plainInterval while it runs, and as it ends.
|
||||
// progressbar library draws its spinner and then line, fitted to the
|
||||
// terminal's width. A phase with an exact total ends the line with its
|
||||
// percent and bar, at the terminal's right edge, when the terminal has
|
||||
// room for them. It is drawn again every redrawInterval, so its elapsed
|
||||
// time keeps moving while the phase waits for its next item. When
|
||||
// stderr is not a TTY it emits no ANSI redraws: it prints line as the
|
||||
// phase starts, every plainInterval while it runs, and as it ends.
|
||||
//
|
||||
// The draws come from a goroutine of their own, so mu guards the fields
|
||||
// below it. Every method must be called from the main goroutine. A nil
|
||||
@@ -107,46 +127,55 @@ type progressParams struct {
|
||||
// phase's helpers without rendering anything, and its writer writes
|
||||
// with no display to clear.
|
||||
type progress struct {
|
||||
width func() int // reads the terminal's width; nil when not a TTY
|
||||
stop chan struct{} // closed by finish to end the draws
|
||||
done chan struct{} // closed once the draws have ended
|
||||
now func() time.Time // reads the time
|
||||
width func() int // reads the terminal's width; nil when not a TTY
|
||||
stop chan struct{} // closed by finish to end the draws
|
||||
done chan struct{} // closed once the draws have ended
|
||||
|
||||
mu sync.Mutex
|
||||
bar *progressbar.ProgressBar // what the library draws; nil until then
|
||||
withBar bool // bar is the library's bar, not its spinner
|
||||
widest int // the widest line bar has drawn, in columns
|
||||
label string
|
||||
unit string
|
||||
total int64
|
||||
estimate bool
|
||||
waitingFor string
|
||||
stalledOn string
|
||||
count int64
|
||||
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 time remaining
|
||||
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.
|
||||
func newProgress(params progressParams) *progress {
|
||||
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
|
||||
// every interval until finish: with the progressbar library on a
|
||||
// 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,
|
||||
interval time.Duration,
|
||||
interval time.Duration, now func() time.Time,
|
||||
) *progress {
|
||||
now := time.Now()
|
||||
start := now()
|
||||
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,
|
||||
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,
|
||||
@@ -173,60 +202,49 @@ func startProgress(params progressParams, width func() int,
|
||||
}
|
||||
|
||||
// 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() {
|
||||
p.mu.Lock()
|
||||
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 {
|
||||
text, _ := p.line(math.MaxInt, true)
|
||||
fmt.Fprintln(os.Stderr, text)
|
||||
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, false))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
width := p.width()
|
||||
text := p.line(now, width-spinnerMargin, true)
|
||||
|
||||
// A phase with an exact total gets the library's bar when its line,
|
||||
// without the percent the bar shows, fits whole beside the bar. A
|
||||
// total of 0 leaves the bar nothing to fill.
|
||||
text, withBar := "", false
|
||||
if p.total > 0 && !p.estimate {
|
||||
text, withBar = p.line(width-barMargin, false)
|
||||
}
|
||||
|
||||
margin := barMargin
|
||||
if !withBar {
|
||||
margin = spinnerMargin
|
||||
text, _ = p.line(width-margin, true)
|
||||
}
|
||||
|
||||
// The library makes a bar or a spinner, and before each draw erases
|
||||
// its line with as many spaces as the widest line it has drawn. A
|
||||
// display gets a new one when it turns from one to the other, as its
|
||||
// line grows or the terminal is resized, and when the terminal has
|
||||
// been made too narrow for that erase, which would wrap and leave a
|
||||
// line behind at every draw.
|
||||
if p.bar == nil || p.withBar != withBar || p.widest >= width {
|
||||
p.newBar(withBar, width)
|
||||
}
|
||||
|
||||
if withBar {
|
||||
_ = p.bar.Set64(p.count)
|
||||
// Before each draw the library erases its line with as many spaces as
|
||||
// the widest line it has drawn. A display gets a new one when the
|
||||
// terminal has been made too narrow for that erase, which would wrap
|
||||
// and leave a line behind at every draw.
|
||||
if p.bar == nil || p.widest >= width {
|
||||
p.newBar(width)
|
||||
}
|
||||
|
||||
p.bar.Describe(text)
|
||||
|
||||
// The library drew the text and its margin, less the terminal's last
|
||||
// column, which the margin keeps clear.
|
||||
p.widest = max(p.widest, len(text)+margin-1)
|
||||
// The library drew the spinner, the text and the spaces around it,
|
||||
// less the terminal's last column, which spinnerMargin keeps clear. A
|
||||
// bar's cells take one column each but more than one byte.
|
||||
p.widest = max(p.widest, utf8.RuneCountInString(text)+spinnerMargin-1)
|
||||
}
|
||||
|
||||
// newBar has the library make what draw draws: a bar for the phase's
|
||||
// total when withBar is set, and a spinner when not. Either shows the
|
||||
// text draw gives it, and nothing of its own besides the spinner, or
|
||||
// the bar and its percent. p.mu must be held.
|
||||
func (p *progress) newBar(withBar bool, width int) {
|
||||
// newBar has the library make what draw draws: its spinner, followed by
|
||||
// the text draw gives it and nothing of the library's own. p.mu must be
|
||||
// held.
|
||||
func (p *progress) newBar(width int) {
|
||||
if p.bar != nil {
|
||||
// Erase what was drawn before, short of the last column of a
|
||||
// terminal that may have been made narrower since: the library's
|
||||
@@ -235,38 +253,49 @@ func (p *progress) newBar(withBar bool, width int) {
|
||||
fmt.Fprint(os.Stderr, "\r"+blank+"\r")
|
||||
}
|
||||
|
||||
total := int64(-1) // the library's spinner
|
||||
if withBar {
|
||||
total = p.total
|
||||
}
|
||||
|
||||
p.bar = progressbar.NewOptions64(total,
|
||||
// A total of -1 gives the library's spinner.
|
||||
p.bar = progressbar.NewOptions64(-1,
|
||||
progressbar.OptionSetWriter(os.Stderr),
|
||||
progressbar.OptionSetWidth(barWidth),
|
||||
progressbar.OptionSpinnerType(spinnerType),
|
||||
// The spinner turns at each draw, not on a timer of the
|
||||
// library's own.
|
||||
progressbar.OptionSetSpinnerChangeInterval(0),
|
||||
// The text carries the elapsed time and the ETA.
|
||||
// The text carries the elapsed time and the time remaining.
|
||||
progressbar.OptionSetElapsedTime(false),
|
||||
progressbar.OptionSetPredictTime(false),
|
||||
)
|
||||
p.withBar, p.widest = withBar, 0
|
||||
p.widest = 0
|
||||
}
|
||||
|
||||
// percentBar returns the percent and the bar that end the text of a
|
||||
// phase with an exact total, as " 25% |██ |": the bar's
|
||||
// barWidth columns fill in proportion to the items done. A count past
|
||||
// the total, as when a scan adds records while trees reads them, shows
|
||||
// as 100%. p.mu must be held.
|
||||
func (p *progress) percentBar() string {
|
||||
done := min(float64(p.count)/float64(p.total), 1)
|
||||
filled := strings.Repeat(barCell, int(done*barWidth))
|
||||
|
||||
return fmt.Sprintf("%4.0f%% |%-*s|", done*percentScale, barWidth, filled)
|
||||
}
|
||||
|
||||
// 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
|
||||
// spent waiting for the total says nothing about how fast items go.
|
||||
// it is known. The rate, the time remaining and the time with no item
|
||||
// completed count from then on: the time spent waiting for the total
|
||||
// says nothing about how fast items go.
|
||||
func (p *progress) setTotal(total int64) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := p.now()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
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
|
||||
@@ -276,10 +305,13 @@ func (p *progress) increment() {
|
||||
return
|
||||
}
|
||||
|
||||
now := p.now()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.count++
|
||||
p.lastItem = now
|
||||
}
|
||||
|
||||
// warnf prints a one-line warning to stderr without corrupting the
|
||||
@@ -391,50 +423,92 @@ 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/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s
|
||||
//
|
||||
// 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
|
||||
// with no total shows the items done instead, or what it waits for.
|
||||
// where "files" is the phase's unit. A phase 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 time remaining 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
|
||||
// are kept whole. A line wider than room leaves out the rate, or what
|
||||
// the phase waits for; only if it is still wider is it cut at room, and
|
||||
// line reports that it does not fit. Every part is ASCII, so its length
|
||||
// is its width in columns. p.mu must be held.
|
||||
func (p *progress) line(room int, percent bool) (string, bool) {
|
||||
elapsed := time.Since(p.start).Round(time.Second)
|
||||
// When bar is true, as on a terminal, a phase with an exact total ends
|
||||
// the line with its percent and bar, in place of the percent after its
|
||||
// count. A total of 0 leaves the bar nothing to fill.
|
||||
//
|
||||
// The phase and its place, the progress, the elapsed time and the time
|
||||
// remaining are kept whole. A line wider than room first leaves out the
|
||||
// rate, the words of a stalled phase, or what the phase waits for. Next
|
||||
// it leaves out the bar. Only if it is still wider is it cut at room.
|
||||
// Every part but the bar is ASCII, so its length is its width in
|
||||
// columns. p.mu must be held.
|
||||
func (p *progress) line(now time.Time, room int, bar bool) string {
|
||||
elapsed := now.Sub(p.start).Round(time.Second)
|
||||
|
||||
// head and tail hold the parts kept whole; extra, between them, is
|
||||
// the part left out.
|
||||
var head, extra, tail string
|
||||
|
||||
if p.total < 0 && p.waitingFor != "" {
|
||||
head, extra = p.label+": ", p.waitingFor
|
||||
tail = fmt.Sprintf("elapsed %s, eta unknown", elapsed)
|
||||
head, extra = p.label+": ", p.waitingFor+", "
|
||||
tail = fmt.Sprintf("elapsed %s, remain unknown", elapsed)
|
||||
} else {
|
||||
rate := 0.0
|
||||
if s := time.Since(p.rateStart).Seconds(); s > 0 {
|
||||
rate = float64(p.count) / s
|
||||
}
|
||||
since := p.drawn[0]
|
||||
rate := perSecond(p.count-since.count, now.Sub(since.at))
|
||||
average := perSecond(p.count, now.Sub(p.rateStart))
|
||||
|
||||
head = p.label + ": " + p.itemsDone(percent) + ", "
|
||||
extra = fmt.Sprintf("%.0f %s/s", rate, p.unit)
|
||||
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(rate))
|
||||
head = p.label + ": " + p.itemsDone(true) + ", "
|
||||
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
|
||||
tail = fmt.Sprintf("elapsed %s, remain %s", elapsed, p.remain(average))
|
||||
|
||||
if now.Sub(p.lastItem) >= stallTime {
|
||||
extra = "stalled " + p.stalledOn + ", "
|
||||
}
|
||||
}
|
||||
|
||||
full, kept := head+extra+", "+tail, head+tail
|
||||
if bar && p.total > 0 && !p.estimate {
|
||||
// Beside the bar, which shows the percent, the count shows none.
|
||||
// The line is padded to the room the bar leaves it, so the bar
|
||||
// stays at room's right edge however long the line is.
|
||||
beside := room - barColumns
|
||||
barHead := p.label + ": " + p.itemsDone(false) + ", "
|
||||
|
||||
switch {
|
||||
case len(barHead+extra+tail) <= beside:
|
||||
return fmt.Sprintf("%-*s%s", beside, barHead+extra+tail,
|
||||
p.percentBar())
|
||||
case len(barHead+tail) <= beside:
|
||||
return fmt.Sprintf("%-*s%s", beside, barHead+tail, p.percentBar())
|
||||
}
|
||||
|
||||
// The bar gives way. The rate, or the words of a stalled phase,
|
||||
// gave way before it and stay out.
|
||||
extra = ""
|
||||
}
|
||||
|
||||
full, kept := head+extra+tail, head+tail
|
||||
|
||||
switch {
|
||||
case len(full) <= room:
|
||||
return full, true
|
||||
return full
|
||||
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 "~"
|
||||
@@ -464,10 +538,10 @@ func (p *progress) itemsDone(percent bool) string {
|
||||
return fmt.Sprintf("%s %.0f%%", done, pct)
|
||||
}
|
||||
|
||||
// eta returns the time the phase has left at rate items a second, or
|
||||
// remain returns the time the phase has left at rate items a second, or
|
||||
// "unknown": with no total, before the first item, and once the count
|
||||
// passes an estimate. p.mu must be held.
|
||||
func (p *progress) eta(rate float64) string {
|
||||
func (p *progress) remain(rate float64) string {
|
||||
switch {
|
||||
case p.total < 0, p.estimate && p.count > p.total:
|
||||
return "unknown"
|
||||
@@ -477,8 +551,8 @@ func (p *progress) eta(rate float64) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
remaining := time.Duration(
|
||||
left := time.Duration(
|
||||
float64(p.total-p.count) / rate * float64(time.Second))
|
||||
|
||||
return remaining.Round(time.Second).String()
|
||||
return left.Round(time.Second).String()
|
||||
}
|
||||
|
||||
+527
-108
@@ -6,7 +6,9 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -20,6 +22,33 @@ const testRedraw = time.Millisecond
|
||||
// it expects.
|
||||
const testDeadline = 5 * time.Second
|
||||
|
||||
// hashStalledOn is where the hash phase says it waits once it has
|
||||
// stalled, as hashPhase 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
|
||||
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
|
||||
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
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
ago := time.Now().Add(-10 * time.Second)
|
||||
now := time.Now()
|
||||
ago := now.Add(-10 * time.Second)
|
||||
|
||||
cases := []struct {
|
||||
p *progress
|
||||
@@ -86,38 +117,38 @@ func TestProgressLine(t *testing.T) {
|
||||
label: scanPhase("load"), total: -1,
|
||||
waitingFor: countingRecords,
|
||||
},
|
||||
want: "[1/5] load: counting the records, elapsed 10s, eta unknown",
|
||||
want: "[1/4] load: counting the records, elapsed 10s, remain unknown",
|
||||
}, {
|
||||
p: &progress{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 100, count: 20,
|
||||
},
|
||||
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
||||
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
|
||||
}, {
|
||||
p: &progress{label: scanPhase("hash"), unit: scanUnit, total: 100},
|
||||
want: "[3/5] hash: [0/100] 0%, 0 files/s, elapsed 10s, eta unknown",
|
||||
want: "[3/4] hash: [0/100] 0%, 0 files/s, elapsed 10s, remain unknown",
|
||||
}, {
|
||||
p: &progress{label: scanPhase("update"), unit: scanUnit, total: 0},
|
||||
want: "[4/5] update: [0/0] 100%, 0 files/s, elapsed 10s, eta 0s",
|
||||
want: "[4/4] update: [0/0] 100%, 0 files/s, elapsed 10s, remain 0s",
|
||||
}, {
|
||||
p: &progress{
|
||||
label: scanPhase("walk"), unit: scanUnit, total: 100,
|
||||
estimate: true, count: 20,
|
||||
},
|
||||
want: "[2/5] walk: [20/~100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
||||
want: "[2/4] walk: [20/~100] 20%, 2 files/s, elapsed 10s, remain 40s",
|
||||
}, {
|
||||
// Past the estimate the walk cannot tell how long it has left.
|
||||
p: &progress{
|
||||
label: scanPhase("walk"), unit: scanUnit, total: 100,
|
||||
estimate: true, count: 120,
|
||||
},
|
||||
want: "[2/5] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
|
||||
"eta unknown",
|
||||
want: "[2/4] walk: [120/~100] 120%, 12 files/s, elapsed 10s, " +
|
||||
"remain unknown",
|
||||
}, {
|
||||
// A first scan's walk has no estimate.
|
||||
p: &progress{
|
||||
label: scanPhase("walk"), unit: scanUnit, total: -1, count: 20,
|
||||
},
|
||||
want: "[2/5] walk: 20 files, 2 files/s, elapsed 10s, eta unknown",
|
||||
want: "[2/4] walk: 20 files, 2 files/s, elapsed 10s, remain unknown",
|
||||
}, {
|
||||
// The rate is in what the phase counts.
|
||||
p: &progress{
|
||||
@@ -125,94 +156,125 @@ func TestProgressLine(t *testing.T) {
|
||||
count: 20,
|
||||
},
|
||||
want: "[3/4] group: [20/100] 20%, 2 directories/s, elapsed 10s, " +
|
||||
"eta 40s",
|
||||
"remain 40s",
|
||||
}}
|
||||
|
||||
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, false); 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
|
||||
// seconds into the phase: it leaves out the rate, or what the phase
|
||||
// waits for, and is cut only when what is left is still too wide.
|
||||
// seconds into the phase, and fifty into a phase with no file read for
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
ago := time.Now().Add(-10 * time.Second)
|
||||
now := time.Now()
|
||||
ago := now.Add(-10 * time.Second)
|
||||
hash := &progress{
|
||||
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{
|
||||
label: scanPhase("content"), total: -1,
|
||||
waitingFor: "searching the database", start: ago, rateStart: ago,
|
||||
load := &progress{
|
||||
label: scanPhase("load"), total: -1,
|
||||
waitingFor: countingRecords, 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 {
|
||||
p *progress
|
||||
room int
|
||||
percent bool
|
||||
want string
|
||||
fits bool
|
||||
p *progress
|
||||
room int
|
||||
bar bool
|
||||
want string
|
||||
}{{
|
||||
p: hash, room: 57, percent: true,
|
||||
want: "[3/5] hash: [20/100] 20%, 2 files/s, elapsed 10s, eta 40s",
|
||||
fits: true,
|
||||
p: hash, room: 60,
|
||||
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
|
||||
}, {
|
||||
p: hash, room: 56, percent: true,
|
||||
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40s",
|
||||
fits: true,
|
||||
p: hash, room: 59,
|
||||
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
|
||||
}, {
|
||||
p: hash, room: 45, percent: true,
|
||||
want: "[3/5] hash: [20/100] 20%, elapsed 10s, eta 40",
|
||||
fits: false,
|
||||
p: hash, room: 48,
|
||||
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
|
||||
}, {
|
||||
// Before the library's bar, which shows the percent.
|
||||
p: hash, room: 57, percent: false,
|
||||
want: "[3/5] hash: [20/100], 2 files/s, elapsed 10s, eta 40s",
|
||||
fits: true,
|
||||
// The bar shows the percent.
|
||||
p: hash, room: 74, bar: true,
|
||||
want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" +
|
||||
" 20% |██ |",
|
||||
}, {
|
||||
p: content, room: 40, percent: true,
|
||||
want: "[5/5] content: elapsed 10s, eta unknown",
|
||||
fits: true,
|
||||
p: load, room: 43,
|
||||
want: "[1/4] load: elapsed 10s, remain unknown",
|
||||
}, {
|
||||
p: stalled, room: 68,
|
||||
want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
|
||||
"remain 3m20s",
|
||||
}, {
|
||||
p: stalled, room: 67,
|
||||
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
got, fits := tc.p.line(tc.room, tc.percent)
|
||||
if got != tc.want || fits != tc.fits {
|
||||
t.Errorf("line(%d, %t) = %q, %t, want %q, %t",
|
||||
tc.room, tc.percent, got, fits, tc.want, tc.fits)
|
||||
if got := tc.p.line(now, tc.room, tc.bar); got != tc.want {
|
||||
t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got,
|
||||
tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetTotalStartsRate checks that setTotal starts a phase's rate,
|
||||
// and so its ETA, from when the total becomes known, not from when the
|
||||
// phase started waiting for it.
|
||||
// its time remaining and its time with no record read from when the
|
||||
// total becomes 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) {
|
||||
t.Parallel()
|
||||
|
||||
waited := time.Now().Add(-20 * time.Second)
|
||||
clock := &testClock{now: time.Now()}
|
||||
waited := clock.Now().Add(-20 * time.Second)
|
||||
p := &progress{
|
||||
label: scanPhase("load"), unit: scanUnit, total: -1, start: waited,
|
||||
rateStart: waited,
|
||||
now: clock.Now, label: scanPhase("load"), unit: scanUnit, total: -1,
|
||||
stalledOn: "db", start: waited, rateStart: waited,
|
||||
lastItem: waited, drawn: []drawnCount{{at: waited}},
|
||||
}
|
||||
|
||||
p.setTotal(100)
|
||||
|
||||
// Ten seconds after the total came, without waiting ten seconds,
|
||||
// 20 records are read.
|
||||
p.start = p.start.Add(-10 * time.Second)
|
||||
p.rateStart = p.rateStart.Add(-10 * time.Second)
|
||||
p.count = 20
|
||||
clock.Add(5 * time.Second)
|
||||
|
||||
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
|
||||
if got, _ := p.line(math.MaxInt, true); got != want {
|
||||
want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown"
|
||||
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
|
||||
t.Errorf("line = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
clock.Add(5 * time.Second)
|
||||
|
||||
want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown"
|
||||
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
|
||||
t.Errorf("line = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Then 20 records are read.
|
||||
for range 20 {
|
||||
p.increment()
|
||||
}
|
||||
|
||||
want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s"
|
||||
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
|
||||
t.Errorf("line = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -255,9 +317,9 @@ func TestPlainProgressPrintsAtStartAndEnd(t *testing.T) {
|
||||
|
||||
lines := strings.Split(strings.TrimSuffix(stderr(), "\n"), "\n")
|
||||
|
||||
want := "[3/5] hash: [0/1000] 0%, 0 files/s, elapsed 0s, eta unknown"
|
||||
want := "[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown"
|
||||
if len(lines) != 2 || lines[0] != want ||
|
||||
!strings.HasPrefix(lines[1], "[3/5] hash: [1000/1000] 100%, ") {
|
||||
!strings.HasPrefix(lines[1], "[3/4] hash: [1000/1000] 100%, ") {
|
||||
t.Errorf("stderr lines = %q, want %q, then the line at 1000",
|
||||
lines, want)
|
||||
}
|
||||
@@ -272,13 +334,137 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
|
||||
p := startProgress(progressParams{
|
||||
label: scanPhase("load"), total: -1,
|
||||
waitingFor: countingRecords,
|
||||
}, nil, testRedraw)
|
||||
}, nil, testRedraw, time.Now)
|
||||
|
||||
awaitOutput(t, stderr,
|
||||
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
|
||||
"[1/4] load: counting the records, elapsed 0s, remain unknown\n", 3)
|
||||
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/4] hash: [20/100] 20%, 4 files/s, elapsed 5s, remain 20s\n", 3)
|
||||
|
||||
clock.Add(time.Second)
|
||||
awaitOutput(t, stderr,
|
||||
"[3/4] hash: [20/100] 20%, 0 files/s, elapsed 6s, remain 24s\n", 3)
|
||||
|
||||
clock.Add(4 * time.Second)
|
||||
awaitOutput(t, stderr,
|
||||
"[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 10s, remain 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 time remaining 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/4] hash: [0/1000] 0%, 0 files/s, elapsed 0s, remain unknown",
|
||||
"[3/4] hash: [0/1000] 0%, 0 files/s, elapsed 5s, remain unknown",
|
||||
"[3/4] hash: [0/1000] 0%, stalled disk/db, elapsed 10s, remain unknown",
|
||||
"[3/4] hash: [100/1000] 10%, 20 files/s, elapsed 15s, remain 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 time remaining 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/4] hash: [50/1000] 5%, 10 files/s, elapsed 15s, remain 4m45s\n" +
|
||||
"[3/4] hash: [50/1000] 5%, 0 files/s, elapsed 15s, remain 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:
|
||||
// the last one. The library starts each frame with a carriage return
|
||||
// and erases the previous one with spaces first.
|
||||
@@ -295,10 +481,10 @@ func lastFrame(out string) string {
|
||||
}
|
||||
|
||||
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
|
||||
// phase, spinner or bar, keeps being drawn while the phase waits for its
|
||||
// next item, with the elapsed time as of now, and shows every item
|
||||
// completed before the wait. stderr is not a terminal here, so the
|
||||
// displays take it to be fallbackWidth wide.
|
||||
// phase, with a bar or without, keeps being drawn while the phase waits
|
||||
// for its next item, with the elapsed time as of now, and shows every
|
||||
// item completed before the wait. The terminal is 120 columns wide, so
|
||||
// the hash phase's display has room for its bar.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
||||
@@ -321,7 +507,8 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
before := len(stderr())
|
||||
since := func() string { return stderr()[before:] }
|
||||
p := startProgress(tc.params, terminalWidth, testRedraw)
|
||||
p := startProgress(tc.params, func() int { return 120 }, testRedraw,
|
||||
time.Now)
|
||||
|
||||
for range 50 {
|
||||
p.increment()
|
||||
@@ -343,21 +530,23 @@ func TestTerminalRedrawsWhileWaiting(t *testing.T) {
|
||||
|
||||
// drawLate draws the display params describes on a terminal width
|
||||
// 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
|
||||
// to stderr.
|
||||
// done two and a half hours into the phase, the last just now, and
|
||||
// returns what it wrote to stderr.
|
||||
func drawLate(stderr func() string, params progressParams, width int,
|
||||
count int64,
|
||||
) string {
|
||||
before := len(stderr())
|
||||
clock := &testClock{now: time.Now()}
|
||||
|
||||
// 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.
|
||||
clock.Add(150 * time.Minute)
|
||||
|
||||
p.mu.Lock()
|
||||
p.count = count
|
||||
p.start = p.start.Add(-150 * time.Minute)
|
||||
p.rateStart = p.start
|
||||
p.count, p.lastItem = count, clock.Now()
|
||||
p.mu.Unlock()
|
||||
|
||||
p.finish()
|
||||
@@ -365,13 +554,13 @@ func drawLate(stderr func() string, params progressParams, width int,
|
||||
return stderr()[before:]
|
||||
}
|
||||
|
||||
// TestTerminalLinesKeepETA checks the line each display shows on an
|
||||
// 80-column terminal at a large scan's counts and times, and the line
|
||||
// of trees' read phase at the same counts: the rate is left out, so
|
||||
// that the elapsed time and the ETA stay whole.
|
||||
// TestTerminalLinesKeepTimeRemaining checks the line each display shows
|
||||
// on an 80-column terminal at a large scan's counts and times, and the
|
||||
// line of trees' read phase at the same counts: the rate is left out,
|
||||
// so that the elapsed time and the time remaining stay whole.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalLinesKeepETA(t *testing.T) {
|
||||
func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
cases := []struct {
|
||||
@@ -383,26 +572,20 @@ func TestTerminalLinesKeepETA(t *testing.T) {
|
||||
label: scanPhase("walk"), unit: scanUnit, total: 1200000,
|
||||
estimate: true,
|
||||
},
|
||||
want: "[2/5] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
|
||||
"eta unknown",
|
||||
want: "[2/4] walk: [1234567/~1200000] 103%, elapsed 2h30m0s, " +
|
||||
"remain unknown",
|
||||
}, {
|
||||
params: progressParams{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||
},
|
||||
want: "[3/5] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
|
||||
"eta 17h30m0s",
|
||||
}, {
|
||||
params: progressParams{
|
||||
label: scanPhase("content"), unit: scanUnit, total: 9876543,
|
||||
},
|
||||
want: "[5/5] content: [1234567/9876543] 12%, elapsed 2h30m0s, " +
|
||||
"eta 17h30m0s",
|
||||
want: "[3/4] hash: [1234567/9876543] 12%, elapsed 2h30m0s, " +
|
||||
"remain 17h30m0s",
|
||||
}, {
|
||||
params: progressParams{
|
||||
label: treesPhase("read"), unit: "records", total: 9876543,
|
||||
},
|
||||
want: "[2/4] read: [1234567/9876543] 12%, elapsed 2h30m0s, " +
|
||||
"eta 17h30m0s",
|
||||
"remain 17h30m0s",
|
||||
}}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -413,6 +596,87 @@ func TestTerminalLinesKeepETA(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalStalledLines draws every phase's line 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, on a terminal 100 columns wide, the line shows where the phase
|
||||
// waits whole, beside the phase, progress, elapsed time and time
|
||||
// remaining. At a large scan's, 1234567 of 9876543 two and a half hours
|
||||
// in, on an 80-column terminal, 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 on a terminal width
|
||||
// columns wide 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, width int, 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 width }, 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"},
|
||||
{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 = "~"
|
||||
}
|
||||
|
||||
// The percent is in the line, or beside the bar.
|
||||
shown := drawStalled(params, 100, 45678, 12345,
|
||||
4*time.Minute+30*time.Second)
|
||||
for _, want := range []string{
|
||||
params.label + ": [12345/" + approx + "45678]",
|
||||
", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s",
|
||||
} {
|
||||
if !strings.Contains(shown, want) {
|
||||
t.Errorf("terminal shows %q, want %q", shown, want)
|
||||
}
|
||||
}
|
||||
|
||||
shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute)
|
||||
for _, want := range []string{
|
||||
params.label + ": [1234567/" + approx + "9876543] 12%, ",
|
||||
", elapsed 2h30m0s, remain 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
|
||||
// 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
|
||||
@@ -427,7 +691,7 @@ func TestTerminalLinesFitWidth(t *testing.T) {
|
||||
for _, width := range []int{120, 100, 80, 72, 60, 50, 40} {
|
||||
for _, total := range []int64{9876543, 20000} {
|
||||
params := progressParams{
|
||||
label: scanPhase("content"), unit: scanUnit, total: total,
|
||||
label: scanPhase("update"), unit: scanUnit, total: total,
|
||||
}
|
||||
out := drawLate(stderr, params, width, total/8)
|
||||
|
||||
@@ -449,13 +713,164 @@ func TestTerminalLinesFitWidth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalLinesStartWithSpinner draws a display with no total and
|
||||
// one with a bar, two and a half hours into the phase, on a terminal 120
|
||||
// columns wide. Each line must start with the library's spinner in the
|
||||
// first column, then a space and the phase's label.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalLinesStartWithSpinner(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
|
||||
for _, params := range []progressParams{
|
||||
{label: scanPhase("walk"), unit: scanUnit, total: -1},
|
||||
{label: scanPhase("hash"), unit: scanUnit, total: 9876543},
|
||||
} {
|
||||
shown := lastFrame(drawLate(stderr, params, 120, 1234567))
|
||||
spinner, rest, _ := strings.Cut(shown, " ")
|
||||
|
||||
if utf8.RuneCountInString(spinner) != 1 ||
|
||||
!strings.HasPrefix(rest, params.label+": ") {
|
||||
t.Errorf("terminal shows %q, want the spinner, a space and %q",
|
||||
shown, params.label)
|
||||
}
|
||||
|
||||
if params.total > 0 && !strings.Contains(shown, "% |") {
|
||||
t.Errorf("terminal shows %q, want a bar", shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two
|
||||
// and a half hours in, at two counts whose lines differ in length, on
|
||||
// terminals 110 and 120 columns wide. The bar is partly filled at the
|
||||
// first count and full at the second. It must start the same number of
|
||||
// columns from the terminal's right edge each time, and end the same
|
||||
// number of columns from it: a line that grows or shrinks, as its time
|
||||
// remaining does, never moves the bar, and nor do the bar's filled
|
||||
// cells.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalBarStaysAtRightEdge(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
params := progressParams{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||
}
|
||||
|
||||
widths := []int{110, 120}
|
||||
counts := []int64{1234567, 9876543}
|
||||
startFromEdge := make([]int, 0, len(widths)*len(counts))
|
||||
endFromEdge := make([]int, 0, len(widths)*len(counts))
|
||||
|
||||
for _, width := range widths {
|
||||
for _, count := range counts {
|
||||
shown := lastFrame(drawLate(stderr, params, width, count))
|
||||
|
||||
beforeBar, _, found := strings.Cut(shown, "% |")
|
||||
if !found {
|
||||
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
|
||||
}
|
||||
|
||||
// The bar ends with the last "|".
|
||||
throughBar := shown[:strings.LastIndex(shown, "|")+1]
|
||||
|
||||
startFromEdge = append(startFromEdge,
|
||||
width-utf8.RuneCountInString(beforeBar))
|
||||
endFromEdge = append(endFromEdge,
|
||||
width-utf8.RuneCountInString(throughBar))
|
||||
}
|
||||
}
|
||||
|
||||
if slices.Min(startFromEdge) != slices.Max(startFromEdge) {
|
||||
t.Errorf("bar starts %v columns from the right edge, want the same "+
|
||||
"every time", startFromEdge)
|
||||
}
|
||||
|
||||
if slices.Min(endFromEdge) != slices.Max(endFromEdge) {
|
||||
t.Errorf("bar ends %v columns from the right edge, want the same "+
|
||||
"every time", endFromEdge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalRateGivesWayBeforeBar draws the hash phase's display two
|
||||
// and a half hours in. On a terminal 90 columns wide the line fits
|
||||
// beside the bar without its rate, but not with it: the bar is drawn
|
||||
// and the rate is not. On a terminal 85 columns wide the line does not
|
||||
// fit beside the bar even without its rate: neither is drawn, though
|
||||
// the rate would fit where the bar was.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalRateGivesWayBeforeBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
params := progressParams{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||
}
|
||||
|
||||
shown := lastFrame(drawLate(stderr, params, 90, 1234567))
|
||||
if !strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
|
||||
t.Errorf("terminal 90 wide shows %q, want the bar and no rate", shown)
|
||||
}
|
||||
|
||||
shown = lastFrame(drawLate(stderr, params, 85, 1234567))
|
||||
if strings.Contains(shown, "% |") || strings.Contains(shown, "files/s") {
|
||||
t.Errorf("terminal 85 wide shows %q, want neither bar nor rate", shown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalSpinnerTurnsBesideBar draws the hash phase's display, its
|
||||
// bar partly filled, several times on a terminal 120 columns wide. The
|
||||
// spinner must turn at every draw. A filled cell of the bar takes one
|
||||
// column but three bytes. A line measured in bytes would seem too wide
|
||||
// for the terminal, and every draw would start the display over, its
|
||||
// spinner included.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalSpinnerTurnsBesideBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
params := progressParams{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||
}
|
||||
|
||||
// No timed draws: the test draws.
|
||||
p := startProgress(params, func() int { return 120 }, time.Hour,
|
||||
time.Now)
|
||||
|
||||
p.mu.Lock()
|
||||
p.count = 1234567
|
||||
p.mu.Unlock()
|
||||
|
||||
const draws = 4
|
||||
|
||||
spinners := make([]string, 0, draws)
|
||||
|
||||
for range draws {
|
||||
before := len(stderr())
|
||||
|
||||
p.draw()
|
||||
|
||||
spinner, _, _ := strings.Cut(lastFrame(stderr()[before:]), " ")
|
||||
spinners = append(spinners, spinner)
|
||||
}
|
||||
|
||||
p.finish()
|
||||
|
||||
for i := 1; i < len(spinners); i++ {
|
||||
if spinners[i] == spinners[i-1] {
|
||||
t.Errorf("spinners at successive draws are %q, want each to "+
|
||||
"differ from the one before", spinners)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTerminalNarrowedRedrawsInPlace draws a large scan's display, two
|
||||
// and a half hours into the phase, on a terminal 120 columns wide, then
|
||||
// makes the terminal narrower than that line and draws it again a few
|
||||
// times. Nothing written after that, the erase before each redraw
|
||||
// included, may reach the narrower terminal's last column: it would
|
||||
// wrap, and every redraw would leave a line behind. At these widths the
|
||||
// spinner stays a spinner and the bar stays a bar.
|
||||
// wrap, and every redraw would leave a line behind. At the narrower
|
||||
// width the hash phase's bar gives way.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
||||
@@ -473,20 +888,22 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
||||
progressParams{
|
||||
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
|
||||
},
|
||||
90,
|
||||
80,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
width := 120
|
||||
clock := &testClock{now: time.Now()}
|
||||
|
||||
// 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.count = 1234567
|
||||
p.start = p.start.Add(-150 * time.Minute)
|
||||
p.rateStart = p.start
|
||||
p.count, p.lastItem = 1234567, clock.Now()
|
||||
p.mu.Unlock()
|
||||
|
||||
p.draw()
|
||||
@@ -511,10 +928,10 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressWarningsOnOwnLines drives the terminal displays, spinner
|
||||
// and bar, through a run of warnings with no items between them, as when
|
||||
// the walk meets many unreadable paths, for many redraws: every warning
|
||||
// must land on a line of its own, never inside a redraw.
|
||||
// TestProgressWarningsOnOwnLines drives the terminal displays, with a
|
||||
// bar and without, through a run of warnings with no items between
|
||||
// them, as when the walk meets many unreadable paths, for many redraws:
|
||||
// every warning must land on a line of its own, never inside a redraw.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
@@ -528,7 +945,7 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
issued := 0
|
||||
|
||||
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
|
||||
// garbled only if a redraw lands while it is being written.
|
||||
@@ -560,11 +977,12 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProgressWriterRowsOnOwnLines drives the terminal displays,
|
||||
// spinner and bar, through a run of rows written through the display's
|
||||
// TestProgressWriterRowsOnOwnLines drives the terminal displays, with a
|
||||
// bar and without, through a run of rows written through the display's
|
||||
// writer to the same terminal, as stdout is in a plain "sfdupes trees",
|
||||
// each written out at once, for many redraws: the terminal must show
|
||||
// every row whole, in order, on a line of its own.
|
||||
// every row whole, in order, on a line of its own. The terminal is 120
|
||||
// columns wide, so the write phase's display has room for its bar.
|
||||
//
|
||||
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
|
||||
func TestProgressWriterRowsOnOwnLines(t *testing.T) {
|
||||
@@ -581,7 +999,8 @@ func TestProgressWriterRowsOnOwnLines(t *testing.T) {
|
||||
var want []string
|
||||
|
||||
for _, params := range cases {
|
||||
p := startProgress(params, terminalWidth, testRedraw)
|
||||
p := startProgress(params, func() int { return 120 }, testRedraw,
|
||||
time.Now)
|
||||
out := p.writer(os.Stderr)
|
||||
|
||||
// No pause between rows: one is garbled only if a redraw lands
|
||||
@@ -644,7 +1063,7 @@ func TestProgressWriterStalledStdoutKeepsLines(t *testing.T) {
|
||||
})
|
||||
|
||||
p := startProgress(progressParams{label: treesPhase("write"), total: 1},
|
||||
nil, testRedraw)
|
||||
nil, testRedraw, time.Now)
|
||||
out := p.writer(w)
|
||||
written := make(chan error, 1)
|
||||
|
||||
@@ -687,7 +1106,7 @@ func TestProgressWriterStalledStdoutKeepsLines(t *testing.T) {
|
||||
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
|
||||
terminalWidth, redrawInterval)
|
||||
terminalWidth, redrawInterval, time.Now)
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
@@ -706,7 +1125,7 @@ func TestTerminalStoppedShortKeepsCount(t *testing.T) {
|
||||
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
|
||||
stderr := captureStderr(t)
|
||||
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
|
||||
terminalWidth, testRedraw)
|
||||
terminalWidth, testRedraw, time.Now)
|
||||
|
||||
p.increment()
|
||||
p.increment()
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -24,9 +25,9 @@ const minGroupSize = 2
|
||||
type scanRec struct {
|
||||
size int64
|
||||
mtime time.Time
|
||||
head string
|
||||
tail string
|
||||
content string
|
||||
head [sha256.Size]byte
|
||||
tail [sha256.Size]byte
|
||||
content [sha256.Size]byte
|
||||
path string
|
||||
}
|
||||
|
||||
@@ -74,9 +75,11 @@ func runReport(ctx context.Context, stdout io.Writer) error {
|
||||
|
||||
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{
|
||||
label: reportPhase("read"), unit: "records", total: -1,
|
||||
waitingFor: countingRecords,
|
||||
waitingFor: countingRecords, stalledOn: "db/out",
|
||||
})
|
||||
report, err := readReport(ctx, db, dbPath, stdout, prog)
|
||||
|
||||
@@ -167,12 +170,6 @@ type reportWriter struct {
|
||||
func (w *reportWriter) add(r scanRec) error {
|
||||
w.records++
|
||||
|
||||
// A record without a content hash has unknown content (README
|
||||
// "Database"), so it is never a duplicate.
|
||||
if r.content == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.size == w.sig.size && r.head == w.sig.head &&
|
||||
r.tail == w.sig.tail && r.content == w.sig.content {
|
||||
w.paths = append(w.paths, r.path)
|
||||
|
||||
+68
-49
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -18,14 +19,27 @@ import (
|
||||
// awkwardDir is a directory name holding every byte the reports escape.
|
||||
const awkwardDir = "/d/\tone\ntwo\rthree\\four"
|
||||
|
||||
// testHash returns a stand-in hash for a test record: the bytes of s,
|
||||
// then zeros. Stand-ins sort as their strings do, so a test can choose
|
||||
// the order in which report reads its records.
|
||||
func testHash(s string) [sha256.Size]byte {
|
||||
var h [sha256.Size]byte
|
||||
|
||||
copy(h[:], s)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// awkwardPairRecs is a duplicate pair in sibling directories /d/A and
|
||||
// awkwardDir. A raw tab sorts before "A" but its escaped form `\t`
|
||||
// sorts after it, so awkwardDir coming first shows that sorting uses
|
||||
// the raw path.
|
||||
func awkwardPairRecs() []scanRec {
|
||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||
|
||||
return []scanRec{
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: "/d/A/f"},
|
||||
{size: 5, head: "h", tail: "t", content: "c", path: awkwardDir + "/f"},
|
||||
{size: 5, head: h, tail: tl, content: c, path: "/d/A/f"},
|
||||
{size: 5, head: h, tail: tl, content: c, path: awkwardDir + "/f"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +157,8 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
|
||||
recs := make([]scanRec, 2*ioBufSize/len(dir))
|
||||
for i := range recs {
|
||||
recs[i] = scanRec{
|
||||
size: int64(i/2) + 1, head: "h", tail: "t", content: "c",
|
||||
path: fmt.Sprintf("%s/%d", dir, i),
|
||||
size: int64(i/2) + 1, head: testHash("h"), tail: testHash("t"),
|
||||
content: testHash("c"), path: fmt.Sprintf("%s/%d", dir, i),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +170,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
|
||||
}
|
||||
|
||||
// Counts the records read, and draws nothing.
|
||||
prog := &progress{}
|
||||
prog := &progress{now: time.Now}
|
||||
|
||||
_, err = readReport(t.Context(), db, "", failingWriter{}, prog)
|
||||
if !errors.Is(err, errWriteFailed) ||
|
||||
@@ -173,7 +187,7 @@ func TestReportStdoutFailsWhileReading(t *testing.T) {
|
||||
// reportOpenLine is the plain line of report's open phase when it opens
|
||||
// the database at once; it prints it as the phase starts and as it ends.
|
||||
const reportOpenLine = "[1/2] open: opening the database, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
"remain unknown"
|
||||
|
||||
// smokeReport is report's stdout for the smoke-test records.
|
||||
const smokeReport = "first\tdupe\tsize\n" +
|
||||
@@ -211,7 +225,7 @@ func TestRunReportProgressNamesEachPhase(t *testing.T) {
|
||||
"report: database " + path + " (from " + databaseEnv + ")",
|
||||
reportOpenLine,
|
||||
reportOpenLine,
|
||||
"[2/2] read: counting the records, elapsed 0s, eta unknown",
|
||||
"[2/2] read: counting the records, elapsed 0s, remain unknown",
|
||||
"[2/2] read: [6/6] 100%, ",
|
||||
"report: 6 records read, 2 duplicate groups, 4 dupe files, " +
|
||||
"6.1 KiB reclaimable",
|
||||
@@ -250,7 +264,7 @@ func TestReadReportOnTerminal(t *testing.T) {
|
||||
p := startProgress(progressParams{
|
||||
label: reportPhase("read"), unit: "records", total: -1,
|
||||
waitingFor: countingRecords,
|
||||
}, terminalWidth, time.Hour)
|
||||
}, terminalWidth, time.Hour, time.Now)
|
||||
|
||||
_, err = readReport(t.Context(), db, "", os.Stderr, p)
|
||||
|
||||
@@ -277,13 +291,14 @@ func TestReadReportOnTerminal(t *testing.T) {
|
||||
func TestRunReportsIgnoreInsertionOrder(t *testing.T) {
|
||||
// README §Constraints: identical database contents give identical
|
||||
// output, whatever order the records were inserted in.
|
||||
a, b := testHash("a"), testHash("b")
|
||||
|
||||
recs := append(smokeTreeRecs(), awkwardPairRecs()...)
|
||||
recs = append(recs,
|
||||
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/2"},
|
||||
scanRec{size: 50, head: "b", tail: "b", content: "b", path: "/y/1"},
|
||||
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/2"},
|
||||
scanRec{size: 50, head: "a", tail: "a", content: "a", path: "/x/1"},
|
||||
scanRec{size: 50, path: "/x/unhashed"},
|
||||
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/2"},
|
||||
scanRec{size: 50, head: b, tail: b, content: b, path: "/y/1"},
|
||||
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/2"},
|
||||
scanRec{size: 50, head: a, tail: a, content: a, path: "/x/1"},
|
||||
)
|
||||
|
||||
reversed := slices.Clone(recs)
|
||||
@@ -386,16 +401,19 @@ func TestWarnfEscapes(t *testing.T) {
|
||||
func TestDupeGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||
bigH, bigT, bigC := testHash("H"), testHash("T"), testHash("C")
|
||||
u := testHash("u")
|
||||
recs := []scanRec{
|
||||
{size: 100, head: "h", tail: "t", content: "c", path: "/z/b"},
|
||||
{size: 100, head: "h", tail: "t", content: "c", path: "/z/a"},
|
||||
{size: 100, head: "h", tail: "t", content: "c", path: "/z/c"},
|
||||
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/2"},
|
||||
{size: 4000, head: "H", tail: "T", content: "C", path: "/big/1"},
|
||||
{size: 100, head: h, tail: tl, content: c, path: "/z/b"},
|
||||
{size: 100, head: h, tail: tl, content: c, path: "/z/a"},
|
||||
{size: 100, head: h, tail: tl, content: c, path: "/z/c"},
|
||||
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/2"},
|
||||
{size: 4000, head: bigH, tail: bigT, content: bigC, path: "/big/1"},
|
||||
// Same size as the /z group but a different head hash.
|
||||
{size: 100, head: "other", tail: "t", content: "c", path: "/z/d"},
|
||||
{size: 100, head: testHash("other"), tail: tl, content: c, path: "/z/d"},
|
||||
// A singleton signature must not form a group.
|
||||
{size: 7, head: "u", tail: "u", content: "u", path: "/lonely"},
|
||||
{size: 7, head: u, tail: u, content: u, path: "/lonely"},
|
||||
}
|
||||
|
||||
groups := dupeGroupsOf(t, recs)
|
||||
@@ -419,16 +437,13 @@ func TestDupeGroups(t *testing.T) {
|
||||
func TestDupeGroupsContentSeparates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Same size, head, and tail, but different content hashes: the final
|
||||
// rung keeps them apart, so no group forms. Matching content groups.
|
||||
// Records without a content hash never group, not even with each
|
||||
// other.
|
||||
// Same size, head, and tail: /b differs from the others only in its
|
||||
// content hash, so it joins no group, while /a and /c group.
|
||||
h, tl := testHash("h"), testHash("t")
|
||||
recs := []scanRec{
|
||||
{size: 100, head: "h", tail: "t", content: "c1", path: "/a"},
|
||||
{size: 100, head: "h", tail: "t", content: "c2", path: "/b"},
|
||||
{size: 100, head: "h", tail: "t", content: "c1", path: "/c"},
|
||||
{size: 100, head: "h", tail: "t", path: "/d"},
|
||||
{size: 100, head: "h", tail: "t", path: "/e"},
|
||||
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/a"},
|
||||
{size: 100, head: h, tail: tl, content: testHash("c2"), path: "/b"},
|
||||
{size: 100, head: h, tail: tl, content: testHash("c1"), path: "/c"},
|
||||
}
|
||||
|
||||
groups := dupeGroupsOf(t, recs)
|
||||
@@ -447,14 +462,15 @@ func TestDupeGroupsMtimeExcluded(t *testing.T) {
|
||||
|
||||
// mtime is informational only; records differing only in mtime
|
||||
// still group together.
|
||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||
recs := []scanRec{
|
||||
{
|
||||
size: 9, mtime: time.Unix(100, 0), head: "h", tail: "t",
|
||||
content: "c", path: "/m/1",
|
||||
size: 9, mtime: time.Unix(100, 0), head: h, tail: tl,
|
||||
content: c, path: "/m/1",
|
||||
},
|
||||
{
|
||||
size: 9, mtime: time.Unix(200, 0), head: "h", tail: "t",
|
||||
content: "c", path: "/m/2",
|
||||
size: 9, mtime: time.Unix(200, 0), head: h, tail: tl,
|
||||
content: c, path: "/m/2",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -469,11 +485,12 @@ func TestDupeGroupsTieBreak(t *testing.T) {
|
||||
|
||||
// The hashes sort opposite to the first paths, so ordering the
|
||||
// groups by hash instead of by first path fails this test.
|
||||
a, b := testHash("a"), testHash("b")
|
||||
recs := []scanRec{
|
||||
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/2"},
|
||||
{size: 50, head: "a", tail: "a", content: "a", path: "/beta/1"},
|
||||
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/2"},
|
||||
{size: 50, head: "b", tail: "b", content: "b", path: "/alpha/1"},
|
||||
{size: 50, head: a, tail: a, content: a, path: "/beta/2"},
|
||||
{size: 50, head: a, tail: a, content: a, path: "/beta/1"},
|
||||
{size: 50, head: b, tail: b, content: b, path: "/alpha/2"},
|
||||
{size: 50, head: b, tail: b, content: b, path: "/alpha/1"},
|
||||
}
|
||||
|
||||
groups := dupeGroupsOf(t, recs)
|
||||
@@ -495,15 +512,16 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
|
||||
// /z, /k, which is neither the order of their first paths nor its
|
||||
// reverse, and must write them ordered by first path. The group of
|
||||
// size 7 comes after them all, though its first path sorts first.
|
||||
a, b, c, d := testHash("a"), testHash("b"), testHash("c"), testHash("d")
|
||||
recs := []scanRec{
|
||||
{size: 50, head: "a", tail: "a", content: "a", path: "/k/2"},
|
||||
{size: 50, head: "a", tail: "a", content: "a", path: "/k/1"},
|
||||
{size: 50, head: "b", tail: "b", content: "b", path: "/z/1"},
|
||||
{size: 50, head: "b", tail: "b", content: "b", path: "/z/2"},
|
||||
{size: 50, head: "c", tail: "c", content: "c", path: "/a/2"},
|
||||
{size: 50, head: "c", tail: "c", content: "c", path: "/a/1"},
|
||||
{size: 7, head: "d", tail: "d", content: "d", path: "/0/2"},
|
||||
{size: 7, head: "d", tail: "d", content: "d", path: "/0/1"},
|
||||
{size: 50, head: a, tail: a, content: a, path: "/k/2"},
|
||||
{size: 50, head: a, tail: a, content: a, path: "/k/1"},
|
||||
{size: 50, head: b, tail: b, content: b, path: "/z/1"},
|
||||
{size: 50, head: b, tail: b, content: b, path: "/z/2"},
|
||||
{size: 50, head: c, tail: c, content: c, path: "/a/2"},
|
||||
{size: 50, head: c, tail: c, content: c, path: "/a/1"},
|
||||
{size: 7, head: d, tail: d, content: d, path: "/0/2"},
|
||||
{size: 7, head: d, tail: d, content: d, path: "/0/1"},
|
||||
}
|
||||
|
||||
groups := dupeGroupsOf(t, recs)
|
||||
@@ -522,11 +540,12 @@ func TestDupeGroupsOrderedByFirstPath(t *testing.T) {
|
||||
func TestDupeGroupsDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a, b := testHash("a"), testHash("b")
|
||||
recs := []scanRec{
|
||||
{size: 1, head: "a", tail: "a", content: "a", path: "/p/1"},
|
||||
{size: 1, head: "a", tail: "a", content: "a", path: "/p/2"},
|
||||
{size: 2, head: "b", tail: "b", content: "b", path: "/q/1"},
|
||||
{size: 2, head: "b", tail: "b", content: "b", path: "/q/2"},
|
||||
{size: 1, head: a, tail: a, content: a, path: "/p/1"},
|
||||
{size: 1, head: a, tail: a, content: a, path: "/p/2"},
|
||||
{size: 2, head: b, tail: b, content: b, path: "/q/1"},
|
||||
{size: 2, head: b, tail: b, content: b, path: "/q/2"},
|
||||
}
|
||||
|
||||
forward := dupeGroupsOf(t, recs)
|
||||
|
||||
+477
-730
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -2,11 +2,12 @@
|
||||
# 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
|
||||
# the formatter on the host, which a pristine checkout cannot do.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
||||
# 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
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
# before every step that lints, tests or compiles; a stage without that
|
||||
# line is served from cache on an unchanged tree, with no warning.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
+5
-4
@@ -1,11 +1,12 @@
|
||||
#!/bin/sh
|
||||
# script/docker: build the Docker image tagged with the project name.
|
||||
# Identical in all repos; the tag comes from script/projectname.
|
||||
# A new CACHEBUST value reruns every RUN step, since a cached gate phase
|
||||
# is a check that did not run; unlike --no-cache, it keeps Go's build cache.
|
||||
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
|
||||
# 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
|
||||
# first; a stage without that line is served from cache on an unchanged
|
||||
# tree, with no warning.
|
||||
# before every step that lints, tests or compiles; a stage without that
|
||||
# line is served from cache on an unchanged tree, with no warning.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
+7
-5
@@ -5,11 +5,13 @@
|
||||
# make its answer untrustworthy.
|
||||
#
|
||||
# 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
|
||||
# a cached lint layer is a lint that did not run; unlike --no-cache, it
|
||||
# keeps Go's build cache. This relies on every stage of the Dockerfile
|
||||
# declaring ARG CACHEBUST first; a stage without that line is served
|
||||
# from cache on an unchanged tree, with no warning.
|
||||
# --target names it. A new CACHEBUST value reruns every RUN step after
|
||||
# the ARG CACHEBUST line, since a cached lint layer is a lint 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 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.
|
||||
set -eu
|
||||
|
||||
|
||||
+6
-5
@@ -3,11 +3,12 @@
|
||||
# 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
|
||||
# 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
|
||||
# relies on every stage of the Dockerfile declaring ARG CACHEBUST first;
|
||||
# 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.
|
||||
# that did not run; unlike --no-cache, it keeps the layers above the ARG
|
||||
# CACHEBUST line and the cache mounts. This relies on every stage of the
|
||||
# Dockerfile 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.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||
|
||||
@@ -72,7 +72,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
|
||||
|
||||
prog = newProgress(progressParams{
|
||||
label: treesPhase("read"), unit: "records", total: -1,
|
||||
waitingFor: countingRecords,
|
||||
waitingFor: countingRecords, stalledOn: "db",
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
// Grouping works in memory, so it waits on nothing but the
|
||||
// processor.
|
||||
prog = newProgress(progressParams{
|
||||
label: treesPhase("group"), unit: "directories",
|
||||
total: int64(len(allDirs)),
|
||||
total: int64(len(allDirs)), stalledOn: "cpu",
|
||||
})
|
||||
dupes := collectTreeGroups(allDirs, super, prog)
|
||||
|
||||
@@ -92,6 +94,7 @@ func runTrees(ctx context.Context, stdout io.Writer) error {
|
||||
|
||||
prog = newProgress(progressParams{
|
||||
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
||||
stalledOn: "out",
|
||||
})
|
||||
dupeTrees, reclaimable, err := writeTreeGroups(stdout, dupes, prog)
|
||||
|
||||
@@ -283,18 +286,9 @@ func (b *treeBuilder) finish() (*treeNode, []*treeNode) {
|
||||
// name and its signature (size, head, tail, content); mtime is
|
||||
// excluded.
|
||||
func fileEntry(name string, r scanRec) string {
|
||||
content := r.content
|
||||
|
||||
// A record without a content hash has unknown content (README
|
||||
// "Database"): give it a signature no other file can share, so
|
||||
// trees containing it never compare equal. Real hashes are hex, so
|
||||
// the NUL-prefixed form cannot collide.
|
||||
if content == "" {
|
||||
content = "unhashed\x00" + r.path
|
||||
}
|
||||
|
||||
return "f\x00" + name + "\x00" + strconv.FormatInt(r.size, 10) +
|
||||
"\x00" + r.head + "\x00" + r.tail + "\x00" + content
|
||||
"\x00" + string(r.head[:]) + "\x00" + string(r.tail[:]) +
|
||||
"\x00" + string(r.content[:])
|
||||
}
|
||||
|
||||
// collectTreeGroups groups directories by digest, counting each one on
|
||||
@@ -344,7 +338,8 @@ func collectTreeGroups(allDirs []*treeNode, super *treeNode,
|
||||
// digest is the SHA-256 of its child entries — files serialized with
|
||||
// name and signature, subdirectories with name and recursive digest —
|
||||
// sorted byte-lexicographically. Filenames cannot contain NUL or "/",
|
||||
// so NUL delimiters are unambiguous.
|
||||
// and every hash and digest is 32 bytes, so NUL delimiters are
|
||||
// unambiguous.
|
||||
func (n *treeNode) computeDigest() {
|
||||
slices.Sort(n.entries)
|
||||
|
||||
|
||||
+25
-29
@@ -10,24 +10,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Signature hashes shared by the smoke-test records.
|
||||
const (
|
||||
f1Head = "f1h"
|
||||
f1Tail = "f1t"
|
||||
f1Content = "f1c"
|
||||
f2Head = "f2h"
|
||||
f2Tail = "f2t"
|
||||
f2Content = "f2c"
|
||||
)
|
||||
|
||||
// treesOpenLine is the plain line of trees' open phase when it opens
|
||||
// the database at once; it prints it as the phase starts and as it ends.
|
||||
const treesOpenLine = "[1/4] open: opening the database, elapsed 0s, " +
|
||||
"eta unknown"
|
||||
"remain unknown"
|
||||
|
||||
// smokeTreeRecs mirrors the README smoke-test tree layout: /d/t1 and
|
||||
// /d/t2 are identical, /d/t3 differs from them only by one filename.
|
||||
// smokeTreeRecs mirrors the tree of the test under README "Definition
|
||||
// of done": /d/t1 and /d/t2 are identical, /d/t3 differs from them only
|
||||
// by one filename.
|
||||
func smokeTreeRecs() []scanRec {
|
||||
f1Head, f1Tail, f1Content := testHash("f1h"), testHash("f1t"), testHash("f1c")
|
||||
f2Head, f2Tail, f2Content := testHash("f2h"), testHash("f2t"), testHash("f2c")
|
||||
|
||||
return []scanRec{
|
||||
{size: 3000, head: f1Head, tail: f1Tail, content: f1Content, path: "/d/t1/f1"},
|
||||
{size: 100, head: f2Head, tail: f2Tail, content: f2Content, path: "/d/t1/sub/f2"},
|
||||
@@ -153,13 +147,14 @@ func TestTreeNamesSortingBeforeSlash(t *testing.T) {
|
||||
|
||||
for _, top := range []string{"/a", "/c"} {
|
||||
for _, p := range []string{"/b", "/b-x/f", "/b.txt", "/b/f"} {
|
||||
content := "c"
|
||||
content := testHash("c")
|
||||
if p == "/b-x/f" {
|
||||
content = "other"
|
||||
content = testHash("other")
|
||||
}
|
||||
|
||||
recs = append(recs, scanRec{
|
||||
size: 1, head: "h", tail: "t", content: content, path: top + p,
|
||||
size: 1, head: testHash("h"), tail: testHash("t"),
|
||||
content: content, path: top + p,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -242,11 +237,11 @@ func TestRunTreesProgressNamesEachPhase(t *testing.T) {
|
||||
"trees: database " + path + " (from " + databaseEnv + ")",
|
||||
treesOpenLine,
|
||||
treesOpenLine,
|
||||
"[2/4] read: counting the records, elapsed 0s, eta unknown",
|
||||
"[2/4] read: counting the records, elapsed 0s, remain unknown",
|
||||
"[2/4] read: [6/6] 100%, ",
|
||||
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, eta unknown",
|
||||
"[3/4] group: [0/8] 0%, 0 directories/s, elapsed 0s, remain unknown",
|
||||
"[3/4] group: [8/8] 100%, ",
|
||||
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, eta unknown",
|
||||
"[4/4] write: [0/1] 0%, 0 groups/s, elapsed 0s, remain unknown",
|
||||
"[4/4] write: [1/1] 100%, ",
|
||||
"trees: 6 records read, 1 duplicate tree groups, 1 dupe trees, ",
|
||||
}
|
||||
@@ -279,7 +274,7 @@ func TestWriteTreeGroupsOnTerminal(t *testing.T) {
|
||||
// No timed draws: the display is drawn as it starts and as it ends.
|
||||
p := startProgress(progressParams{
|
||||
label: treesPhase("write"), unit: "groups", total: int64(len(dupes)),
|
||||
}, terminalWidth, time.Hour)
|
||||
}, terminalWidth, time.Hour, time.Now)
|
||||
|
||||
_, _, err := writeTreeGroups(os.Stderr, dupes, p)
|
||||
|
||||
@@ -332,11 +327,10 @@ func TestTreeDigests(t *testing.T) {
|
||||
func TestTreeDigestContentSensitivity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const sharedTail = "same"
|
||||
|
||||
same, c := testHash("same"), testHash("c")
|
||||
recs := []scanRec{
|
||||
{size: 10, head: sharedTail, tail: sharedTail, content: "c", path: "/r/a/f"},
|
||||
{size: 10, head: "DIFF", tail: sharedTail, content: "c", path: "/r/b/f"},
|
||||
{size: 10, head: same, tail: same, content: c, path: "/r/a/f"},
|
||||
{size: 10, head: testHash("DIFF"), tail: same, content: c, path: "/r/b/f"},
|
||||
}
|
||||
|
||||
_, dirs := treeOf(t, recs)
|
||||
@@ -397,9 +391,10 @@ func TestCollectTreeGroupsSiblings(t *testing.T) {
|
||||
|
||||
// Identical sibling dirs share a parent, so their group cannot be
|
||||
// implied by a parent group and must be reported.
|
||||
h, tl, c := testHash("h"), testHash("t"), testHash("c")
|
||||
recs := []scanRec{
|
||||
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x1/f"},
|
||||
{size: 10, head: "h", tail: "t", content: "c", path: "/p/x2/f"},
|
||||
{size: 10, head: h, tail: tl, content: c, path: "/p/x1/f"},
|
||||
{size: 10, head: h, tail: tl, content: c, path: "/p/x2/f"},
|
||||
}
|
||||
|
||||
super, dirs := treeOf(t, recs)
|
||||
@@ -418,10 +413,11 @@ func TestCollectTreeGroupsDifferingParents(t *testing.T) {
|
||||
// /p/a and /q/b contain identical x subtrees, but /p/a has an
|
||||
// extra file, so the parents' digests differ and the x group must
|
||||
// be reported.
|
||||
h, tl, c, e := testHash("h"), testHash("t"), testHash("c"), testHash("e")
|
||||
recs := []scanRec{
|
||||
{size: 10, head: "h", tail: "t", content: "c", path: "/p/a/x/f"},
|
||||
{size: 99, head: "e", tail: "e", content: "e", path: "/p/a/extra"},
|
||||
{size: 10, head: "h", tail: "t", content: "c", path: "/q/b/x/f"},
|
||||
{size: 10, head: h, tail: tl, content: c, path: "/p/a/x/f"},
|
||||
{size: 99, head: e, tail: e, content: e, path: "/p/a/extra"},
|
||||
{size: 10, head: h, tail: tl, content: c, path: "/q/b/x/f"},
|
||||
}
|
||||
|
||||
super, dirs := treeOf(t, recs)
|
||||
|
||||
Reference in New Issue
Block a user