1 Commits
Author SHA1 Message Date
clawbot a04601f010 scan: show each phase as N of M, with progress, elapsed time and ETA, also while it waits (closes #112)
check / check (push) Canceled after 0s
Every scan display now starts with its phase and its place among the
five, as "[2/5] walk", and shows its progress, rate, elapsed time and
ETA. On a terminal hash, update and the content reads keep the
library's bar, which fills what the rest of the line leaves; load,
the walk and the content checks are a spinner whose text is cut to
the terminal's width. load counts the records first. The content
phase adds up the sizes of the matching groups, without the join and
sort, and says it is searching until the first record returns. The
walk's total is estimated from the records under the operands. Each
display is drawn from its own goroutine: several times a second on a
terminal, every 5 seconds in plain mode.

Judgement call: load and the content checks stay spinners once their totals are known.

Model: opus-5-5
2026-10-16 14:50:03 +00:00
7 changed files with 369 additions and 154 deletions
+17 -8
View File
@@ -731,10 +731,12 @@ showing its progress, its elapsed time and its estimated time remaining (ETA):
scan, the ETA is unknown.
- `hash` and `update` have exact totals. Only files that actually need hashing
appear in the hash total, so its ETA is meaningful.
- `content` counts the records whose files it checks with `lstat`, saying it is
searching the database while SQLite finds them, then checks them against that
count. The files to read then get a display of their own, with their count as
its total.
- `content` first counts the records whose files it checks with `lstat`, which
SQLite does by adding up the number of records with each matching size,
`head`, and `tail`, without fetching the records. It then says it is searching
the database until SQLite returns the first record; the checks, and their rate
and ETA, start there. The files to read then get a display of their own, with
their count as its total.
Each display is one line holding:
@@ -748,15 +750,22 @@ Each display is one line holding:
Example shape (exact layout is flexible, content is not):
```
[3/5] hash: [12345/98765] 12%, 92 files/s, elapsed 2m32s, eta 17m54s
[3/5] hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
[2/5] walk: [1234567/~1200000] 103%, 5120 files/s, elapsed 4m1s, eta unknown
```
Additional requirements:
- On a terminal the progress-bar library draws the line behind a spinner, and
draws it again several times a second, so the elapsed time and ETA stay
current while a phase waits for its next item.
- On a terminal, a phase with an exact total from its start (`hash`, `update`,
and the content phase's reads) is drawn as the progress-bar library's bar,
with the phase and its place before it and the library's counts, rate, elapsed
time and ETA after it. Any other phase (`load`, `walk`, and the content
phase's checks) is drawn as the library's spinner followed by its line. Each
display is drawn again several times a second, so its elapsed time keeps
moving while a phase waits for its next item.
- On a terminal every line fits the terminal's width: the bar takes the room the
rest of its line leaves, and a spinner's line is cut short where it would
reach the terminal's last column.
- 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
runs (whether or not an item has completed, and never more often), and once
+8 -5
View File
@@ -32,11 +32,14 @@
- each `scan` phase's progress display names the phase and its place among the
five, as `[2/5] walk`, from the moment it starts, with its progress, elapsed
time and ETA: `load` counts the records first, the walk's total is estimated
from the records under the operands, and the content phase says it is
searching the database before it checks the files it found against their
count. On a terminal the line is drawn again several times a second; in plain
mode it is printed every 5 seconds whether or not an item completed
(2026-10-16, https://git.eeqj.de/sneak/sfdupes/issues/112)
from the records under the operands, and the content phase counts the records
it checks from SQLite's grouping alone, then says it is searching the database
until the first one is returned. On a terminal `hash`, `update` and the
content phase's reads keep the library's bar and the other displays are a
spinner followed by the line; every display is drawn again several times a
second, and every line fits the terminal's width. In plain mode the line is
printed every 5 seconds whether or not an item completed (2026-10-16,
https://git.eeqj.de/sneak/sfdupes/issues/112)
- `scan`, `report` and `trees` first print a line on stderr naming the database
they use, as an absolute path, and whether that path is the default or comes
+23 -17
View File
@@ -564,35 +564,41 @@ func loadFileMeta(ctx context.Context, db *sql.DB,
return nil
}
// contentCandidatesSQL selects every record of at least headTailMin
// bytes whose size, head, and tail equal another record's, in each
// group (the records sharing a size, head, and tail) where at least one
// record has no content hash, with whether each record has one. SQLite
// does the grouping, so no other record's hashes are loaded into
// memory; the rows come ordered by size, head, and tail, so each
// group's rows arrive together.
// contentGroupsSQL selects each group (the records sharing a size,
// head, and tail) of records of at least headTailMin bytes that has more
// than one record and at least one record without a content hash, with
// the number of its records.
const contentGroupsSQL = `
SELECT size, head, tail, COUNT(*) AS records
FROM files
WHERE size >= ? AND head <> ''
GROUP BY size, head, tail
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
`
// contentCandidatesSQL selects every record of the groups
// contentGroupsSQL selects, with whether each record has a content
// hash. SQLite does the grouping, so no other record's hashes are
// loaded into memory; the rows come ordered by size, head, and tail, so
// each group's rows arrive together.
const contentCandidatesSQL = `
SELECT f.path, f.size, f.mtime, f.mtime_nsec, f.head, f.tail, f.content <> ''
FROM files AS f
JOIN (
SELECT size, head, tail
FROM files
WHERE size >= ? AND head <> ''
GROUP BY size, head, tail
HAVING COUNT(*) > 1 AND SUM(content = '') > 0
) AS g USING (size, head, tail)
JOIN (` + contentGroupsSQL + `) AS g USING (size, head, tail)
ORDER BY size, head, tail
`
// countContentCandidates returns the number of rows of
// contentCandidatesSQL.
// contentCandidatesSQL, adding up the records of the groups
// contentGroupsSQL selects, so it neither joins nor sorts.
func countContentCandidates(ctx context.Context, db *sql.DB) (int64, error) {
var n int64
err := db.QueryRowContext(ctx,
"SELECT COUNT(*) FROM ("+contentCandidatesSQL+")", headTailMin).Scan(&n)
"SELECT COALESCE(SUM(records), 0) FROM ("+contentGroupsSQL+")",
headTailMin).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count records: %w", err)
return 0, fmt.Errorf("count content candidates: %w", err)
}
return n, nil
+59
View File
@@ -385,3 +385,62 @@ func TestApplyChangesBatching(t *testing.T) {
t.Fatalf("records = %d, want 0", len(got))
}
}
// TestCountContentCandidates checks that countContentCandidates counts
// the records the content phase's search returns: those of each group
// of two or more records of headTailMin or more with a head, where at
// least one has no content hash.
func TestCountContentCandidates(t *testing.T) {
t.Parallel()
db := openTestDB(t)
// Each group's records: size, head, and content, one per record.
groups := []struct {
size int64
head string
contents []string
}{
{headTailMin, "h", []string{"", "c", "c"}}, // returned
{headTailMin + 1, "h", []string{"", ""}}, // returned
{headTailMin + 2, "h", []string{"c", "c"}}, // all hashed
{headTailMin + 3, "h", []string{""}}, // one record
{headTailMin - 1, "h", []string{"", ""}}, // too small
{headTailMin + 4, "", []string{"", ""}}, // no head
}
var recs []scanRec
for _, g := range groups {
for _, content := range g.contents {
recs = append(recs, scanRec{
size: g.size, mtime: time.Unix(1, 0), head: g.head,
tail: "t", content: content,
path: fmt.Sprintf("/r/%d", len(recs)),
})
}
}
err := applyChanges(t.Context(), db, recs, nil, nil)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
got, err := countContentCandidates(t.Context(), db)
if err != nil {
t.Fatalf("countContentCandidates: %v", err)
}
var returned int64
err = loadContentCandidates(t.Context(), db,
func(scanRec, bool) { returned++ })
if err != nil {
t.Fatalf("loadContentCandidates: %v", err)
}
if got != 5 || returned != 5 {
t.Errorf("count = %d, records returned = %d, want 5 and 5",
got, returned)
}
}
+108 -45
View File
@@ -25,11 +25,33 @@ const spinnerType = 14
// percentScale converts a fraction to a percentage.
const percentScale = 100
// fallbackWidth is the width the progressbar library takes a terminal
// to have when it cannot read it, as when stderr is not a terminal.
const fallbackWidth = 80
// spinnerMargin is the columns of a spinner's line besides its text:
// the spinner and the three spaces the library draws around the text,
// and the terminal's last column, which the line stays clear of, as the
// library's bars do.
const spinnerMargin = 5
// stderrIsTTY reports whether stderr is attached to a terminal.
func stderrIsTTY() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// terminalWidth returns the width of the terminal on stderr, or
// fallbackWidth, as the progressbar library does, when it cannot read
// one.
func terminalWidth() int {
width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil || width <= 0 {
return fallbackWidth
}
return width
}
// phaseLabel names phase with its place among phases, as "[2/5] walk".
func phaseLabel(phases []string, phase string) string {
return fmt.Sprintf("[%d/%d] %s",
@@ -44,24 +66,28 @@ type progressParams struct {
waitingFor string // what the phase waits for until setTotal is called
}
// progress renders one phase's progress on stderr as one line (see
// line): the phase and its place among the phases, the items done out
// of the total, the rate, the elapsed time and the estimated time
// remaining. On a TTY the progressbar library draws the line behind a
// spinner, again every redrawInterval. When stderr is not a TTY it
// emits no ANSI redraws: it prints the line as the phase starts, every
// plainInterval while it runs, and as it ends. Either way the display
// shows the phase is alive while it waits for its next item.
// progress renders one phase's progress on stderr. On a TTY the
// progressbar library draws it. A phase with an exact total from its
// start gets the library's bar, with the phase and its place as the
// bar's description and the library's counts, rate, elapsed time and
// ETA beside it; the bar takes the room the rest leaves on the line. Any
// other phase gets the library's spinner, with line as its text, cut to
// the terminal's width. Either is drawn again every redrawInterval, so
// its elapsed time keeps moving while the phase waits for its next
// item. When stderr is not a TTY it emits no ANSI redraws: it prints
// line as the phase starts, every plainInterval while it runs, and as it
// ends.
//
// The redraws come from a goroutine of their own, so mu guards what
// they read. Every method must be called from the main goroutine. A
// nil *progress is a valid no-display receiver: every method is a
// no-op, so batched database flushes during the hash phase can reuse
// the update phase's helpers without rendering anything.
// The draws come from a goroutine of their own, so mu guards the fields
// below it and every call on bar. Every method must be called from the
// main goroutine. A nil *progress is a valid no-display receiver: every
// method is a no-op, so batched database flushes during the hash phase
// can reuse the update phase's helpers without rendering anything.
type progress struct {
bar *progressbar.ProgressBar // nil when stderr is not a TTY
stop chan struct{} // closed by finish to end the redraws
done chan struct{} // closed once the redraws have ended
bar *progressbar.ProgressBar // nil when stderr is not a TTY
spinner bool // bar is a spinner, with line as its text
stop chan struct{} // closed by finish to end the draws
done chan struct{} // closed once the draws have ended
mu sync.Mutex
label string
@@ -69,6 +95,7 @@ type progress struct {
estimate bool
waitingFor string
count int64
drawn int64 // the items bar has been given, unless a spinner
start time.Time // when the phase started, for the elapsed time
rateStart time.Time // when the count started, for the rate
}
@@ -76,24 +103,33 @@ type progress struct {
// newProgress starts the display of one phase on stderr.
func newProgress(params progressParams) *progress {
if stderrIsTTY() {
return startProgress(params, newSpinner(), redrawInterval)
return startProgress(params, true, redrawInterval)
}
return startProgress(params, nil, plainInterval)
return startProgress(params, false, plainInterval)
}
// startProgress shows the phase params describes at once, on bar, or as
// a plain line when bar is nil, and again every interval until finish.
func startProgress(params progressParams, bar *progressbar.ProgressBar,
// startProgress shows the phase params describes at once, with the
// progressbar library when onTTY is set and as a plain line when not,
// and again every interval until finish.
func startProgress(params progressParams, onTTY bool,
interval time.Duration,
) *progress {
now := time.Now()
p := &progress{
bar: bar, stop: make(chan struct{}), done: make(chan struct{}),
stop: make(chan struct{}), done: make(chan struct{}),
label: params.label, total: params.total, estimate: params.estimate,
waitingFor: params.waitingFor, start: now, rateStart: now,
}
switch {
case onTTY && (params.total < 0 || params.estimate):
// The library's bar needs an exact total.
p.bar, p.spinner = newSpinner(), true
case onTTY:
p.bar = newBar(params.label, params.total)
}
// Show the zero state at once: the first item may take minutes,
// and a phase must never look hung.
p.draw()
@@ -117,8 +153,26 @@ func startProgress(params progressParams, bar *progressbar.ProgressBar,
return p
}
// newSpinner builds the TTY display for newProgress: the library's
// spinner, followed by the text draw gives it.
// newBar builds the library's bar for a phase with an exact total, with
// label as its description and the library's counts, rate, elapsed time
// and ETA beside it. The bar is as wide as the rest of the line leaves
// room for on the terminal.
func newBar(label string, total int64) *progressbar.ProgressBar {
return progressbar.NewOptions64(total,
progressbar.OptionSetWriter(os.Stderr),
progressbar.OptionSetDescription(label),
progressbar.OptionShowCount(),
progressbar.OptionShowIts(),
progressbar.OptionSetItsString("files"),
progressbar.OptionSetElapsedTime(true),
progressbar.OptionSetPredictTime(true),
progressbar.OptionShowElapsedTimeOnFinish(),
progressbar.OptionFullWidth(),
)
}
// newSpinner builds the library's spinner for a phase without an exact
// total, followed by the text draw gives it.
func newSpinner() *progressbar.ProgressBar {
return progressbar.NewOptions64(-1,
progressbar.OptionSetWriter(os.Stderr),
@@ -131,19 +185,33 @@ func newSpinner() *progressbar.ProgressBar {
)
}
// draw shows the phase's line: on a TTY in place of the one before, and
// otherwise as a plain line.
// draw shows the phase's progress: on a TTY in place of the line
// before, and otherwise as a plain line.
func (p *progress) draw() {
p.mu.Lock()
defer p.mu.Unlock()
if p.bar == nil {
switch {
case p.bar == nil:
fmt.Fprintln(os.Stderr, p.line())
case p.spinner:
text := p.line()
if room := terminalWidth() - spinnerMargin; len(text) > room {
text = text[:max(room, 0)]
}
return
p.bar.Describe(text)
case p.count > p.drawn:
// The library measures its rate over the items it is given,
// so it is given items only when there are new ones: an
// empty add would count a wait as a rate of zero.
_ = p.bar.Add64(p.count - p.drawn)
p.drawn = p.count
default:
// No new items: the bar is drawn again all the same, with
// its elapsed time as of now.
p.bar.Describe(p.label)
}
p.bar.Describe(p.line())
}
// setTotal gives a phase that started without a total its total, once
@@ -187,21 +255,18 @@ func (p *progress) warnf(format string, args ...any) {
p.mu.Lock()
defer p.mu.Unlock()
if p.bar == nil {
fmt.Fprintln(os.Stderr, msg)
return
if p.bar != nil {
// The warning takes the display's line; the next draw shows
// the display again, on the line below.
_ = p.bar.Clear()
}
// A direct write could land inside the line on the TTY. The bar
// prints the warning on a line of its own, just before its next
// draw.
_, _ = progressbar.Bprintln(p.bar, msg)
fmt.Fprintln(os.Stderr, msg)
}
// finish ends the phase's display with its line as the phase ends. A
// phase stopped short of its total, as an interrupted one is, shows the
// count it stopped at.
// finish ends the phase's display, drawn once more as the phase ends.
// A phase stopped short of its total, as an interrupted one is, shows
// the count it stopped at rather than a full bar.
func (p *progress) finish() {
if p == nil {
return
@@ -213,10 +278,8 @@ func (p *progress) finish() {
p.draw()
if p.bar != nil {
// Finish draws the line once more, below any warning it holds,
// and leaves it on the TTY.
_ = p.bar.Finish()
// The display stays on the TTY as drawn; what follows starts
// on a line of its own.
fmt.Fprintln(os.Stderr)
}
}
+135 -72
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
)
// testRedraw is the redraw interval the tests give a display, so that
@@ -119,24 +120,49 @@ func TestProgressLine(t *testing.T) {
}
}
// TestSetTotalStartsRate checks that a phase's rate, and so its ETA,
// count from when its total became known, not from when it started
// waiting for it.
// TestSetTotalStartsRate checks that setTotal starts a phase's rate,
// and so its ETA, from when the total becomes known, not from when the
// phase started waiting for it.
func TestSetTotalStartsRate(t *testing.T) {
t.Parallel()
p := progress{
label: scanPhase("load"), total: 100, count: 20,
start: time.Now().Add(-20 * time.Second),
rateStart: time.Now().Add(-10 * time.Second),
waited := time.Now().Add(-20 * time.Second)
p := &progress{
label: scanPhase("load"), total: -1, start: waited, rateStart: waited,
}
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 20s, eta 40s"
p.setTotal(100)
// Ten seconds after the total came, without waiting ten seconds,
// 20 records are read.
p.start = p.start.Add(-10 * time.Second)
p.rateStart = p.rateStart.Add(-10 * time.Second)
p.count = 20
want := "[1/5] load: [20/100] 20%, 2 files/s, elapsed 30s, eta 40s"
if got := p.line(); got != want {
t.Errorf("line = %q, want %q", got, want)
}
}
// awaitOutput waits until stderr holds want at least times times, and
// fails the test if it never does.
func awaitOutput(t *testing.T, stderr func() string, want string,
times int,
) {
t.Helper()
deadline := time.Now().Add(testDeadline)
for strings.Count(stderr(), want) < times {
if time.Now().After(deadline) {
t.Fatalf("stderr = %q, want %q at least %d times",
stderr(), want, times)
}
time.Sleep(testRedraw)
}
}
// TestPlainProgressPrintsAtStartAndEnd checks that when stderr is not a
// terminal a phase prints its line the moment it starts, before its
// first item, nothing for the items it completes before plainInterval
@@ -172,19 +198,11 @@ func TestPlainProgressPrintsWhileWaiting(t *testing.T) {
p := startProgress(progressParams{
label: scanPhase("load"), total: -1,
waitingFor: "counting the records",
}, nil, testRedraw)
want := "[1/5] load: counting the records, elapsed 0s, eta unknown\n"
for deadline := time.Now().Add(testDeadline); strings.Count(stderr(),
want) < 3 && time.Now().Before(deadline); {
time.Sleep(testRedraw)
}
}, false, testRedraw)
awaitOutput(t, stderr,
"[1/5] load: counting the records, elapsed 0s, eta unknown\n", 3)
p.finish()
if got := stderr(); strings.Count(got, want) < 3 {
t.Errorf("stderr = %q, want %q at least three times", got, want)
}
}
// lastFrame returns what a terminal shows of the frames a display drew:
@@ -202,73 +220,116 @@ func lastFrame(out string) string {
return shown
}
// awaitFrame waits until the last frame a terminal display drew to
// stderr shows want, and fails the test if it never does.
func awaitFrame(t *testing.T, stderr func() string, want string) {
t.Helper()
for deadline := time.Now().Add(testDeadline); !strings.Contains(
lastFrame(stderr()), want); time.Sleep(testRedraw) {
if time.Now().After(deadline) {
t.Fatalf("terminal shows %q, want %q", lastFrame(stderr()), want)
}
}
}
// TestTerminalRedrawsWhileWaiting checks that the terminal display of a
// phase, with a total or without, keeps showing the elapsed time while
// the phase waits for its next item, and shows every item completed
// before the wait.
// phase keeps being drawn while the phase waits for its next item, and
// shows every item completed before the wait: a spinner's line with
// the elapsed time as of now, and the library's bar, which works out
// its elapsed time at each draw.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalRedrawsWhileWaiting(t *testing.T) {
stderr := captureStderr(t)
counts := map[int64]string{-1: "50 files", 100: "[50/100]"}
walk := startProgress(progressParams{label: scanPhase("walk"), total: -1},
true, testRedraw)
for total, count := range counts {
p := startProgress(progressParams{
label: scanPhase("hash"), total: total,
}, newSpinner(), testRedraw)
for range 50 {
walk.increment()
}
for range 50 {
p.increment()
}
// An hour into the phase, without waiting an hour.
walk.mu.Lock()
walk.start = walk.start.Add(-time.Hour)
walk.mu.Unlock()
// An hour into the phase, without waiting an hour.
awaitOutput(t, stderr, "elapsed 1h0m0s", 1)
walk.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "50 files,") {
t.Errorf("terminal shows %q, want a count of 50", shown)
}
hash := startProgress(progressParams{label: scanPhase("hash"), total: 100},
true, testRedraw)
for range 50 {
hash.increment()
}
// Drawn with the 50 items, then twice more with none.
awaitOutput(t, stderr, "(50/100,", 3)
hash.finish()
}
// TestTerminalLinesFitWidth checks that every line a terminal display
// draws, bar or spinner, is narrower than the terminal at the size of a
// large scan. A line as wide as the terminal or wider would wrap, and
// every redraw would leave a line behind. stderr is not a terminal
// here, so the displays take it to be fallbackWidth wide.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalLinesFitWidth(t *testing.T) {
stderr := captureStderr(t)
cases := []progressParams{
{label: scanPhase("content"), total: 9876543},
{label: scanPhase("walk"), total: 9876543, estimate: true},
}
for _, params := range cases {
p := startProgress(params, true, testRedraw)
// 1234567 items two and a half hours into the phase, without
// counting them one by one or waiting the hours.
p.mu.Lock()
p.start = p.start.Add(-time.Hour)
p.count = 1234567
p.start = p.start.Add(-150 * time.Minute)
p.rateStart = p.start
p.mu.Unlock()
awaitFrame(t, stderr, "elapsed 1h0m0s")
if shown := lastFrame(stderr()); !strings.Contains(shown, count) {
t.Errorf("terminal shows %q, want %q", shown, count)
}
awaitOutput(t, stderr, "1234567", 1)
p.finish()
}
lines := strings.FieldsFuncSeq(stderr(), func(r rune) bool {
return r == '\r' || r == '\n'
})
for line := range lines {
if n := utf8.RuneCountInString(line); n >= fallbackWidth {
t.Errorf("a line of %d columns on a terminal %d wide: %q",
n, fallbackWidth, line)
}
}
}
// TestProgressWarningsOnOwnLines drives the terminal display through a
// run of warnings with no items between them, as when the walk meets
// many unreadable paths, for many redraws: every warning must land on a
// line of its own, never inside a redraw.
// TestProgressWarningsOnOwnLines drives the terminal displays, spinner
// and bar, through a run of warnings with no items between them, as when
// the walk meets many unreadable paths, for many redraws: every warning
// must land on a line of its own, never inside a redraw.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWarningsOnOwnLines(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{label: scanPhase("walk"), total: -1},
newSpinner(), testRedraw)
// No pause between warnings: one written straight to stderr is
// garbled only if a redraw lands while it is being written.
issued := 0
for start := time.Now(); time.Since(start) < 50*testRedraw; issued++ {
p.warnf("warning")
cases := []progressParams{
{label: scanPhase("walk"), total: -1},
{label: scanPhase("hash"), total: 10},
}
p.finish()
issued := 0
for _, params := range cases {
p := startProgress(params, true, testRedraw)
// No pause between warnings: one written straight to stderr is
// garbled only if a redraw lands while it is being written.
for start := time.Now(); time.Since(start) < 50*testRedraw; issued++ {
p.warnf("warning")
}
p.finish()
}
// A terminal shows each line as the text after its last carriage
// return.
@@ -293,39 +354,41 @@ func TestProgressWarningsOnOwnLines(t *testing.T) {
// TestTerminalStoppedShortKeepsCount checks that the terminal display
// of a phase that stops before its total, as an interrupted one does,
// is left showing the count it stopped at.
// is left showing the count it stopped at rather than a full bar.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestTerminalStoppedShortKeepsCount(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{label: scanPhase("hash"), total: 10},
newSpinner(), redrawInterval)
true, redrawInterval)
p.increment()
p.increment()
p.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/10]") {
if shown := lastFrame(stderr()); !strings.Contains(shown, "(2/10,") {
t.Errorf("terminal shows %q, want a count of 2 of 10", shown)
}
}
// TestWarningAfterLastItemKeepsLine checks that a warning printed after
// a phase's last item, as for a failed read of the last file, leaves
// the phase's last line on the terminal below the warning.
// TestWarningAfterLastItemKeepsBar checks that a warning printed after
// a phase's last item, as for a failed read of the last file, leaves the
// phase's full bar on the terminal below the warning.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestWarningAfterLastItemKeepsLine(t *testing.T) {
func TestWarningAfterLastItemKeepsBar(t *testing.T) {
stderr := captureStderr(t)
p := startProgress(progressParams{label: scanPhase("hash"), total: 2},
newSpinner(), redrawInterval)
true, testRedraw)
p.increment()
p.increment()
awaitOutput(t, stderr, "(2/2,", 1)
p.warnf("warning")
p.finish()
if shown := lastFrame(stderr()); !strings.Contains(shown, "[2/2]") {
if shown := lastFrame(stderr()); !strings.Contains(shown, "(2/2,") {
t.Errorf("terminal shows %q, want a count of 2 of 2", shown)
}
}
+19 -7
View File
@@ -856,7 +856,9 @@ func (s *scanState) contentCandidates(
) ([]fileRec, map[string]scanRec, error) {
// The search and the checks take real time on a large database;
// without a display the scan looks hung before the reads begin.
// Counting what the search finds first gives the checks a total.
// The display says it is searching until SQLite returns the first
// record. The checks start there, against a count of the records
// the search returns, and so do their rate and ETA.
prog := newProgress(progressParams{
label: scanPhase("content"), total: -1,
waitingFor: "searching the database",
@@ -868,13 +870,12 @@ func (s *scanState) contentCandidates(
return nil, nil, err
}
prog.setTotal(total)
var (
toRead []fileRec
first scanRec // the current group's first record
passed int // the current group's files that passed the check
unread []fileRec // those of them without a content hash
searching = true // no record has been returned yet
toRead []fileRec
first scanRec // the current group's first record
passed int // the current group's files that passed the check
unread []fileRec // those of them without a content hash
)
recs := make(map[string]scanRec)
@@ -894,6 +895,12 @@ func (s *scanState) contentCandidates(
}
err = loadContentCandidates(ctx, s.db, func(r scanRec, hashed bool) {
if searching {
searching = false
prog.setTotal(total)
}
prog.increment()
if r.size != first.size || r.head != first.head || r.tail != first.tail {
@@ -924,6 +931,11 @@ func (s *scanState) contentCandidates(
return nil, nil, err
}
// A search that returned no record still ends showing its count.
if searching {
prog.setTotal(total)
}
endGroup()
return toRead, recs, nil