check / check (push) Successful in 1m25s
On a pipe, a file or /dev/null each scan phase now prints its zero-state line the moment it starts instead of after its first finished item, so a slow first read under cron no longer leaves the log silent. Terminal detection uses golang.org/x/term, now a direct dependency listed in the README; the old character-device test took /dev/null for a terminal. The walk spinner no longer redraws from the library's own goroutine; it advances only as items complete, so a redraw cannot garble a warning. Building the bar moved into newBar so the terminal display can be tested without a terminal. Lint suppressed: gosec on two test file opens, paralleltest on three tests that replace os.Stderr. Model: opus-5-5
179 lines
4.5 KiB
Go
179 lines
4.5 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/schollz/progressbar/v3"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// plainInterval is the minimum time between progress lines when stderr
|
|
// is not a TTY.
|
|
const plainInterval = 5 * time.Second
|
|
|
|
// barThrottle caps how often the TTY progress bar redraws.
|
|
const barThrottle = 100 * time.Millisecond
|
|
|
|
// walkSpinnerType selects the progressbar library's spinner style used
|
|
// for the walk pass, which has no known total.
|
|
const walkSpinnerType = 14
|
|
|
|
// percentScale converts a fraction to a percentage.
|
|
const percentScale = 100
|
|
|
|
// stderrIsTTY reports whether stderr is attached to a terminal.
|
|
func stderrIsTTY() bool {
|
|
return term.IsTerminal(int(os.Stderr.Fd()))
|
|
}
|
|
|
|
// progress renders one scan pass's progress on stderr. On a TTY it
|
|
// delegates to the progressbar library (spinner style when the total is
|
|
// unknown, full bar with count/percent/rate/elapsed/ETA otherwise). When
|
|
// stderr is not a TTY it emits no ANSI redraws: it prints a plain
|
|
// one-line update as the pass starts, then no more often than every
|
|
// plainInterval.
|
|
//
|
|
// All methods must be called from the main goroutine only; nothing
|
|
// else writes to stderr while a pass runs. A nil *progress is a valid
|
|
// no-display receiver: every method is a no-op, so batched database
|
|
// flushes during the streaming pass can reuse the update-pass helpers
|
|
// without rendering anything.
|
|
type progress struct {
|
|
label string
|
|
total int64 // -1 when unknown (walk pass)
|
|
bar *progressbar.ProgressBar
|
|
count int64
|
|
start time.Time
|
|
last time.Time
|
|
}
|
|
|
|
func newProgress(label string, total int64) *progress {
|
|
p := &progress{label: label, total: total, start: time.Now()}
|
|
if stderrIsTTY() {
|
|
p.bar = newBar(label, total)
|
|
|
|
return p
|
|
}
|
|
|
|
// Print the zero state at once: the first item may take minutes,
|
|
// and a pass must never look hung.
|
|
p.last = p.start
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
|
|
return p
|
|
}
|
|
|
|
// newBar builds the TTY display for newProgress.
|
|
func newBar(label string, total int64) *progressbar.ProgressBar {
|
|
opts := []progressbar.Option{
|
|
progressbar.OptionSetWriter(os.Stderr),
|
|
progressbar.OptionSetDescription(label),
|
|
progressbar.OptionShowCount(),
|
|
progressbar.OptionShowIts(),
|
|
progressbar.OptionSetItsString("files"),
|
|
progressbar.OptionSetElapsedTime(true),
|
|
progressbar.OptionThrottle(barThrottle),
|
|
// Render at zero immediately: a phase must be visible the
|
|
// moment it starts, even before its first item completes.
|
|
progressbar.OptionSetRenderBlankState(true),
|
|
}
|
|
if total >= 0 {
|
|
opts = append(opts,
|
|
progressbar.OptionSetPredictTime(true),
|
|
progressbar.OptionShowElapsedTimeOnFinish(),
|
|
)
|
|
} else {
|
|
opts = append(opts,
|
|
progressbar.OptionSetPredictTime(false),
|
|
progressbar.OptionSpinnerType(walkSpinnerType),
|
|
// Advance the spinner only on Add. The library's default
|
|
// redraws it from its own goroutine every 100ms, which
|
|
// garbles a warning printed at the same moment.
|
|
progressbar.OptionSetSpinnerChangeInterval(0),
|
|
)
|
|
}
|
|
|
|
return progressbar.NewOptions64(total, opts...)
|
|
}
|
|
|
|
// increment records one completed item and refreshes the display.
|
|
func (p *progress) increment() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
p.count++
|
|
if p.bar != nil {
|
|
_ = p.bar.Add(1)
|
|
|
|
return
|
|
}
|
|
|
|
if time.Since(p.last) >= plainInterval {
|
|
p.last = time.Now()
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
}
|
|
}
|
|
|
|
// warnf prints a one-line warning to stderr without corrupting the bar.
|
|
func (p *progress) warnf(format string, args ...any) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
if p.bar != nil {
|
|
_ = p.bar.Clear()
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
|
}
|
|
|
|
// finish terminates the pass's display.
|
|
func (p *progress) finish() {
|
|
if p == nil {
|
|
return
|
|
}
|
|
|
|
if p.bar != nil {
|
|
_ = p.bar.Finish()
|
|
|
|
fmt.Fprintln(os.Stderr)
|
|
|
|
return
|
|
}
|
|
|
|
fmt.Fprintln(os.Stderr, p.plainLine())
|
|
}
|
|
|
|
// plainLine formats a non-TTY progress line.
|
|
func (p *progress) plainLine() string {
|
|
elapsed := time.Since(p.start)
|
|
if p.total < 0 {
|
|
return fmt.Sprintf("%s: %d files, elapsed %s",
|
|
p.label, p.count, elapsed.Round(time.Second))
|
|
}
|
|
|
|
pct := float64(percentScale)
|
|
if p.total > 0 {
|
|
pct = percentScale * float64(p.count) / float64(p.total)
|
|
}
|
|
|
|
rate := 0.0
|
|
if s := elapsed.Seconds(); s > 0 {
|
|
rate = float64(p.count) / s
|
|
}
|
|
|
|
eta := "?"
|
|
|
|
if rate > 0 && p.count <= p.total {
|
|
remaining := time.Duration(float64(p.total-p.count) / rate * float64(time.Second))
|
|
eta = remaining.Round(time.Second).String()
|
|
}
|
|
|
|
return fmt.Sprintf("%s: [%d/%d] %.0f%% %.0f files/s elapsed %s eta %s",
|
|
p.label, p.count, p.total, pct, rate,
|
|
elapsed.Round(time.Second), eta)
|
|
}
|