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
This commit is contained in:
2026-10-16 14:50:03 +00:00
parent 264817c413
commit a04601f010
8 changed files with 888 additions and 306 deletions
+69 -7
View File
@@ -309,8 +309,10 @@ func TestApplyChangesRoundTrip(t *testing.T) {
},
}
err := applyChanges(t.Context(), db, recs, nil,
newProgress("update", 2))
prog := newProgress(progressParams{label: "update", total: 4})
defer prog.finish()
err := applyChanges(t.Context(), db, recs, nil, prog)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
@@ -329,7 +331,7 @@ func TestApplyChangesRoundTrip(t *testing.T) {
}
err = applyChanges(t.Context(), db, []scanRec{upd},
[]string{"/a/tab\tnew\nline"}, newProgress("update", 2))
[]string{"/a/tab\tnew\nline"}, prog)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
@@ -357,8 +359,10 @@ func TestApplyChangesBatching(t *testing.T) {
})
}
err := applyChanges(t.Context(), db, recs, nil,
newProgress("update", int64(n)))
prog := newProgress(progressParams{label: "update", total: int64(2 * n)})
defer prog.finish()
err := applyChanges(t.Context(), db, recs, nil, prog)
if err != nil {
t.Fatalf("applyChanges: %v", err)
}
@@ -372,8 +376,7 @@ func TestApplyChangesBatching(t *testing.T) {
deletes = append(deletes, r.path)
}
err = applyChanges(t.Context(), db, nil, deletes,
newProgress("update", int64(n)))
err = applyChanges(t.Context(), db, nil, deletes, prog)
if err != nil {
t.Fatalf("applyChanges deletes: %v", err)
}
@@ -382,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)
}
}