1 Commits
Author SHA1 Message Date
sneak c8c7882cc7 Walk an operand nested on another filesystem with -x (closes #106)
check / check (push) Canceled after 0s
With -x, an operand under another operand is kept and walked on its
own when it is a directory on a different filesystem than the deepest
kept operand above it, or a regular file in such a directory, since
that operand's walk does not reach it. Below a kept operand, the rule
keeping records under a directory -x did not enter covers only
directories its own walk did not enter, so records of files gone from
it are deleted.

The new tests stub the devices and share their scan steps with the
test for #99, whose comment no
longer claims it runs every phase. The startWalk comment is rewrapped.

Model: opus-5-5
2026-10-14 19:07:23 +00:00
4 changed files with 34 additions and 104 deletions
+9 -10
View File
@@ -399,10 +399,9 @@ 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 a directory on a different
directory, for a regular file) up to but not including the operand covering it filesystem than the operand covering it, or a regular file in such a directory,
is on a different filesystem than that operand, because that operand's walk does because the walk of the covering operand does not reach it.
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 +410,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:
@@ -531,10 +531,9 @@ Rules for the walk:
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
+2 -3
View File
@@ -30,9 +30,8 @@
# 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 is walked on its own instead of
being dropped when a directory from it (from its directory, for a regular being dropped when it is a directory on a different filesystem than that
file) up to but not including that operand is on a different filesystem than operand, or a regular file in such a directory, and the records of files gone
that operand, whose walk then cannot reach it, and the records of files gone
from below it are deleted (2026-10-14, from below it are deleted (2026-10-14,
https://git.eeqj.de/sneak/sfdupes/issues/106) https://git.eeqj.de/sneak/sfdupes/issues/106)
+19 -28
View File
@@ -204,12 +204,12 @@ func resolveRoots(roots []string) ([]string, error) {
// pruneRoots drops operands already covered by another operand: // pruneRoots drops operands already covered by another operand:
// duplicates and any operand lying under another one. With -x, an // duplicates and any operand lying under another one. 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, or for a
// operand above it cannot reach it: when device puts any directory from // regular file its directory, on a different filesystem than the
// it (from its directory, for a regular file) up to but not including // deepest kept operand above it, because that operand's walk then does
// that operand on a different filesystem than that operand. Every // not reach it. Every remaining file is then reachable through exactly
// remaining file is then reachable through exactly one root, so walked // one root, so walked paths are unique without keeping a scan-wide set
// paths are unique without keeping a scan-wide set of every path seen. // 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 {
@@ -239,15 +239,10 @@ func pruneRoots(roots []string, oneFS bool,
continue continue
} }
aboveDev, aboveOK := device(above)
if !aboveOK {
continue
}
// The walk of above enters a directory only when it is on // The walk of above enters a directory only when it is on
// above's filesystem, and then reaches every regular file in it // above's filesystem, and then reaches every regular file in it
// whatever device the file reports, so for a regular-file // whatever device the file reports, so for a regular-file
// operand the directories checked start at its directory. // operand its directory decides.
dir := r dir := r
fi, err := os.Lstat(r) fi, err := os.Lstat(r)
@@ -255,13 +250,11 @@ func pruneRoots(roots []string, oneFS bool,
dir = filepath.Dir(r) dir = filepath.Dir(r)
} }
for ; dir != above; dir = filepath.Dir(dir) { dev, ok := device(dir)
dev, ok := device(dir) aboveDev, aboveOK := device(above)
if ok && dev != aboveDev {
kept = append(kept, r)
break if ok && aboveOK && dev != aboveDev {
} kept = append(kept, r)
} }
} }
@@ -338,8 +331,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 +374,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))
@@ -1039,8 +1032,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 +1232,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) {
+4 -63
View File
@@ -1136,11 +1136,10 @@ func TestSubdirJobStatError(t *testing.T) {
} }
// syncOneFilesystem scans roots into db with -x and returns the scan // syncOneFilesystem scans roots into db with -x and returns the scan
// stats. Every device pruneRoots looks up, and the device each // stats, taking the device of each operand, and of a regular-file
// operand's walk compares its subdirectories with, comes from devices // operand's directory, from devices instead of from lstat, so a test
// instead of from lstat, so a test can put a path on a filesystem that // can put an operand on a filesystem that no directory here is on. It
// no directory here is on. Unlike runPhases, it skips walkableRoots, // runs the steps of runPhases except the content phase.
// 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 { ) scanStats {
@@ -1303,64 +1302,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 // TestScanOneFilesystemRecordsNestedFileOnce checks that with -x a
// regular-file operand under another operand is dropped when the file // regular-file operand under another operand is dropped when the file
// reports a different device than that operand but its directory does // reports a different device than that operand but its directory does