Compare commits
1
Commits
main
..
e248313ed1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e248313ed1 |
@@ -399,10 +399,8 @@ scan and are enumerated concurrently: every operand seeds the shared walk worker
|
|||||||
pool. Overlapping operands are harmless — an operand that duplicates another or
|
pool. Overlapping operands are harmless — an operand that duplicates another or
|
||||||
lies under another is dropped before walking, so every file is reached exactly
|
lies under another is dropped before walking, so every file is reached exactly
|
||||||
once and produces one database record. With `-x`, though, an operand under
|
once and produces one database record. With `-x`, though, an operand under
|
||||||
another one is kept and walked on its own when any directory from it (from its
|
another one is kept and walked on its own when it is on a different filesystem
|
||||||
directory, for a regular file) up to but not including the operand covering it
|
than the operand covering it, because that operand's walk does not enter it.
|
||||||
is on a different filesystem than that operand, because that operand's walk does
|
|
||||||
not enter such a directory and so does not reach it.
|
|
||||||
|
|
||||||
An operand that is a symlink (never followed, not even as an operand), socket,
|
An operand that is a symlink (never followed, not even as an operand), socket,
|
||||||
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
FIFO, or device node, or a directory named `.zfs`, is not scanned. `scan` prints
|
||||||
@@ -411,8 +409,9 @@ drops it from the scanned operands before reading the database. Another operand
|
|||||||
beneath it is still scanned. The records stored beneath it are not deleted: they
|
beneath it is still scanned. The records stored beneath it are not deleted: they
|
||||||
are treated like any other record outside the scanned operands, including the
|
are treated like any other record outside the scanned operands, including the
|
||||||
content-phase exception below. If it lies under another operand, they are under
|
content-phase exception below. If it lies under another operand, they are under
|
||||||
that operand instead, and are treated like any other record there. This is not
|
that operand instead, and are deleted like any other record there that this scan
|
||||||
an error: a scan whose every operand is dropped walks nothing and exits 0.
|
did not verify. This is not an error: a scan whose every operand is dropped
|
||||||
|
walks nothing and exits 0.
|
||||||
|
|
||||||
`scan` synchronizes the database with the filesystem state under the scanned
|
`scan` synchronizes the database with the filesystem state under the scanned
|
||||||
operands:
|
operands:
|
||||||
@@ -454,7 +453,7 @@ operands:
|
|||||||
directory is on another filesystem than its operand counts as outside the
|
directory is on another filesystem than its operand counts as outside the
|
||||||
scanned operands: it is not deleted, and its size joins the size census. A
|
scanned operands: it is not deleted, and its size joins the size census. A
|
||||||
filesystem mounted under an operand can therefore be scanned on its own
|
filesystem mounted under an operand can therefore be scanned on its own
|
||||||
schedule into the same database. Records below an operand at or inside such a
|
schedule into the same database. Records below an operand inside such a
|
||||||
directory are not kept this way, since that operand's own walk verifies them.
|
directory are not kept this way, since that operand's own walk verifies them.
|
||||||
|
|
||||||
`scan` runs **four sequential phases over the whole scan**. Parallelism lives
|
`scan` runs **four sequential phases over the whole scan**. Parallelism lives
|
||||||
@@ -522,19 +521,17 @@ Rules for the walk:
|
|||||||
- Filesystem boundaries are crossed by default. With `-x` (long form
|
- Filesystem boundaries are crossed by default. With `-x` (long form
|
||||||
`--one-file-system`, following the GNU `du`/`rsync` convention), never descend
|
`--one-file-system`, following the GNU `du`/`rsync` convention), never descend
|
||||||
into a directory on a different filesystem than its `PATH` operand; each
|
into a directory on a different filesystem than its `PATH` operand; each
|
||||||
operand is bounded by its own filesystem. The records below such a directory,
|
operand is bounded by its own filesystem. The records below such a directory
|
||||||
unless they lie below an operand at or inside that directory, count as outside
|
count as outside the scanned operands, as described in "`scan` mode" above:
|
||||||
the scanned operands, as described in "`scan` mode" above: they are not
|
they are not deleted, and their sizes join the size census.
|
||||||
deleted, and their sizes join the size census.
|
|
||||||
- On any per-path error (permission denied, file vanished between passes,
|
- On any per-path error (permission denied, file vanished between passes,
|
||||||
unreadable): print a one-line warning to stderr, skip the path, and continue.
|
unreadable): print a one-line warning to stderr, skip the path, and continue.
|
||||||
Per-file errors never abort the run; the final summary reports how many were
|
Per-file errors never abort the run; the final summary reports how many were
|
||||||
skipped. As specified above, a skipped path that has a database record from an
|
skipped. As specified above, a skipped path that has a database record from an
|
||||||
earlier scan loses that record, unless it failed only in the content phase, or
|
earlier scan loses that record, unless it failed only in the content phase, or
|
||||||
is an operand dropped before the database was read whose record counts as
|
is an operand dropped before the database was read that lies under no other
|
||||||
outside the scanned operands; an unreadable directory subtree likewise loses
|
operand; an unreadable directory subtree likewise loses its records (accepted:
|
||||||
its records (accepted: the database mirrors what the latest scan could
|
the database mirrors what the latest scan could actually verify).
|
||||||
actually verify).
|
|
||||||
|
|
||||||
Concurrency: the walk phase (which also stats files), the hash phase, and the
|
Concurrency: the walk phase (which also stats files), the hash phase, and the
|
||||||
content phase each use a worker pool of `--workers` workers (default
|
content phase each use a worker pool of `--workers` workers (default
|
||||||
|
|||||||
@@ -29,11 +29,9 @@
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- with `-x`, an operand under another operand is walked on its own instead of
|
- with `-x`, an operand under another operand but on a different filesystem is
|
||||||
being dropped when a directory from it (from its directory, for a regular
|
walked on its own instead of being dropped, and the records of files gone from
|
||||||
file) up to but not including that operand is on a different filesystem than
|
below it are deleted (2026-10-14,
|
||||||
that operand, whose walk then cannot reach it, and the records of files gone
|
|
||||||
from below it are deleted (2026-10-14,
|
|
||||||
https://git.eeqj.de/sneak/sfdupes/issues/106)
|
https://git.eeqj.de/sneak/sfdupes/issues/106)
|
||||||
|
|
||||||
- with `-x`, `scan` keeps the records below a directory on another filesystem
|
- with `-x`, `scan` keeps the records below a directory on another filesystem
|
||||||
|
|||||||
@@ -204,12 +204,11 @@ func resolveRoots(roots []string) ([]string, error) {
|
|||||||
|
|
||||||
// pruneRoots drops operands already covered by another operand:
|
// pruneRoots drops operands already covered by another operand:
|
||||||
// duplicates and any operand lying under another one. With -x, an
|
// duplicates and any operand lying under another one. With -x, an
|
||||||
// operand under another one is kept when the walk of the deepest kept
|
// operand under another one is kept when device puts it on a different
|
||||||
// operand above it cannot reach it: when device puts any directory from
|
// filesystem than the deepest kept operand above it, because that
|
||||||
// it (from its directory, for a regular file) up to but not including
|
// operand's walk does not enter it. Every remaining file is then
|
||||||
// that operand on a different filesystem than that operand. Every
|
// reachable through exactly one root, so walked paths are unique
|
||||||
// remaining file is then reachable through exactly one root, so walked
|
// without keeping a scan-wide set of every path seen.
|
||||||
// paths are unique without keeping a scan-wide set of every path seen.
|
|
||||||
func pruneRoots(roots []string, oneFS bool,
|
func pruneRoots(roots []string, oneFS bool,
|
||||||
device func(path string) (uint64, bool),
|
device func(path string) (uint64, bool),
|
||||||
) []string {
|
) []string {
|
||||||
@@ -234,34 +233,15 @@ func pruneRoots(roots []string, oneFS bool,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without -x, and always for a duplicate, r is dropped.
|
if !oneFS {
|
||||||
if !oneFS || above == r {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dev, ok := device(r)
|
||||||
aboveDev, aboveOK := device(above)
|
aboveDev, aboveOK := device(above)
|
||||||
if !aboveOK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The walk of above enters a directory only when it is on
|
if ok && aboveOK && dev != aboveDev {
|
||||||
// above's filesystem, and then reaches every regular file in it
|
|
||||||
// whatever device the file reports, so for a regular-file
|
|
||||||
// operand the directories checked start at its directory.
|
|
||||||
dir := r
|
|
||||||
|
|
||||||
fi, err := os.Lstat(r)
|
|
||||||
if err == nil && fi.Mode().IsRegular() {
|
|
||||||
dir = filepath.Dir(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
for ; dir != above; dir = filepath.Dir(dir) {
|
|
||||||
dev, ok := device(dir)
|
|
||||||
if ok && dev != aboveDev {
|
|
||||||
kept = append(kept, r)
|
kept = append(kept, r)
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,8 +318,8 @@ func syncScan(ctx context.Context, db *sql.DB, roots []string,
|
|||||||
// size, head, and tail match another record's). Records outside the
|
// size, head, and tail match another record's). Records outside the
|
||||||
// roots are never touched, except that the content phase fills in
|
// roots are never touched, except that the content phase fills in
|
||||||
// their content hash. Operands the walk cannot start from are dropped
|
// their content hash. Operands the walk cannot start from are dropped
|
||||||
// first, so the records beneath them are treated as if those operands
|
// first, so the records beneath them count as outside the roots unless
|
||||||
// had not been given.
|
// they lie under another root.
|
||||||
func (s *scanState) runPhases(ctx context.Context, roots []string,
|
func (s *scanState) runPhases(ctx context.Context, roots []string,
|
||||||
workers int, oneFS bool,
|
workers int, oneFS bool,
|
||||||
) error {
|
) error {
|
||||||
@@ -381,10 +361,10 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
|
|||||||
// walkableRoots returns the operands the walk can start from: regular
|
// walkableRoots returns the operands the walk can start from: regular
|
||||||
// files, and directories not named .zfs. Every other operand is warned
|
// files, and directories not named .zfs. Every other operand is warned
|
||||||
// about, counted as skipped, and dropped. A dropped operand is no
|
// about, counted as skipped, and dropped. A dropped operand is no
|
||||||
// longer a root, so the records stored beneath it are treated as if it
|
// longer a root, so the records stored beneath it count as outside the
|
||||||
// had not been given: those under no other root count as outside the
|
// roots and are not deleted as unverified, unless it lies under another
|
||||||
// roots and are not deleted as unverified. An operand that fails lstat
|
// root. An operand that fails lstat here is kept, and the walk warns
|
||||||
// here is kept, and the walk warns about it.
|
// about it.
|
||||||
func (s *scanState) walkableRoots(roots []string) []string {
|
func (s *scanState) walkableRoots(roots []string) []string {
|
||||||
kept := make([]string, 0, len(roots))
|
kept := make([]string, 0, len(roots))
|
||||||
|
|
||||||
@@ -444,8 +424,8 @@ func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
|
|||||||
// hashes; both remain candidates until the census decides whether
|
// hashes; both remain candidates until the census decides whether
|
||||||
// their sizes are shared. A record below a directory -x did not enter
|
// their sizes are shared. A record below a directory -x did not enter
|
||||||
// counts as outside the roots: it is not deleted, and its size joins
|
// counts as outside the roots: it is not deleted, and its size joins
|
||||||
// the census. That does not hold below a root at or inside that
|
// the census. That does not hold below a root inside that directory,
|
||||||
// directory, since that root's own walk verifies the records below it.
|
// since that root's own walk verifies the records below it.
|
||||||
func (s *scanState) walkPhase(
|
func (s *scanState) walkPhase(
|
||||||
events <-chan walkEvent, roots []string,
|
events <-chan walkEvent, roots []string,
|
||||||
) ([]fileRec, []fileRec) {
|
) ([]fileRec, []fileRec) {
|
||||||
@@ -1039,8 +1019,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
|
|||||||
// operand becomes an initial job. walkableRoots has already dropped
|
// operand becomes an initial job. walkableRoots has already dropped
|
||||||
// every other operand. One that has changed into something else since
|
// every other operand. One that has changed into something else since
|
||||||
// is warned about and skipped here; it is still a root, so the records
|
// is warned about and skipped here; it is still a root, so the records
|
||||||
// stored beneath it, other than those under another root, are deleted
|
// stored beneath it are deleted as unverified.
|
||||||
// as unverified.
|
|
||||||
func seedRoot(ctx context.Context, root string,
|
func seedRoot(ctx context.Context, root string,
|
||||||
events chan<- walkEvent,
|
events chan<- walkEvent,
|
||||||
) []dirJob {
|
) []dirJob {
|
||||||
@@ -1240,8 +1219,7 @@ func emitFile(ctx context.Context, p string, e fs.DirEntry,
|
|||||||
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
|
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
|
||||||
// snapshot), and with -x never enter a directory on a different
|
// snapshot), and with -x never enter a directory on a different
|
||||||
// filesystem than its operand, sending an event that names it so the
|
// filesystem than its operand, sending an event that names it so the
|
||||||
// scan keeps the records below it, except those below an operand at or
|
// scan keeps the records below it.
|
||||||
// inside it.
|
|
||||||
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
|
||||||
parent dirJob, oneFS bool, events chan<- walkEvent,
|
parent dirJob, oneFS bool, events chan<- walkEvent,
|
||||||
) (dirJob, bool) {
|
) (dirJob, bool) {
|
||||||
|
|||||||
+9
-141
@@ -1135,15 +1135,13 @@ func TestSubdirJobStatError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncOneFilesystem scans roots into db with -x and returns the scan
|
// syncOneFilesystem scans roots into db with -x, taking each operand's
|
||||||
// stats. Every device pruneRoots looks up, and the device each
|
// device from devices instead of from lstat, so a test can put an
|
||||||
// operand's walk compares its subdirectories with, comes from devices
|
// operand on a filesystem that no directory here is on. It runs the
|
||||||
// instead of from lstat, so a test can put a path on a filesystem that
|
// steps of runPhases except the content phase.
|
||||||
// no directory here is on. Unlike runPhases, it skips walkableRoots,
|
|
||||||
// the check for a cancelled walk, and the content phase.
|
|
||||||
func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
||||||
devices map[string]uint64,
|
devices map[string]uint64,
|
||||||
) scanStats {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
device := func(path string) (uint64, bool) {
|
device := func(path string) (uint64, bool) {
|
||||||
@@ -1162,19 +1160,13 @@ func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs, subdirs, events := startWalkWorkers(ctx, 2, true)
|
initial := make([]dirJob, 0, len(roots))
|
||||||
|
|
||||||
// seedRoot emits a regular-file operand itself and returns a job
|
|
||||||
// only for a directory operand.
|
|
||||||
var initial []dirJob
|
|
||||||
|
|
||||||
for _, root := range roots {
|
for _, root := range roots {
|
||||||
for _, job := range seedRoot(ctx, root, events) {
|
initial = append(initial,
|
||||||
job.rootDev = devices[root]
|
dirJob{path: root, rootDev: devices[root], rootDevOK: true})
|
||||||
initial = append(initial, job)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jobs, subdirs, events := startWalkWorkers(ctx, 2, true)
|
||||||
dispatchDirs(ctx, initial, jobs, subdirs)
|
dispatchDirs(ctx, initial, jobs, subdirs)
|
||||||
|
|
||||||
changed, unhashed := s.walkPhase(events, roots)
|
changed, unhashed := s.walkPhase(events, roots)
|
||||||
@@ -1189,8 +1181,6 @@ func syncOneFilesystem(t *testing.T, db *sql.DB, roots []string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.st
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScanOneFilesystemKeepsOtherFilesystemRecords checks that with -x a
|
// TestScanOneFilesystemKeepsOtherFilesystemRecords checks that with -x a
|
||||||
@@ -1303,128 +1293,6 @@ func TestScanOneFilesystemWalksNestedOperand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScanOneFilesystemWalksOperandBelowOtherFilesystem checks that
|
|
||||||
// with -x an operand on the same filesystem as the operand covering it
|
|
||||||
// is walked on its own when a directory between them is on another
|
|
||||||
// filesystem, since the walk of the covering operand does not enter
|
|
||||||
// that directory: a file added below the nested operand is recorded,
|
|
||||||
// and the record of a file gone from below it is deleted. A record
|
|
||||||
// below that directory but outside the nested operand is still kept.
|
|
||||||
// Both operands are given one device that no directory here is on,
|
|
||||||
// while mnt keeps its own.
|
|
||||||
func TestScanOneFilesystemWalksOperandBelowOtherFilesystem(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
|
|
||||||
// A scan without -x records both files below mnt.
|
|
||||||
gone := writeFile(t, dir, "mnt/back/gone", pattern(1, 10))
|
|
||||||
kept := writeFile(t, dir, "mnt/kept", pattern(2, 20))
|
|
||||||
syncTree(t, db, dir)
|
|
||||||
|
|
||||||
err := os.Remove(gone)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
added := writeFile(t, dir, "mnt/back/added", pattern(3, 30))
|
|
||||||
|
|
||||||
info, err := os.Lstat(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dev, ok := deviceOfInfo(info)
|
|
||||||
if !ok {
|
|
||||||
t.Skip("platform exposes no device id")
|
|
||||||
}
|
|
||||||
|
|
||||||
mnt := filepath.Join(dir, "mnt")
|
|
||||||
back := filepath.Join(mnt, "back")
|
|
||||||
syncOneFilesystem(t, db, []string{dir, back},
|
|
||||||
map[string]uint64{dir: dev + 1, mnt: dev, back: dev + 1})
|
|
||||||
|
|
||||||
paths := recordPaths(dbRecords(t, db))
|
|
||||||
|
|
||||||
if !slices.Contains(paths, added) {
|
|
||||||
t.Errorf("no record for %s: the walk did not reach %s", added, back)
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(paths, gone) {
|
|
||||||
t.Errorf("record for %s kept: the file is gone", gone)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !slices.Contains(paths, kept) {
|
|
||||||
t.Errorf("record for %s deleted: the walk of %s did not enter %s",
|
|
||||||
kept, dir, mnt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScanOneFilesystemRecordsNestedFileOnce checks that with -x a
|
|
||||||
// regular-file operand under another operand is dropped when the file
|
|
||||||
// reports a different device than that operand but its directory does
|
|
||||||
// not, as a file bind-mounted into the tree does: the walk of the other
|
|
||||||
// operand enters the directory and reaches the file, so the file is
|
|
||||||
// recorded once.
|
|
||||||
func TestScanOneFilesystemRecordsNestedFileOnce(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
||||||
|
|
||||||
info, err := os.Lstat(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dev, ok := deviceOfInfo(info)
|
|
||||||
if !ok {
|
|
||||||
t.Skip("platform exposes no device id")
|
|
||||||
}
|
|
||||||
|
|
||||||
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
|
||||||
dir: dev, filepath.Dir(file): dev, file: dev + 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
if st.added != 1 {
|
|
||||||
t.Errorf("%s recorded %d times, want once", file, st.added)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestScanOneFilesystemWalksNestedFileOperand checks that with -x a
|
|
||||||
// regular-file operand under another operand is kept when its directory
|
|
||||||
// is on a different filesystem than that operand: the walk of the other
|
|
||||||
// operand does not enter the directory, so the file is recorded once,
|
|
||||||
// as its own operand. The other operand is given a device that no
|
|
||||||
// directory here is on.
|
|
||||||
func TestScanOneFilesystemWalksNestedFileOperand(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
db := openTestDB(t)
|
|
||||||
file := writeFile(t, dir, "sub/f", pattern(1, 10))
|
|
||||||
|
|
||||||
info, err := os.Lstat(dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dev, ok := deviceOfInfo(info)
|
|
||||||
if !ok {
|
|
||||||
t.Skip("platform exposes no device id")
|
|
||||||
}
|
|
||||||
|
|
||||||
st := syncOneFilesystem(t, db, []string{dir, file}, map[string]uint64{
|
|
||||||
dir: dev + 1, filepath.Dir(file): dev,
|
|
||||||
})
|
|
||||||
|
|
||||||
if st.added != 1 {
|
|
||||||
t.Errorf("%s recorded %d times, want once", file, st.added)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSmokeTree recreates the README smoke-test filesystem layout
|
// buildSmokeTree recreates the README smoke-test filesystem layout
|
||||||
// with deterministic content and returns the tree root.
|
// with deterministic content and returns the tree root.
|
||||||
func buildSmokeTree(t *testing.T) string {
|
func buildSmokeTree(t *testing.T) string {
|
||||||
|
|||||||
Reference in New Issue
Block a user