1 Commits
Author SHA1 Message Date
sneak e248313ed1 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 when it is on a
different filesystem than the deepest kept operand above it, because
that operand's walk does not enter it; it is then walked on its own.
Below such an operand, the rule that keeps the records under a
directory -x did not enter applies only to directories its own walk
did not enter, so the records of files gone from it are deleted.

The new test stubs each operand's device and shares its 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 18:37:06 +00:00
4 changed files with 24 additions and 78 deletions

No files matched your search

+3 -4
View File
@@ -399,9 +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 it is a directory on a different another one is kept and walked on its own when it is on a different filesystem
filesystem than the operand covering it, or a regular file in such a directory, than the operand covering it, because that operand's walk does not enter it.
because the walk of the covering operand 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
@@ -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
+3 -4
View File
@@ -29,10 +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 it is a directory on a different filesystem than that walked on its own instead of being dropped, and the records of files gone from
operand, or a regular file in such a directory, and the records of files gone 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)
- with `-x`, `scan` keeps the records below a directory on another filesystem - with `-x`, `scan` keeps the records below a directory on another filesystem
+9 -20
View File
@@ -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 device puts it, or for a // operand under another one is kept when device puts it on a different
// regular file its directory, on a different filesystem than the // filesystem than the deepest kept operand above it, because that
// deepest kept operand above it, because that operand's walk then does // operand's walk does not enter it. Every remaining file is then
// not reach it. Every remaining file is then reachable through exactly // reachable through exactly one root, so walked paths are unique
// one root, so walked paths are unique without keeping a scan-wide set // 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 {
@@ -234,21 +233,11 @@ 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
} }
// The walk of above enters a directory only when it is on dev, ok := device(r)
// above's filesystem, and then reaches every regular file in it
// whatever device the file reports, so for a regular-file
// operand its directory decides.
dir := r
if fi, err := os.Lstat(r); err == nil && fi.Mode().IsRegular() {
dir = filepath.Dir(r)
}
dev, ok := device(dir)
aboveDev, aboveOK := device(above) aboveDev, aboveOK := device(above)
if ok && aboveOK && dev != aboveDev { if ok && aboveOK && dev != aboveDev {
@@ -435,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) {
+9 -50
View File
@@ -1135,14 +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, taking the device of each operand, and of a regular-file // device from devices instead of from lstat, so a test can put an
// operand's directory, from devices instead of from lstat, so a test // operand on a filesystem that no directory here is on. It runs the
// can put an operand on a filesystem that no directory here is on. It // steps of runPhases except the content phase.
// runs the steps of runPhases except 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) {
@@ -1161,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)
@@ -1188,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
@@ -1302,38 +1293,6 @@ func TestScanOneFilesystemWalksNestedOperand(t *testing.T) {
} }
} }
// 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)
}
}
// 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 {