Author SHA1 Message Date
sneak fd76633211 Walk an operand below .zfs or a symlink on its own (closes #142)
check / check (push) Waiting to run
An operand under another was dropped as covered by it even when a
directory between them was named .zfs or was a symlink. The walk of
the outer operand enters neither, so the files under the inner operand
went unscanned and their records were deleted. walkReaches now reports
such an operand as not reached, so pruneRoots keeps it and walks it on
its own. README lists both cases with the others.

Judgement call: the new constant zfsDirName replaces the .zfs literal
in operandWarning and subdirJob too, because goconst rejects a third
copy.

Model: opus-5-5
2026-10-26 12:23:51 +00:00
clawbot aa99b37b99 Skip removed paths silently and fix the progress line (closes #133)
check / check (push) Canceled after 0s
A path removed while the scan runs gets no warning and no skipped
count, in the walk or the hash phase. If the first of several hard
links is gone, the next is read. Other errors are still warned about.

On a terminal every display is the library's spinner, then the line. A
phase with an exact total ends the padded line with its percent and a
bar at a fixed column, drawn by sfdupes, as the library shows no
spinner beside its own bar. A too-wide line loses its rate first.

The time remaining is labelled remain, not eta.

Judgement call: an operand removed mid-scan is skipped silently too.
Deviation: README's stalled-line example keeps its words on 100
columns, not 80.

Model: opus-5-5
2026-10-26 06:32:38 +01:00
6 changed files with 320 additions and 106 deletions
+27 -20
View File
@@ -455,11 +455,17 @@ scan and are enumerated concurrently: every operand seeds the shared walk worker
pool. Overlapping operands are harmless — an operand that duplicates another or
lies under another is dropped before walking, so every file is reached exactly
once and produces one database record. An operand under another one is kept,
though, and walked on its own when any directory from it (from its directory,
for a regular file) up to but not including the operand covering it is a default
exclude, is not on a persistent filesystem (see "Rules for the walk"), or, with
`-x`, is on a different filesystem than that operand, because that operand's
walk does not enter such a directory and so does not reach it.
though, when the walk of the outer operand does not reach it. It is then walked
on its own. The walk of the outer operand does not reach the inner one when it,
or any directory between the two, is:
- named `.zfs`;
- a symlink;
- a default exclude;
- not on a persistent filesystem (see "Rules for the walk");
- with `-x`, on a different filesystem than the outer operand.
For a regular-file operand, only the directories between the two count.
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
@@ -837,9 +843,9 @@ A scan has four phases (`[1/4] load`, `[2/4] walk`, `[3/4] hash`,
- `load` counts the records first, then reads them against that count. While the
count runs, the display says it is counting the records.
- `walk` has no exact total. Its estimate is the number of records the last scan
left under the operands, known once `load` is done and shown with a `~`. Once
the walk passes the estimate, or when there are no such records, as on a first
scan, the time remaining is unknown.
left under the operands, known once `load` is done and shown with a `~`. The
time remaining is unknown once the walk passes the estimate, and when there
are no such records, as on a first scan.
- `hash` and `update` have exact totals. Only files that actually need hashing
appear in the hash total, so its time remaining is meaningful.
@@ -913,8 +919,8 @@ Additional requirements:
- On a terminal, a phase with an exact total ends its line with its percent and
a bar 10 columns wide. This percent takes the place of the line's own. The bar
stays at the terminal's right edge, so it does not move as the line grows or
shrinks. Every phase has an exact total once it has counted what it will do,
except these:
shrinks. A phase with nothing to do shows no bar. Every phase has an exact
total once it has counted what it will do, except these:
- `scan`'s `walk`;
- the `open` phase of `report` and `trees`;
- both phases of `info`.
@@ -926,21 +932,22 @@ Additional requirements:
```
- On a terminal no line reaches the terminal's last column, however narrow the
terminal is. A line too wide for the terminal first loses its bar. Next it
loses whichever of these it shows: its rate, the words of a stalled phase, or
what the phase is waiting for. The other four parts are kept whole:
terminal is. A line too wide for the terminal first loses whichever of these
it shows: its rate, the words of a stalled phase, or what the phase is waiting
for. The other four parts are kept whole:
- the phase and its place;
- the progress;
- the elapsed time;
- the time remaining.
The line is cut short at the terminal's edge only when those four alone are
too wide, and then the end of the time remaining is lost first. That happens
hours into a scan of millions of files, on a terminal narrower than about 75
columns. On an 80-column terminal, the stalled line of the example above
keeps its words. The next draw fits the line to a terminal made narrower
while a phase runs. It erases the old line within the new width, and the
display goes on redrawing in place.
The line loses its bar only when those four and the bar do not fit. It is
cut short at the terminal's edge only when those four alone are too wide,
and then the end of the time remaining is lost first. That happens hours
into a scan of millions of files, on a terminal narrower than about 75
columns. On a terminal 100 columns wide, the stalled line of the example
above keeps its words and its bar. The next draw fits the line to a terminal
made narrower while a phase runs. It erases the old line within the new
width, and the display goes on redrawing in place.
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not emit ANSI
redraws: print the line the moment each phase starts, every 5 seconds while it
+9 -3
View File
@@ -29,15 +29,21 @@
# Completed Steps
- an operand under another is walked on its own when a directory between them,
the inner operand included, is named `.zfs` or is a symlink. The walk of the
outer operand does not reach it then. `scan` now records the files below it
and no longer deletes their records (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. The time remaining is
labelled `remain`, not `eta` (2026-10-26,
https://git.eeqj.de/sneak/sfdupes/issues/133)
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
+39 -29
View File
@@ -217,27 +217,13 @@ func (p *progress) draw() {
}
if p.width == nil {
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, true))
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, false))
return
}
width := p.width()
room := width - spinnerMargin
text := p.line(now, room, true)
// A phase with an exact total ends its text with its percent and bar
// when its whole line, without the percent the bar shows, fits beside
// them: the bar gives way before any part of the line. The line is
// padded to the room the bar leaves it, so the bar stays at the
// terminal's right edge however long the line is. A total of 0 leaves
// the bar nothing to fill.
if p.total > 0 && !p.estimate {
line := p.line(now, math.MaxInt, false)
if len(line) <= room-barColumns {
text = fmt.Sprintf("%-*s%s", room-barColumns, line, p.percentBar())
}
}
text := p.line(now, width-spinnerMargin, true)
// 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
@@ -442,20 +428,24 @@ func (p *progress) finish() {
//
// [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 bar, which shows its own. 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".
// 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".
//
// 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 leaves out the rate,
// the words of a stalled phase, or what the phase waits for; only if it
// is still wider is it cut at room. Every part is ASCII, so its length
// is its width in columns. p.mu must be held.
func (p *progress) line(now time.Time, room int, percent bool) string {
// 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
@@ -470,7 +460,7 @@ func (p *progress) line(now time.Time, room int, percent bool) string {
rate := perSecond(p.count-since.count, now.Sub(since.at))
average := perSecond(p.count, now.Sub(p.rateStart))
head = p.label + ": " + p.itemsDone(percent) + ", "
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))
@@ -479,6 +469,26 @@ func (p *progress) line(now time.Time, room int, percent bool) string {
}
}
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 {
+140 -49
View File
@@ -163,7 +163,7 @@ func TestProgressLine(t *testing.T) {
tc.p.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now
tc.p.drawn = []drawnCount{{at: ago}}
if got := tc.p.line(now, 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)
}
}
@@ -198,39 +198,40 @@ func TestLineFitsRoom(t *testing.T) {
}
cases := []struct {
p *progress
room int
percent bool
want string
p *progress
room int
bar bool
want string
}{{
p: hash, room: 60, percent: true,
p: hash, room: 60,
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
}, {
p: hash, room: 59, percent: true,
p: hash, room: 59,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
}, {
p: hash, room: 48, percent: true,
p: hash, room: 48,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
}, {
// Before the bar, which shows the percent.
p: hash, room: 60, percent: false,
want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s",
// 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: load, room: 43, percent: true,
p: load, room: 43,
want: "[1/4] load: elapsed 10s, remain unknown",
}, {
p: stalled, room: 68, percent: true,
p: stalled, room: 68,
want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"remain 3m20s",
}, {
p: stalled, room: 67, percent: true,
p: stalled, room: 67,
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
}}
for _, tc := range cases {
if got := tc.p.line(now, tc.room, tc.percent); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q",
tc.room, tc.percent, got, tc.want)
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)
}
}
}
@@ -256,14 +257,14 @@ func TestSetTotalStartsRate(t *testing.T) {
clock.Add(5 * time.Second)
want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown"
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
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, true); got != want {
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
t.Errorf("line = %q, want %q", got, want)
}
@@ -273,7 +274,7 @@ func TestSetTotalStartsRate(t *testing.T) {
}
want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s"
if got := p.line(clock.Now(), math.MaxInt, true); got != want {
if got := p.line(clock.Now(), math.MaxInt, false); got != want {
t.Errorf("line = %q, want %q", got, want)
}
}
@@ -595,31 +596,32 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
}
}
// TestTerminalStalledLines draws every phase's line on an 80-column
// terminal once the phase has stalled, its last item done 42 seconds
// before. At an ordinary scan's counts and times, 12345 of 45678 items
// done four and a half minutes in, the line shows where the phase waits
// whole, beside the phase, progress, elapsed time and time remaining.
// At a large scan's, 1234567 of 9876543 two and a half hours in, those
// four still show whole, as the words give way first where they do not
// fit.
// 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 as it starts and
// once more as it ends, with count of total items done elapsed into
// the phase, and returns what the terminal shows.
drawStalled := func(params progressParams, total, count int64,
elapsed time.Duration,
// 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 80 }, time.Hour,
p := startProgress(params, func() int { return width }, time.Hour,
clock.Now)
clock.Add(elapsed - 42*time.Second)
@@ -651,16 +653,19 @@ func TestTerminalStalledLines(t *testing.T) {
approx = "~"
}
shown := drawStalled(params, 45678, 12345,
// The percent is in the line, or beside the bar.
shown := drawStalled(params, 100, 45678, 12345,
4*time.Minute+30*time.Second)
want := params.label + ": [12345/" + approx + "45678] 27%, stalled " +
params.stalledOn + ", elapsed 4m30s, remain 12m9s"
if !strings.Contains(shown, want) {
t.Errorf("terminal shows %q, want %q", shown, want)
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, 9876543, 1234567, 150*time.Minute)
shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute)
for _, want := range []string{
params.label + ": [1234567/" + approx + "9876543] 12%, ",
", elapsed 2h30m0s, remain 17h30m0s",
@@ -738,9 +743,12 @@ func TestTerminalLinesStartWithSpinner(t *testing.T) {
// 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. Its bar must start the same number
// of columns from the terminal's right edge each time: a line that grows
// or shrinks, as its time remaining does, never moves the bar.
// 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) {
@@ -751,7 +759,8 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
widths := []int{110, 120}
counts := []int64{1234567, 9876543}
fromEdge := make([]int, 0, len(widths)*len(counts))
startFromEdge := make([]int, 0, len(widths)*len(counts))
endFromEdge := make([]int, 0, len(widths)*len(counts))
for _, width := range widths {
for _, count := range counts {
@@ -762,14 +771,96 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
}
fromEdge = append(fromEdge,
// 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(fromEdge) != slices.Max(fromEdge) {
if slices.Min(startFromEdge) != slices.Max(startFromEdge) {
t.Errorf("bar starts %v columns from the right edge, want the same "+
"every time", fromEdge)
"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
}
}
}
@@ -797,7 +888,7 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543,
},
90,
80,
},
}
+20 -5
View File
@@ -48,6 +48,11 @@ const sampleWindow = 1024 * 1024
// and hash worker pools.
const workQueueDepth = 1024
// zfsDirName is the name of the directory in which ZFS shows a
// filesystem's snapshots. The walk never enters one, since it would
// list every file once per snapshot.
const zfsDirName = ".zfs"
// errInterrupted reports a scan stopped by SIGINT or SIGTERM. runScan
// has already printed its line, so run prints nothing more.
var errInterrupted = errors.New("scan interrupted")
@@ -272,9 +277,10 @@ func pruneRoots(roots []string, oneFS bool, rules walkRules,
}
// walkReaches reports whether the walk of operand above reaches r, an
// operand under it. That walk does not enter a default exclude in
// rules, nor a directory that device puts on a filesystem in rules that
// is not persistent, nor, with -x, one that device puts on a different
// operand under it. That walk never follows a symlink. It does not
// enter a directory named .zfs, nor a default exclude in rules, nor a
// directory that device puts on a filesystem in rules that is not
// persistent, nor, with -x, one that device puts on a different
// filesystem than above, when device knows above's. It reaches every
// regular file in a directory it enters, so for a regular-file operand
// the directories checked start at its directory; they end below above.
@@ -295,6 +301,15 @@ func walkReaches(above, r string, oneFS bool, rules walkRules,
return false
}
if filepath.Base(dir) == zfsDirName {
return false
}
fi, err = os.Lstat(dir)
if err == nil && fi.Mode()&fs.ModeSymlink != 0 {
return false
}
dev, ok := device(dir)
if !ok {
continue
@@ -883,7 +898,7 @@ func operandWarning(root string, fi fs.FileInfo) string {
case mode.IsRegular():
return ""
case mode.IsDir():
if filepath.Base(root) != ".zfs" {
if filepath.Base(root) != zfsDirName {
return ""
}
@@ -1153,7 +1168,7 @@ func subdirJob(ctx context.Context, p string, e fs.DirEntry,
parent dirJob, oneFS bool, rules walkRules,
events chan<- walkEvent,
) (dirJob, bool) {
if e.Name() == ".zfs" {
if e.Name() == zfsDirName {
return dirJob{}, false
}
+85
View File
@@ -1672,6 +1672,81 @@ func TestSyncScanOverlappingRoots(t *testing.T) {
}
}
// TestScanWalksOperandInZFSDirectory checks that an operand inside a
// directory named .zfs below another operand is walked on its own,
// since the walk of the other operand does not enter .zfs. The file
// under it is recorded, and a second scan keeps the record.
func TestScanWalksOperandInZFSDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
db := openTestDB(t)
f := writeFile(t, dir, "pool/.zfs/snapshot/daily/f", pattern(1, 10))
daily := filepath.Dir(f)
syncTree(t, db, dir, daily)
recordByPath(t, dbRecords(t, db), f)
syncTree(t, db, dir, daily)
recordByPath(t, dbRecords(t, db), f)
}
// TestScanWalksOperandBehindSymlink checks that an operand reached
// through a symlinked directory below another operand is walked on its
// own, since the walk of the other operand does not follow the symlink.
// The file under it is recorded, and a second scan keeps the record.
func TestScanWalksOperandBehindSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
target := t.TempDir()
db := openTestDB(t)
writeFile(t, target, "sub/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(link, "sub")
f := filepath.Join(sub, "f")
syncTree(t, db, dir, sub)
recordByPath(t, dbRecords(t, db), f)
syncTree(t, db, dir, sub)
recordByPath(t, dbRecords(t, db), f)
}
// TestScanDropsOperandReachedBehindSymlink checks that an operand under
// another one is still dropped when both lie behind the same symlinked
// directory: the walk of the other operand starts past the symlink and
// reaches it, so the file under it is walked once.
func TestScanDropsOperandReachedBehindSymlink(t *testing.T) {
t.Parallel()
dir := t.TempDir()
target := t.TempDir()
db := openTestDB(t)
writeFile(t, target, "sub/deeper/f", pattern(1, 10))
link := filepath.Join(dir, "link")
err := os.Symlink(target, link)
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(link, "sub")
st := syncTree(t, db, sub, filepath.Join(sub, "deeper"))
if st != (scanStats{walked: 1, added: 1}) {
t.Fatalf("stats = %+v, want 1 walked and added", st)
}
}
// TestScanHashesUniqueSize checks that a file whose size no other file
// shares is hashed like every other file.
func TestScanHashesUniqueSize(t *testing.T) {
@@ -2085,6 +2160,16 @@ func TestPruneRoots(t *testing.T) {
t.Fatalf("pruneRoots with %s not persistent = %q, want %q",
pqRoot, got, want)
}
// Nor does it enter a directory named .zfs, so /p/.zfs/s is kept. The
// walk of /p/.zfs/s reaches /p/.zfs/s/t, which is dropped.
got = pruneRoots([]string{"/p", "/p/.zfs/s", "/p/.zfs/s/t"}, false,
walkRules{}, device)
want = []string{"/p", "/p/.zfs/s"}
if !slices.Equal(got, want) {
t.Fatalf("pruneRoots with .zfs = %q, want %q", got, want)
}
}
func TestReportsNeverTouchFilesystem(t *testing.T) {