trees: show its phases as N of M, with progress, elapsed time and ETA (closes #113)
check / check (push) Waiting to run

trees now runs in four phases, open, read, group and write, each under
the progress display scan uses, until the summary line. open covers a
wait on a busy database. read counts the records and reads them in one
read-only transaction, so the two agree during a scan; group counts the
directories, and write the groups. The rows reach stdout through a
writer. When stdout and stderr are the same file, it clears the display
and holds its lock while writing, so no row shares a line with the
display on a shared terminal; otherwise it writes without the lock, so
a slow reader of stdout cannot stop the display. Each rate names what
its phase counts.

Judgement call: opening the database is a phase of its own, so there are four phases, not the three the issue sketches.

Model: opus-5-5
This commit is contained in:
2026-10-16 18:36:33 +00:00
committed by sneak
parent 8dc73aa3f5
commit a2483a84f1
11 changed files with 643 additions and 99 deletions
+82 -10
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"io"
"math"
"os"
"slices"
@@ -72,6 +73,7 @@ func phaseLabel(phases []string, phase string) string {
// progressParams describe one phase's progress display as it starts.
type progressParams struct {
label string // the phase and its place, from phaseLabel
unit string // what the phase counts, as "files"
total int64 // the items the phase will complete; -1 when not known
estimate bool // total is an estimate, which the count may pass
waitingFor string // what the phase waits for until setTotal is called
@@ -90,7 +92,8 @@ type progressParams struct {
// below it. 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.
// phase's helpers without rendering anything, and its writer writes
// with no display to clear.
type progress struct {
width func() int // reads the terminal's width; nil when not a TTY
stop chan struct{} // closed by finish to end the draws
@@ -101,6 +104,7 @@ type progress struct {
withBar bool // bar is the library's bar, not its spinner
widest int // the widest line bar has drawn, in columns
label string
unit string
total int64
estimate bool
waitingFor string
@@ -128,8 +132,9 @@ func startProgress(params progressParams, width func() int,
now := time.Now()
p := &progress{
width: width, stop: make(chan struct{}), done: make(chan struct{}),
label: params.label, total: params.total, estimate: params.estimate,
waitingFor: params.waitingFor, start: now, rateStart: now,
label: params.label, unit: params.unit, total: params.total,
estimate: params.estimate, waitingFor: params.waitingFor,
start: now, rateStart: now,
}
// Show the zero state at once: the first item may take minutes,
@@ -287,6 +292,73 @@ func (p *progress) warnf(format string, args ...any) {
fmt.Fprintln(os.Stderr, msg)
}
// progressWriter writes a phase's output to out, which may be the file
// the phase's display is drawn on, as stdout is in a plain "sfdupes
// trees" on a terminal. It holds what is written to it and writes that
// to out in pieces of about ioBufSize bytes. When out is the file
// stderr is, each piece is written under the display's lock with the
// display cleared first, as warnf does for a warning: no draw lands in
// the middle of a piece, and the next draw shows the display again, on
// the line below. Every write to it must end with a newline, as a row
// written with one fmt.Fprintf does, so that each piece ends a line.
// Flush writes what it holds once the last row is written.
type progressWriter struct {
// p is the display to clear, nil unless out is the file stderr is.
p *progress
out io.Writer
buf []byte
}
// writer returns a progressWriter writing to out around p's display.
// It clears the display and takes its lock only when out is the file
// stderr is: the same device and inode, as os.SameFile compares them,
// which is one terminal, or one pipe or file that both are sent to.
// Otherwise it writes to out without the lock, so the display keeps
// being drawn while a write to out waits for a slow reader.
func (p *progress) writer(out io.Writer) *progressWriter {
w := &progressWriter{out: out}
if f, ok := out.(*os.File); ok {
outInfo, outErr := f.Stat()
errInfo, errErr := os.Stderr.Stat()
if outErr == nil && errErr == nil && os.SameFile(outInfo, errInfo) {
w.p = p
}
}
return w
}
// Write holds b, and writes what it holds to out once that comes to
// ioBufSize bytes.
func (w *progressWriter) Write(b []byte) (int, error) {
w.buf = append(w.buf, b...)
if len(w.buf) < ioBufSize {
return len(b), nil
}
return len(b), w.Flush()
}
// Flush writes what w holds to out. When out is the file stderr is, the
// display is cleared first and not drawn again until it is written.
func (w *progressWriter) Flush() error {
if w.p != nil {
w.p.mu.Lock()
defer w.p.mu.Unlock()
if w.p.bar != nil {
_ = w.p.bar.Clear()
}
}
_, err := w.out.Write(w.buf)
w.buf = w.buf[:0]
return err
}
// 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.
@@ -311,9 +383,9 @@ func (p *progress) finish() {
//
// [3/5] hash: [1234/5000] 25%, 300 files/s, elapsed 4s, eta 13s
//
// without the percent when percent is false, as before the library's
// bar, which shows its own. A phase with no total shows the items done
// instead, or what it waits for.
// where "files" is the phase's unit, without the percent when percent
// is false, as before the library's bar, which shows its own. A phase
// with no total shows the items done instead, or what it waits for.
//
// The phase and its place, the progress, the elapsed time and the ETA
// are kept whole. A line wider than room leaves out the rate, or what
@@ -337,7 +409,7 @@ func (p *progress) line(room int, percent bool) (string, bool) {
}
head = p.label + ": " + p.itemsDone(percent) + ", "
extra = fmt.Sprintf("%.0f files/s", rate)
extra = fmt.Sprintf("%.0f %s/s", rate, p.unit)
tail = fmt.Sprintf("elapsed %s, eta %s", elapsed, p.eta(rate))
}
@@ -355,11 +427,11 @@ func (p *progress) line(room int, percent bool) (string, bool) {
// itemsDone formats the items done, as "[1234/5000] 25%", with "~"
// before a total that is an estimate and without the percent when
// percent is false, or as "1234 files" when the phase has no total.
// p.mu must be held.
// percent is false, or as "1234 files", in the phase's unit, when the
// phase has no total. p.mu must be held.
func (p *progress) itemsDone(percent bool) string {
if p.total < 0 {
return fmt.Sprintf("%d files", p.count)
return fmt.Sprintf("%d %s", p.count, p.unit)
}
approx := ""