1 Commits
Author SHA1 Message Date
sneak e80c6541d2 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. Any other error is 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. sfdupes draws that bar: the library shows no
spinner beside its own.

The time remaining is labelled remain, not eta.

Judgement call: an operand removed after the scan starts is passed over
silently too.
Deviation: README's 80-column promise for stalled lines is narrowed to
its example.

Model: opus-5-5
2026-10-26 04:06:11 +00:00
6 changed files with 130 additions and 374 deletions
+26 -36
View File
@@ -461,26 +461,17 @@ 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 `-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. walk does not enter such a directory and so does not reach it.
An operand is not scanned when it is: 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
- a symlink, which `scan` never follows, not even as an operand; a one-line warning naming the path and what it is, counts it as skipped, and
- a socket, FIFO or device node; drops it from the scanned operands before reading the database. Another operand
- at or inside a directory named `.zfs`. beneath it is still scanned. The records stored beneath it are not deleted: they
are treated like any other record outside the scanned operands. If it lies under
An operand reached through a symlink into a `.zfs` directory is not scanned another operand, they are under that operand instead, and are treated like any
either. other record there. This is not an error: a scan whose every operand is dropped
walks nothing and exits 0. An operand that is not on a persistent filesystem
This holds even when the operand lies under another operand. `scan` prints a (see "Rules for the walk") is dropped the same way. The line `scan` prints for
one-line warning naming the path and what it is, counts it as skipped, and drops it is not a warning, though, and it is not counted as skipped.
it from the scanned operands before reading the database. An operand beneath a
symlink operand is still scanned. The records stored beneath the dropped operand
are not deleted: they are treated like any other record outside the scanned
operands. If it lies under another operand, they are under that operand instead,
and are treated like any other record there. This is not an error: a scan whose
every operand is dropped walks nothing and exits 0. An operand that is not on a
persistent filesystem (see "Rules for the walk") is dropped the same way. The
line `scan` prints for it is not a warning, though, and it is not counted as
skipped.
`scan` synchronizes the database with the filesystem state under the scanned `scan` synchronizes the database with the filesystem state under the scanned
operands: operands:
@@ -846,9 +837,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 - `load` counts the records first, then reads them against that count. While the
count runs, the display says it is counting the records. 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 - `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 `~`. The left under the operands, known once `load` is done and shown with a `~`. Once
time remaining is unknown once the walk passes the estimate, and when there the walk passes the estimate, or when there are no such records, as on a first
are no such records, as on a first scan. scan, the time remaining is unknown.
- `hash` and `update` have exact totals. Only files that actually need hashing - `hash` and `update` have exact totals. Only files that actually need hashing
appear in the hash total, so its time remaining is meaningful. appear in the hash total, so its time remaining is meaningful.
@@ -922,8 +913,8 @@ Additional requirements:
- On a terminal, a phase with an exact total ends its line with its percent and - 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 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 stays at the terminal's right edge, so it does not move as the line grows or
shrinks. A phase with nothing to do shows no bar. Every phase has an exact shrinks. Every phase has an exact total once it has counted what it will do,
total once it has counted what it will do, except these: except these:
- `scan`'s `walk`; - `scan`'s `walk`;
- the `open` phase of `report` and `trees`; - the `open` phase of `report` and `trees`;
- both phases of `info`. - both phases of `info`.
@@ -935,22 +926,21 @@ Additional requirements:
``` ```
- On a terminal no line reaches the terminal's last column, however narrow the - 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 whichever of these terminal is. A line too wide for the terminal first loses its bar. Next it
it shows: its rate, the words of a stalled phase, or what the phase is waiting loses whichever of these it shows: its rate, the words of a stalled phase, or
for. The other four parts are kept whole: what the phase is waiting for. The other four parts are kept whole:
- the phase and its place; - the phase and its place;
- the progress; - the progress;
- the elapsed time; - the elapsed time;
- the time remaining. - the time remaining.
The line loses its bar only when those four and the bar do not fit. It is The line is cut short at the terminal's edge only when those four alone are
cut short at the terminal's edge only when those four alone are too wide, too wide, and then the end of the time remaining is lost first. That happens
and then the end of the time remaining is lost first. That happens hours hours into a scan of millions of files, on a terminal narrower than about 75
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
columns. On a terminal 100 columns wide, the stalled line of the example keeps its words. The next draw fits the line to a terminal made narrower
above keeps its words and its bar. The next draw fits the line to a terminal while a phase runs. It erases the old line within the new width, and the
made narrower while a phase runs. It erases the old line within the new display goes on redrawing in place.
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 - 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 redraws: print the line the moment each phase starts, every 5 seconds while it
+3 -8
View File
@@ -29,20 +29,15 @@
# Completed Steps # Completed Steps
- 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 - 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 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 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 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 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, 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 which no longer moves as the line's length changes. The time remaining is
terminal now loses its rate before its bar. The time remaining is labelled labelled `remain`, not `eta` (2026-10-26,
`remain`, not `eta` (2026-10-26, https://git.eeqj.de/sneak/sfdupes/issues/133) https://git.eeqj.de/sneak/sfdupes/issues/133)
- the `head`, `tail` and `content` columns are BLOBs, each holding the raw 32 - 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 bytes of a SHA-256, half the size of the hex text they held. The hashes stay
+11 -123
View File
@@ -928,21 +928,24 @@ func TestRunScanWalksOperandUnderSymlinkOperand(t *testing.T) {
} }
func TestRunScanSkipsZFSOperand(t *testing.T) { func TestRunScanSkipsZFSOperand(t *testing.T) {
zfs := filepath.Join(t.TempDir(), ".zfs") path := testDBPath(t)
f := writeFile(t, zfs, "snapshot/hourly/f", pattern(1, 10))
// 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) t.Setenv(databaseEnv, path)
zfs := filepath.Join(t.TempDir(), ".zfs")
snapshot := filepath.Join(zfs, "snapshot", "hourly")
f := writeFile(t, snapshot, "f", pattern(1, 10))
// An operand beneath a .zfs directory is walked, because it is not
// itself named .zfs.
scanOK(t, snapshot)
assertOperandSkipped(t, path, zfs, ".zfs directory", f) assertOperandSkipped(t, path, zfs, ".zfs directory", f)
} }
// assertOperandSkipped scans operand alone and checks that it is skipped // 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 // as kind: a warning naming it, one skip in the summary, exit 0, and the
// record for kept, stored beneath operand before this scan, still in the // record for kept, which an earlier scan stored beneath operand, still
// database at dbPath. // in the database at dbPath.
func assertOperandSkipped(t *testing.T, dbPath, operand, kind, func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
kept string, kept string,
) { ) {
@@ -971,121 +974,6 @@ func assertOperandSkipped(t *testing.T, dbPath, operand, kind,
recordByPath(t, dbRecords(t, db), kept) 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) { func TestRunReportSucceeds(t *testing.T) {
path := testDBPath(t) path := testDBPath(t)
t.Setenv(databaseEnv, path) t.Setenv(databaseEnv, path)
+29 -39
View File
@@ -217,13 +217,27 @@ func (p *progress) draw() {
} }
if p.width == nil { if p.width == nil {
fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, false)) fmt.Fprintln(os.Stderr, p.line(now, math.MaxInt, true))
return return
} }
width := p.width() width := p.width()
text := p.line(now, width-spinnerMargin, true) 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())
}
}
// Before each draw the library erases its line with as many spaces as // 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 // the widest line it has drawn. A display gets a new one when the
@@ -428,24 +442,20 @@ func (p *progress) finish() {
// //
// [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s // [3/4] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, remain 13s
// //
// where "files" is the phase's unit. A phase with no total shows the // where "files" is the phase's unit, without the percent when percent
// items done instead, or what it waits for. The rate is that since the // is false, as before the bar, which shows its own. A phase with no
// first draw in drawn, and the time remaining comes from the rate since // total shows the items done instead, or what it waits for. The rate is
// the count started. Once no item has been completed for stallTime, the // that since the first draw in drawn, and the time remaining comes from
// rate, which would read 0, gives way to the word "stalled" and where // the rate since the count started. Once no item has been completed for
// the phase waits, as "stalled disk/db". // 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 // 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 // remaining are kept whole. A line wider than room leaves out the rate,
// rate, the words of a stalled phase, or what the phase waits for. Next // the words of a stalled phase, or what the phase waits for; only if it
// it leaves out the bar. Only if it is still wider is it cut at room. // is still wider is it cut at room. Every part is ASCII, so its length
// Every part but the bar is ASCII, so its length is its width in // is its width in columns. p.mu must be held.
// columns. p.mu must be held. func (p *progress) line(now time.Time, room int, percent bool) string {
func (p *progress) line(now time.Time, room int, bar bool) string {
elapsed := now.Sub(p.start).Round(time.Second) elapsed := now.Sub(p.start).Round(time.Second)
// head and tail hold the parts kept whole; extra, between them, is // head and tail hold the parts kept whole; extra, between them, is
@@ -460,7 +470,7 @@ func (p *progress) line(now time.Time, room int, bar bool) string {
rate := perSecond(p.count-since.count, now.Sub(since.at)) rate := perSecond(p.count-since.count, now.Sub(since.at))
average := perSecond(p.count, now.Sub(p.rateStart)) average := perSecond(p.count, now.Sub(p.rateStart))
head = p.label + ": " + p.itemsDone(true) + ", " head = p.label + ": " + p.itemsDone(percent) + ", "
extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit) extra = fmt.Sprintf("%.0f %s/s, ", rate, p.unit)
tail = fmt.Sprintf("elapsed %s, remain %s", elapsed, p.remain(average)) tail = fmt.Sprintf("elapsed %s, remain %s", elapsed, p.remain(average))
@@ -469,26 +479,6 @@ func (p *progress) line(now time.Time, room int, bar 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 full, kept := head+extra+tail, head+tail
switch { switch {
+49 -140
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.start, tc.p.rateStart, tc.p.lastItem = ago, ago, now
tc.p.drawn = []drawnCount{{at: ago}} tc.p.drawn = []drawnCount{{at: ago}}
if got := tc.p.line(now, math.MaxInt, false); got != tc.want { if got := tc.p.line(now, math.MaxInt, true); got != tc.want {
t.Errorf("line = %q, want %q", got, tc.want) t.Errorf("line = %q, want %q", got, tc.want)
} }
} }
@@ -198,40 +198,39 @@ func TestLineFitsRoom(t *testing.T) {
} }
cases := []struct { cases := []struct {
p *progress p *progress
room int room int
bar bool percent bool
want string want string
}{{ }{{
p: hash, room: 60, p: hash, room: 60, percent: true,
want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s", want: "[3/4] hash: [20/100] 20%, 2 files/s, elapsed 10s, remain 40s",
}, { }, {
p: hash, room: 59, p: hash, room: 59, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s", want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40s",
}, { }, {
p: hash, room: 48, p: hash, room: 48, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40", want: "[3/4] hash: [20/100] 20%, elapsed 10s, remain 40",
}, { }, {
// The bar shows the percent. // Before the bar, which shows the percent.
p: hash, room: 74, bar: true, p: hash, room: 60, percent: false,
want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s" + want: "[3/4] hash: [20/100], 2 files/s, elapsed 10s, remain 40s",
" 20% |██ |",
}, { }, {
p: load, room: 43, p: load, room: 43, percent: true,
want: "[1/4] load: elapsed 10s, remain unknown", want: "[1/4] load: elapsed 10s, remain unknown",
}, { }, {
p: stalled, room: 68, p: stalled, room: 68, percent: true,
want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " + want: "[3/4] hash: [20/100] 20%, stalled disk/db, elapsed 50s, " +
"remain 3m20s", "remain 3m20s",
}, { }, {
p: stalled, room: 67, p: stalled, room: 67, percent: true,
want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s", want: "[3/4] hash: [20/100] 20%, elapsed 50s, remain 3m20s",
}} }}
for _, tc := range cases { for _, tc := range cases {
if got := tc.p.line(now, tc.room, tc.bar); got != tc.want { if got := tc.p.line(now, tc.room, tc.percent); got != tc.want {
t.Errorf("line(%d, %t) = %q, want %q", tc.room, tc.bar, got, t.Errorf("line(%d, %t) = %q, want %q",
tc.want) tc.room, tc.percent, got, tc.want)
} }
} }
} }
@@ -257,14 +256,14 @@ func TestSetTotalStartsRate(t *testing.T) {
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want := "[1/4] load: [0/100] 0%, 0 files/s, elapsed 25s, remain unknown" 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 { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
clock.Add(5 * time.Second) clock.Add(5 * time.Second)
want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown" want = "[1/4] load: [0/100] 0%, stalled db, elapsed 30s, remain unknown"
if got := p.line(clock.Now(), math.MaxInt, false); got != want { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
@@ -274,7 +273,7 @@ func TestSetTotalStartsRate(t *testing.T) {
} }
want = "[1/4] load: [20/100] 20%, 2 files/s, elapsed 30s, remain 40s" 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 { if got := p.line(clock.Now(), math.MaxInt, true); got != want {
t.Errorf("line = %q, want %q", got, want) t.Errorf("line = %q, want %q", got, want)
} }
} }
@@ -596,32 +595,31 @@ func TestTerminalLinesKeepTimeRemaining(t *testing.T) {
} }
} }
// TestTerminalStalledLines draws every phase's line once the phase has // TestTerminalStalledLines draws every phase's line on an 80-column
// stalled, its last item done 42 seconds before. At an ordinary scan's // terminal once the phase has stalled, its last item done 42 seconds
// counts and times, 12345 of 45678 items done four and a half minutes // before. At an ordinary scan's counts and times, 12345 of 45678 items
// in, on a terminal 100 columns wide, the line shows where the phase // done four and a half minutes in, the line shows where the phase waits
// waits whole, beside the phase, progress, elapsed time and time // whole, beside the phase, progress, elapsed time and time remaining.
// remaining. At a large scan's, 1234567 of 9876543 two and a half hours // At a large scan's, 1234567 of 9876543 two and a half hours in, those
// in, on an 80-column terminal, those four still show whole, as the // four still show whole, as the words give way first where they do not
// words give way first where they do not fit. // fit.
// //
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalStalledLines(t *testing.T) { func TestTerminalStalledLines(t *testing.T) {
stderr := captureStderr(t) stderr := captureStderr(t)
// drawStalled draws the display params describes on a terminal width // drawStalled draws the display params describes as it starts and
// columns wide as it starts and once more as it ends, with count of // once more as it ends, with count of total items done elapsed into
// total items done elapsed into the phase, and returns what the // the phase, and returns what the terminal shows.
// terminal shows. drawStalled := func(params progressParams, total, count int64,
drawStalled := func(params progressParams, width int, total, elapsed time.Duration,
count int64, elapsed time.Duration,
) string { ) string {
before := len(stderr()) before := len(stderr())
clock := &testClock{now: time.Now()} clock := &testClock{now: time.Now()}
params.total = total params.total = total
// No timed draws come between the two. // No timed draws come between the two.
p := startProgress(params, func() int { return width }, time.Hour, p := startProgress(params, func() int { return 80 }, time.Hour,
clock.Now) clock.Now)
clock.Add(elapsed - 42*time.Second) clock.Add(elapsed - 42*time.Second)
@@ -653,19 +651,16 @@ func TestTerminalStalledLines(t *testing.T) {
approx = "~" approx = "~"
} }
// The percent is in the line, or beside the bar. shown := drawStalled(params, 45678, 12345,
shown := drawStalled(params, 100, 45678, 12345,
4*time.Minute+30*time.Second) 4*time.Minute+30*time.Second)
for _, want := range []string{ want := params.label + ": [12345/" + approx + "45678] 27%, stalled " +
params.label + ": [12345/" + approx + "45678]", params.stalledOn + ", elapsed 4m30s, remain 12m9s"
", stalled " + params.stalledOn + ", elapsed 4m30s, remain 12m9s",
} { if !strings.Contains(shown, want) {
if !strings.Contains(shown, want) { t.Errorf("terminal shows %q, want %q", shown, want)
t.Errorf("terminal shows %q, want %q", shown, want)
}
} }
shown = drawStalled(params, 80, 9876543, 1234567, 150*time.Minute) shown = drawStalled(params, 9876543, 1234567, 150*time.Minute)
for _, want := range []string{ for _, want := range []string{
params.label + ": [1234567/" + approx + "9876543] 12%, ", params.label + ": [1234567/" + approx + "9876543] 12%, ",
", elapsed 2h30m0s, remain 17h30m0s", ", elapsed 2h30m0s, remain 17h30m0s",
@@ -743,12 +738,9 @@ func TestTerminalLinesStartWithSpinner(t *testing.T) {
// TestTerminalBarStaysAtRightEdge draws the hash phase's display two // TestTerminalBarStaysAtRightEdge draws the hash phase's display two
// and a half hours in, at two counts whose lines differ in length, on // 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 // terminals 110 and 120 columns wide. Its bar must start the same number
// first count and full at the second. It must start the same number of // of columns from the terminal's right edge each time: a line that grows
// columns from the terminal's right edge each time, and end the same // or shrinks, as its time remaining does, never moves the bar.
// 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 //nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalBarStaysAtRightEdge(t *testing.T) { func TestTerminalBarStaysAtRightEdge(t *testing.T) {
@@ -759,8 +751,7 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
widths := []int{110, 120} widths := []int{110, 120}
counts := []int64{1234567, 9876543} counts := []int64{1234567, 9876543}
startFromEdge := make([]int, 0, len(widths)*len(counts)) fromEdge := make([]int, 0, len(widths)*len(counts))
endFromEdge := make([]int, 0, len(widths)*len(counts))
for _, width := range widths { for _, width := range widths {
for _, count := range counts { for _, count := range counts {
@@ -771,96 +762,14 @@ func TestTerminalBarStaysAtRightEdge(t *testing.T) {
t.Fatalf("terminal %d wide shows %q, want a bar", width, shown) t.Fatalf("terminal %d wide shows %q, want a bar", width, shown)
} }
// The bar ends with the last "|". fromEdge = append(fromEdge,
throughBar := shown[:strings.LastIndex(shown, "|")+1]
startFromEdge = append(startFromEdge,
width-utf8.RuneCountInString(beforeBar)) width-utf8.RuneCountInString(beforeBar))
endFromEdge = append(endFromEdge,
width-utf8.RuneCountInString(throughBar))
} }
} }
if slices.Min(startFromEdge) != slices.Max(startFromEdge) { if slices.Min(fromEdge) != slices.Max(fromEdge) {
t.Errorf("bar starts %v columns from the right edge, want the same "+ t.Errorf("bar starts %v columns from the right edge, want the same "+
"every time", startFromEdge) "every time", fromEdge)
}
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
}
} }
} }
@@ -888,7 +797,7 @@ func TestTerminalNarrowedRedrawsInPlace(t *testing.T) {
progressParams{ progressParams{
label: scanPhase("hash"), unit: scanUnit, total: 9876543, label: scanPhase("hash"), unit: scanUnit, total: 9876543,
}, },
80, 90,
}, },
} }
+12 -28
View File
@@ -383,8 +383,7 @@ func (s *scanState) runPhases(ctx context.Context, roots []string,
) error { ) error {
// Types and filesystems are checked before pruning so that an // Types and filesystems are checked before pruning so that an
// operand under a dropped one is still scanned, not dropped as lying // operand under a dropped one is still scanned, not dropped as lying
// under it, and so that an operand inside a .zfs directory gets its // under it.
// warning even when it lies under another operand.
roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules, roots = pruneRoots(s.walkableRoots(roots, rules), oneFS, rules,
deviceOfPath) deviceOfPath)
@@ -412,15 +411,15 @@ 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, none of them inside a // files, and directories not named .zfs. Every other operand is warned
// directory named .zfs. Every other operand is warned about, counted as // about, counted as skipped, and dropped. A dropped operand is no
// skipped, and dropped. A dropped operand is no longer a root, so the // longer a root, so the records stored beneath it are treated as if it
// records stored beneath it are treated as if it had not been given: // had not been given: those under no other root count as outside the
// those under no other root count as outside the roots and are not // roots and are not deleted as unverified. An operand on a filesystem
// deleted as unverified. An operand on a filesystem in rules that is not // in rules that is not persistent is dropped the same way, with a line
// persistent is dropped the same way, with a line saying so that is not // saying so that is not a warning, and is not counted as skipped. An
// a warning, and is not counted as skipped. An operand that fails lstat // operand that fails lstat here is kept, and the walk warns about it
// here is kept, and the walk warns about it unless it no longer exists. // unless it no longer exists.
func (s *scanState) walkableRoots(roots []string, rules walkRules) []string { func (s *scanState) walkableRoots(roots []string, rules walkRules) []string {
kept := make([]string, 0, len(roots)) kept := make([]string, 0, len(roots))
@@ -875,24 +874,9 @@ func sendEvent(ctx context.Context, events chan<- walkEvent,
// operandWarning returns the one-line warning for an operand the walk // operandWarning returns the one-line warning for an operand the walk
// does not start from, naming the path and what it is, or "" for one it // does not start from, naming the path and what it is, or "" for one it
// does: a regular file, or a directory not named .zfs, that is not // does: a regular file, or a directory not named .zfs. Symlinks are
// inside a directory named .zfs, neither as given nor with its symlinks // never followed, including as operands.
// resolved. Symlinks are never followed, including as operands.
func operandWarning(root string, fi fs.FileInfo) string { func operandWarning(root string, fi fs.FileInfo) string {
// The walk never enters a .zfs directory, so it starts from nothing
// inside one either, not even through a symlink. An operand whose
// symlinks cannot be resolved is checked as given.
resolved, err := filepath.EvalSymlinks(root)
if err != nil {
resolved = root
}
if slices.Contains(strings.Split(filepath.Dir(root), "/"), ".zfs") ||
slices.Contains(strings.Split(filepath.Dir(resolved), "/"), ".zfs") {
return fmt.Sprintf("walk %s: skipping operand inside a .zfs directory",
root)
}
var kind string var kind string
switch mode := fi.Mode(); { switch mode := fi.Mode(); {