2 Commits
Author SHA1 Message Date
clawbot f9ba1a3c5a Print progress at once off a terminal, keep stderr to one writer (closes #13)
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
2026-10-03 13:26:35 +00:00
clawbot c9bf22d483 Stamp the git tag or short commit in a plain docker build (closes #67)
check / check (push) Successful in 1m1s
.dockerignore now sends .git, without .git/config, which can hold a
credential. The build stage takes the VERSION build argument when one
is given, otherwise git describe --tags --always of that .git, and
fails if the context carries .git and still yields no version. A plain
docker build . used to stamp dev. The CI checkout fetches full history
so CI sees the tag and stamps the same value as make build.

Model: opus-5-5
2026-10-02 08:49:03 +02:00
9 changed files with 225 additions and 26 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
.git
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
.git/config
.claude
.DS_Store
sfdupes
+2
View File
@@ -6,4 +6,6 @@ jobs:
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
with:
fetch-depth: 0
- run: script/cibuild
+13 -1
View File
@@ -108,7 +108,19 @@ ARG CHECK_EPOCH
RUN echo "gate test, epoch ${CHECK_EPOCH}" && make test
RUN echo "gate fmt-check, epoch ${CHECK_EPOCH}" && make fmt-check
RUN make build
# The version stamped into the binary: the VERSION build argument when
# one is given, otherwise `git describe --tags --always` of the .git in
# the build context (git is installed by script/bootstrap above). A
# context that carries .git and still yields no version fails the build;
# with neither, as from a source tarball, it is "dev".
ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always || echo dev)}"; \
if [ -e .git ] && { [ -z "$version" ] || [ "$version" = dev ] || \
[ "$version" = unknown ]; }; then \
echo "no version could be derived although the build context carries .git" >&2; \
exit 1; \
fi; \
make build VERSION="$version"
# Runtime stage
# alpine:3.22, 2026-07-23
+8 -4
View File
@@ -113,7 +113,8 @@ Goals, in order:
`sfdupes`.
- Dependencies: standard library, `github.com/spf13/cobra` for the
CLI, **one progress-bar library**
(`github.com/schollz/progressbar/v3`), and **one SQLite driver**
(`github.com/schollz/progressbar/v3`), `golang.org/x/term` to tell
whether stderr is a terminal, and **one SQLite driver**
(`modernc.org/sqlite`, pure Go, so builds keep cgo disabled).
`github.com/spf13/viper` is permitted if configuration-file support
is ever needed, but is not currently used. No other third-party
@@ -533,10 +534,13 @@ hash: [12345/98765] 12% |████ | 92 files/s elapsed 2:32 eta 17:54
Additional requirements:
- When stderr is not a TTY, do not emit ANSI redraws: print a plain
one-line progress update no more often than every 5 seconds instead.
- When stderr is not a terminal (a pipe, a file, `/dev/null`), do not
emit ANSI redraws: print a plain one-line progress update the moment
each phase starts, then no more often than every 5 seconds.
- Progress updates are driven from the main goroutine and must be
non-blocking with respect to the worker pool.
non-blocking with respect to the worker pool. Nothing redraws in the
background (the spinner advances only as items complete), so a
warning printed during a phase always lands on its own line.
- `report` and `trees` modes need no progress display, only their
stderr summaries.
+14
View File
@@ -29,6 +29,20 @@
# Completed Steps
- progress prints at once on a non-terminal, uses a real terminal test,
and has a single writer to stderr (2026-10-03,
https://git.eeqj.de/sneak/sfdupes/issues/13)
- stamp the git tag or short commit in a plain `docker build .`
instead of `dev` (2026-10-02, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/67): `.dockerignore` now
sends `.git`, without `.git/config`, and the `Dockerfile` build
stage takes the `VERSION` build argument when one is given,
otherwise `git describe --tags --always` of that `.git`. The build
fails if the context carries `.git` and the version still comes out
empty, `dev` or `unknown`. The CI checkout step fetches the full
history (`fetch-depth: 0`) so CI sees the tag and stamps the same
value as `make build`.
- replace the 1 KiB end-window sampling with the head/tail plus
content-hash ladder (2026-09-22, branch `next`, closes
https://git.eeqj.de/sneak/sfdupes/issues/61): a file under 10 MiB is
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.25.7
require (
github.com/schollz/progressbar/v3 v3.19.1
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.44.0
modernc.org/sqlite v1.54.0
)
@@ -19,7 +20,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+27 -15
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/schollz/progressbar/v3"
"golang.org/x/term"
)
// plainInterval is the minimum time between progress lines when stderr
@@ -24,24 +25,21 @@ const percentScale = 100
// stderrIsTTY reports whether stderr is attached to a terminal.
func stderrIsTTY() bool {
fi, err := os.Stderr.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
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 no more often than every plainInterval.
// one-line update as the pass starts, then no more often than every
// plainInterval.
//
// All methods must be called from the main goroutine only. 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.
// 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)
@@ -53,10 +51,22 @@ type progress struct {
func newProgress(label string, total int64) *progress {
p := &progress{label: label, total: total, start: time.Now()}
if !stderrIsTTY() {
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),
@@ -78,12 +88,14 @@ func newProgress(label string, total int64) *progress {
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),
)
}
p.bar = progressbar.NewOptions64(total, opts...)
return p
return progressbar.NewOptions64(total, opts...)
}
// increment records one completed item and refreshes the display.
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
// spinnerIdle outlasts the 100ms interval at which the progressbar
// library redraws a spinner from its own goroutine unless told not to.
const spinnerIdle = 300 * time.Millisecond
// captureStderr points os.Stderr at a file for the rest of the test and
// returns a function reading back everything written to it.
func captureStderr(t *testing.T) func() string {
t.Helper()
path := filepath.Join(t.TempDir(), "stderr")
f, err := os.Create(path) //nolint:gosec // test-controlled path
if err != nil {
t.Fatal(err)
}
saved := os.Stderr
os.Stderr = f
t.Cleanup(func() {
os.Stderr = saved
_ = f.Close()
})
return func() string {
b, err := os.ReadFile(path) //nolint:gosec // test-controlled path
if err != nil {
t.Fatal(err)
}
return string(b)
}
}
//nolint:paralleltest // replaces the process-wide os.Stderr
func TestStderrIsTTYFalseForNonTerminals(t *testing.T) {
r, pipe, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
regular, err := os.Create(filepath.Join(t.TempDir(), "stderr"))
if err != nil {
t.Fatal(err)
}
devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
saved := os.Stderr
t.Cleanup(func() {
os.Stderr = saved
for _, f := range []*os.File{r, pipe, regular, devNull} {
_ = f.Close()
}
})
cases := map[string]*os.File{
"a pipe": pipe,
"a regular file": regular,
os.DevNull: devNull,
}
for name, f := range cases {
os.Stderr = f
if stderrIsTTY() {
t.Errorf("stderrIsTTY() = true with stderr on %s", name)
}
}
}
// TestNewProgressPrintsBeforeFirstItem checks that each pass shows its
// zero state the moment it starts when stderr is not a terminal, and
// that the next line still waits for plainInterval.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestNewProgressPrintsBeforeFirstItem(t *testing.T) {
stderr := captureStderr(t)
newProgress("walk", -1).increment()
newProgress("hash", 10).increment()
want := "walk: 0 files, elapsed 0s\n" +
"hash: [0/10] 0% 0 files/s elapsed 0s eta ?\n"
if got := stderr(); got != want {
t.Errorf("stderr = %q, want %q", got, want)
}
}
// TestProgressWarningsOnOwnLines drives the terminal display of the walk
// pass: every warning must land on a line of its own between redraws of
// the spinner, and nothing may redraw while no method is being called,
// or a warning could be garbled by a redraw from another goroutine.
//
//nolint:paralleltest // captureStderr replaces the process-wide os.Stderr
func TestProgressWarningsOnOwnLines(t *testing.T) {
stderr := captureStderr(t)
p := &progress{
label: "walk", total: -1, start: time.Now(),
bar: newBar("walk", -1),
}
for _, w := range []string{"first", "second", "third"} {
p.increment()
p.warnf("warning %s", w)
}
p.increment()
before := stderr()
time.Sleep(spinnerIdle)
if after := stderr(); after != before {
t.Errorf("stderr written while idle: %q", after[len(before):])
}
// A terminal shows each line as the text after its last carriage
// return.
var warnings []string
for line := range strings.SplitSeq(before, "\n") {
if strings.Contains(line, "warning") {
warnings = append(warnings, line[strings.LastIndex(line, "\r")+1:])
}
}
want := []string{"warning first", "warning second", "warning third"}
if !slices.Equal(warnings, want) {
t.Errorf("warning lines = %q, want %q", warnings, want)
}
}
+4 -4
View File
@@ -16,10 +16,10 @@
# implies the repo is green.
#
# That implication holds only because of CHECK_EPOCH. A COPY layer is
# invalidated by changed content, and a merge commit's tree is
# byte-identical to the branch head it merges, so without a fresh value
# here Docker serves the gate layers from cache and the build reports a
# green it never earned. Passing the current epoch invalidates the gate
# invalidated only by changed content, and a rebuild of an unchanged
# checkout sends the same content, so without a fresh value here Docker
# serves the gate layers from cache and the build reports a green it
# never earned. Passing the current epoch invalidates the gate
# layers on every run while leaving the pinned base images and
# go mod download cached; see the Dockerfile for the placement.
set -eu