trees: show its phases as N of M, with progress, elapsed time and ETA (closes #113)
check / check (push) Canceled after 0s

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 was merged in pull request #121.
This commit is contained in:
2026-10-16 21:00:14 +02:00
parent 8dc73aa3f5
commit 8e0facdcd3
11 changed files with 643 additions and 99 deletions
+21 -8
View File
@@ -303,8 +303,15 @@ func TestRunMissingDatabaseIsFatal(t *testing.T) {
t.Errorf("run(%s) = %d, want %d", name, code, exitFatal)
}
// trees shows its open phase, as it starts and as it ends,
// before the error.
opening := ""
if name == cmdTrees {
opening = treesOpenLine + "\n" + treesOpenLine + "\n"
}
want := name + ": database " + path + " (from " + databaseEnv +
")\n" +
")\n" + opening +
"sfdupes: " + path + ": no database (run \"sfdupes " +
"scan\" first, or set " + databaseEnv + ")\n"
if got := stderr(); got != want {
@@ -506,7 +513,7 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
// nothing here may create or read one: scan gets an operand that
// does not exist, which stops it before it takes the lock, and
// report and trees a cancelled context, which stops them before
// they read anything.
// they read anything. trees shows its open phase after the line.
t.Setenv(databaseEnv, "")
// Whether scan calls the database new depends on that machine too.
@@ -540,7 +547,8 @@ func TestRunNamesDefaultDatabase(t *testing.T) {
run: func() error {
return runTrees(cancelledContext(t), io.Discard)
},
want: "trees: database " + defaultDatabasePath + " (default)\n",
want: "trees: database " + defaultDatabasePath + " (default)\n" +
treesOpenLine + "\n" + treesOpenLine + "\n",
},
}
@@ -942,11 +950,16 @@ func TestRunTreesSucceeds(t *testing.T) {
t.Errorf("stdout = %q, want %q", got, want)
}
want = "trees: database " + path + " (from " + databaseEnv + ")\n" +
"trees: 2 records read, 1 duplicate tree groups, 1 dupe trees, " +
"300 B reclaimable\n"
if got := stderr(); got != want {
t.Errorf("stderr = %q, want %q", got, want)
// Its phases come between the two lines; see
// TestRunTreesProgressNamesEachPhase.
first := "trees: database " + path + " (from " + databaseEnv + ")\n"
last := "\ntrees: 2 records read, 1 duplicate tree groups, " +
"1 dupe trees, 300 B reclaimable\n"
if got := stderr(); !strings.HasPrefix(got, first) ||
!strings.HasSuffix(got, last) {
t.Errorf("stderr = %q, want it to start %q and end %q",
got, first, last)
}
assertNoSidecars(t, path)