Files
Snow Lee (Sungwon)andGitHub 333d1fb8e5 fix(cli): bdrive log sorts by when a change arrived (BEA-112) (#174)
`mv a.md b.md` produced two rows minutes apart: the delete carried the
rename's time, the put carried the original file's mtime, so a file that
appeared seconds ago sorted below the fold of "what changed since
yesterday" — the question `bdrive log` exists to answer.

SortForDisplay now orders by CommitTime (when the change was journaled),
tie-breaking on DisplayTime so one scan still reads by the files' own
edit times. The write time is still shown, appended as `written <time>`
when it lags the commit by more than a minute — a rename, or an old
document added today — rather than silently replacing the column.

DisplayTime and both of its security clamps are untouched. CommitTime
carries the same clamp: an op stamped after this machine's clock cannot
date itself, so it sorts last rather than first.

One deviation from the plan: it assumed a scan shares one commit time,
but nextOp stamped time.Now() per op, so the tie-break never engaged and
one scan sorted in walk order. A scan is now one commit instant — order
inside the batch is already carried by Lamport and Seq, which
journal.Less reads first, so replay is unaffected.

journal.Less, Replay, LogEntries' causal order and the op format are
unchanged. The hub's History is a separate path and still orders a
rename by write time.
2026-08-18 22:10:02 -07:00

285 lines
8.9 KiB
Go

package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
)
// The reported bug was read off `bdrive log`'s own output, so assert on that
// output: every printed timestamp descends, the file's edit time still reaches
// the reader (files written minutes apart don't collapse onto one), and -n
// keeps the newest by that stamp rather than the highest lamport.
//
// The first column is now when the change was journaled, so the edit time it
// used to carry is asserted where it is now printed — the appended
// `written ...` field. That is BEA-40's guarantee, moved, not dropped.
func TestLogPrintsNewestFirstByEditTime(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
if _, err := config.SaveProject(folder, config.Project{
Volume: "wiki",
Remote: "https://hub.example.com/p/p-12345678", // unreachable: the cycle degrades offline
}); err != nil {
t.Fatal(err)
}
if _, _, err := config.EnrollMount(folder); err != nil { // enroll, as `bdrive init` would
t.Fatal(err)
}
// Written in one scan, edited at three different times — and the newest
// edit is deliberately not the one the walk sees last.
now := time.Now()
files := map[string]time.Duration{
"a-oldest.md": -30 * time.Minute,
"b-newest.md": -1 * time.Minute,
"c-middle.md": -10 * time.Minute,
}
for rel, age := range files {
abs := filepath.Join(folder, rel)
if err := os.WriteFile(abs, []byte("content of "+rel), 0o644); err != nil {
t.Fatal(err)
}
when := now.Add(age)
if err := os.Chtimes(abs, when, when); err != nil {
t.Fatal(err)
}
}
sync := syncCmd()
sync.SetOut(&bytes.Buffer{})
sync.SetArgs([]string{folder})
if err := sync.Execute(); err != nil {
t.Fatalf("sync: %v", err)
}
rows := runLog(t, folder)
if len(rows) != 3 {
t.Fatalf("got %d rows, want 3:\n%v", len(rows), paths(rows))
}
if want := []string{"b-newest.md", "c-middle.md", "a-oldest.md"}; !equal(paths(rows), want) {
t.Fatalf("row order = %v, want %v", paths(rows), want)
}
for i := 1; i < len(rows); i++ {
if rows[i].stamp.After(rows[i-1].stamp) {
t.Fatalf("printed stamps are not newest-first: %v then %v", rows[i-1].stamp, rows[i].stamp)
}
}
// Three edits minutes apart must still reach the reader as three distinct
// write times; before BEA-40 one scan stamped them all with its own time.
for i, r := range rows {
if r.written.IsZero() {
t.Fatalf("row %d (%s) dropped the file's write time", i, r.path)
}
}
if rows[0].written.Equal(rows[1].written) || rows[1].written.Equal(rows[2].written) {
t.Fatalf("write times collapsed onto the sync time: %v %v %v",
rows[0].written, rows[1].written, rows[2].written)
}
// -n truncates after the display sort.
top := runLog(t, folder, "-n", "2")
if want := []string{"b-newest.md", "c-middle.md"}; !equal(paths(top), want) {
t.Fatalf("-n 2 = %v, want %v", paths(top), want)
}
// -p still filters to one path.
only := runLog(t, folder, "-p", "c-middle.md")
if want := []string{"c-middle.md"}; !equal(paths(only), want) {
t.Fatalf("-p = %v, want %v", paths(only), want)
}
}
// A rename is one change, and `mv` preserves mtime — so before this, the put
// half was stamped with the original file's write time and sorted away from
// its own delete, which is how a file that appeared seconds ago falls below
// the fold of "what changed since yesterday".
func TestLogKeepsARenameTogether(t *testing.T) {
folder := logFixture(t)
old := filepath.Join(folder, "architecture.md")
if err := os.WriteFile(old, []byte("# architecture"), 0o644); err != nil {
t.Fatal(err)
}
// Written well before the rename: the gap is the bug.
long := time.Now().Add(-90 * time.Minute)
if err := os.Chtimes(old, long, long); err != nil {
t.Fatal(err)
}
// Filler, so "adjacent at the top" is a real claim and not the only rows.
for _, rel := range []string{"notes.md", "todo.md"} {
if err := os.WriteFile(filepath.Join(folder, rel), []byte(rel), 0o644); err != nil {
t.Fatal(err)
}
}
runSync(t, folder)
if err := os.Rename(old, filepath.Join(folder, "arch-v2.md")); err != nil {
t.Fatal(err)
}
runSync(t, folder)
rows := runLog(t, folder)
if len(rows) < 2 {
t.Fatalf("got %d rows, want the rename plus fillers: %v", len(rows), paths(rows))
}
// The two halves are one change and sit together at the top. The delete
// leads: it has no file left to stat, so it sorts on the commit time while
// the put still carries its 90-minute-old write time as the tie-break.
got := []string{rows[0].kind + " " + rows[0].path, rows[1].kind + " " + rows[1].path}
want := []string{"delete architecture.md", "put arch-v2.md"}
if !equal(got, want) {
t.Fatalf("top two rows = %v, want the rename's two halves %v\nall: %v", got, want, paths(rows))
}
if !rows[0].stamp.Equal(rows[1].stamp) {
t.Fatalf("one rename printed two stamps: %v and %v", rows[0].stamp, rows[1].stamp)
}
// The write time is what makes the 90-minute gap legible instead of silent.
if rows[1].written.IsZero() {
t.Fatal("the put half dropped the file's write time")
}
if gap := rows[1].stamp.Sub(rows[1].written); gap < time.Hour {
t.Fatalf("put half's write time = %v, stamp = %v: the original mtime was lost",
rows[1].written, rows[1].stamp)
}
// A delete has no file left to stat, so it never carries the field.
if !rows[0].written.IsZero() {
t.Fatalf("delete row carried a write time: %v", rows[0].written)
}
}
// An old document dropped into the project today is a change today. It used to
// sort by its own mtime, i.e. below everything journaled since it was written.
func TestLogSortsAnOldFileByWhenItArrived(t *testing.T) {
folder := logFixture(t)
recent := filepath.Join(folder, "recent.md")
if err := os.WriteFile(recent, []byte("edited a minute ago"), 0o644); err != nil {
t.Fatal(err)
}
fresh := time.Now().Add(-2 * time.Minute)
if err := os.Chtimes(recent, fresh, fresh); err != nil {
t.Fatal(err)
}
runSync(t, folder)
// Journaled second, written days before the file above it.
ancient := filepath.Join(folder, "ancient.md")
if err := os.WriteFile(ancient, []byte("from the archive"), 0o644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-72 * time.Hour)
if err := os.Chtimes(ancient, old, old); err != nil {
t.Fatal(err)
}
runSync(t, folder)
rows := runLog(t, folder)
if want := []string{"ancient.md", "recent.md"}; !equal(paths(rows), want) {
t.Fatalf("row order = %v, want %v — the newly arrived file sorts first", paths(rows), want)
}
if rows[0].written.IsZero() || rows[0].stamp.Sub(rows[0].written) < 24*time.Hour {
t.Fatalf("ancient.md lost its write time: stamp %v, written %v", rows[0].stamp, rows[0].written)
}
}
// logFixture is an enrolled folder pointed at an unreachable hub, so the cycle
// degrades offline and the journal is all local.
func logFixture(t *testing.T) string {
t.Helper()
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
if _, err := config.SaveProject(folder, config.Project{
Volume: "wiki",
Remote: "https://hub.example.com/p/p-12345678",
}); err != nil {
t.Fatal(err)
}
if _, _, err := config.EnrollMount(folder); err != nil {
t.Fatal(err)
}
return folder
}
func runSync(t *testing.T, folder string) {
t.Helper()
c := syncCmd()
c.SetOut(&bytes.Buffer{})
c.SetArgs([]string{folder})
if err := c.Execute(); err != nil {
t.Fatalf("sync: %v", err)
}
}
// logRow is one parsed `bdrive log` line: the leading stamp (when the change
// was journaled), the kind and path columns, and the appended write time when
// the row carries one.
type logRow struct {
stamp time.Time
written time.Time
kind string
path string
}
// runLog runs `bdrive log` and parses its rows.
func runLog(t *testing.T, folder string, extra ...string) []logRow {
t.Helper()
c := logCmd()
var out bytes.Buffer
c.SetOut(&out)
c.SetArgs(append([]string{folder}, extra...))
if err := c.Execute(); err != nil {
t.Fatalf("log: %v", err)
}
const stampFmt = "2006-01-02 15:04:05"
var rows []logRow
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
if line == "" {
continue
}
ts, err := time.ParseInLocation(stampFmt, line[:19], time.Local)
if err != nil {
t.Fatalf("unparsable log row %q: %v", line, err)
}
fields := strings.Fields(line[19:])
row := logRow{stamp: ts, kind: fields[0], path: fields[1]}
if _, rest, ok := strings.Cut(line, "(written "); ok {
stamp, _, _ := strings.Cut(rest, ")")
w, err := time.ParseInLocation(stampFmt, stamp, time.Local)
if err != nil {
t.Fatalf("unparsable write time in %q: %v", line, err)
}
row.written = w
}
rows = append(rows, row)
}
return rows
}
func paths(rows []logRow) []string {
out := make([]string, len(rows))
for i, r := range rows {
out[i] = r.path
}
return out
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}