mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
`bdrive log` sorted by the lamport clock, so the wall-clock stamps it prints came out non-monotonic — two 06:09:24 rows above a 06:10:00 row. And every op of one scan is stamped with that scan's commit time, so a 22-file agent run collapsed onto a single stamp. Neither is readable as a timeline, which is the whole job of the command. Two display-only changes: - `journal.Op` gains `Mtime` (`omitzero`, so old journals and old binaries are unaffected), populated on put ops from the `os.FileInfo` the scan already holds. Deletes and conflict copies keep their commit time. - `syncer.DisplayTime` / `SortForDisplay` order by the timestamp that is actually printed, ties broken by reversed `journal.Less`. `bdrive log` sorts and *then* truncates, so `-n 25` is the 25 newest by that stamp. `journal.Less`, `Sort`, and `Replay` are untouched — replay order is the convergence contract, so the sort lives in `syncer`, not in `journal`. `LogEntries` also keeps returning causal order because `bdrive restore` walks it to find a file's previous version. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
713 lines
21 KiB
Go
713 lines
21 KiB
Go
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
)
|
|
|
|
// TestPushProgress verifies the push phase reports upload progress: the total
|
|
// is the number of unique blobs, Done climbs to that total, and byte totals
|
|
// are populated. (Done isn't strictly ordered across the parallel workers, so
|
|
// we only assert it reaches the total.)
|
|
func TestPushProgress(t *testing.T) {
|
|
a := newDevice(t, "deva", sharedRemote(t))
|
|
const n = 25
|
|
for i := 0; i < n; i++ {
|
|
write(t, a.Folder, fmt.Sprintf("f%02d.txt", i), fmt.Sprintf("unique content for file %d — pad pad pad", i))
|
|
}
|
|
var mu sync.Mutex
|
|
var total, maxDone int
|
|
var toBytes int64
|
|
a.OnProgress = func(p Progress) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
total = p.Total
|
|
toBytes = p.ToBytes
|
|
if p.Done > maxDone {
|
|
maxDone = p.Done
|
|
}
|
|
}
|
|
cycle(t, a)
|
|
if total != n {
|
|
t.Fatalf("progress Total = %d, want %d", total, n)
|
|
}
|
|
if maxDone != n {
|
|
t.Fatalf("progress reached Done = %d, want %d", maxDone, n)
|
|
}
|
|
if toBytes == 0 {
|
|
t.Fatal("progress ToBytes should be > 0")
|
|
}
|
|
}
|
|
|
|
// newDevice simulates one device: its own folder, volume store, and identity,
|
|
// all syncing through a shared file:// remote.
|
|
func newDevice(t *testing.T, name string, backend remote.Backend) *Session {
|
|
t.Helper()
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Session{
|
|
Folder: t.TempDir(),
|
|
Store: st,
|
|
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
|
|
Backend: backend,
|
|
}
|
|
}
|
|
|
|
func sharedRemote(t *testing.T) remote.Backend {
|
|
t.Helper()
|
|
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return be
|
|
}
|
|
|
|
func write(t *testing.T, folder, rel, content string) {
|
|
t.Helper()
|
|
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func read(t *testing.T, folder, rel string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(folder, filepath.FromSlash(rel)))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", rel, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func cycle(t *testing.T, s *Session) *Result {
|
|
t.Helper()
|
|
res, err := s.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Offline {
|
|
t.Fatalf("unexpected offline: %v", res.OfflineErr)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func TestOfflineCycle(t *testing.T) {
|
|
a := newDevice(t, "deva", nil)
|
|
write(t, a.Folder, "notes/hello.md", "hi")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 {
|
|
t.Fatalf("LocalOps = %d, want 1", res.LocalOps)
|
|
}
|
|
// idempotent: second cycle sees no changes
|
|
res = cycle(t, a)
|
|
if res.Activity() {
|
|
t.Fatalf("second cycle should be quiet, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestTwoDeviceSync(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// A creates files, B receives them
|
|
write(t, a.Folder, "doc.txt", "v1")
|
|
write(t, a.Folder, "sub/nested.txt", "deep")
|
|
cycle(t, a)
|
|
res := cycle(t, b)
|
|
if res.PulledOps != 2 || res.Materialized != 2 {
|
|
t.Fatalf("b pull: %+v", res)
|
|
}
|
|
if read(t, b.Folder, "doc.txt") != "v1" || read(t, b.Folder, "sub/nested.txt") != "deep" {
|
|
t.Fatal("content mismatch after sync")
|
|
}
|
|
|
|
// B edits, A receives the update
|
|
time.Sleep(10 * time.Millisecond) // ensure mtime moves
|
|
write(t, b.Folder, "doc.txt", "v2 from b")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "doc.txt"); got != "v2 from b" {
|
|
t.Fatalf("a got %q", got)
|
|
}
|
|
|
|
// B deletes, A's copy disappears
|
|
os.Remove(filepath.Join(b.Folder, "sub", "nested.txt"))
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if _, err := os.Stat(filepath.Join(a.Folder, "sub", "nested.txt")); !os.IsNotExist(err) {
|
|
t.Fatal("delete did not propagate")
|
|
}
|
|
// empty dir pruned
|
|
if _, err := os.Stat(filepath.Join(a.Folder, "sub")); !os.IsNotExist(err) {
|
|
t.Fatal("empty dir not pruned")
|
|
}
|
|
}
|
|
|
|
func TestHistoryTracksDeviceAndAuthor(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "from a")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, b.Folder, "f.txt", "from b")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
|
|
entries, err := LogEntries(a.Store, "f.txt", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("want 2 history entries, got %d: %+v", len(entries), entries)
|
|
}
|
|
// newest first
|
|
if entries[0].Author != "devb@test" || entries[0].DeviceName != "devb" {
|
|
t.Fatalf("newest entry should be devb's: %+v", entries[0])
|
|
}
|
|
if entries[1].Author != "deva@test" {
|
|
t.Fatalf("oldest entry should be deva's: %+v", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestConcurrentEditConflictPreserved(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// shared base
|
|
write(t, a.Folder, "shared.txt", "base")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
// both edit before syncing
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, a.Folder, "shared.txt", "edit from a")
|
|
write(t, b.Folder, "shared.txt", "edit from b")
|
|
cycle(t, a) // a pushes first
|
|
cycle(t, b) // b scans its edit, pulls a's, loses or wins deterministically
|
|
cycle(t, a) // a converges
|
|
cycle(t, b)
|
|
|
|
aContent := read(t, a.Folder, "shared.txt")
|
|
bContent := read(t, b.Folder, "shared.txt")
|
|
if aContent != bContent {
|
|
t.Fatalf("devices diverged: %q vs %q", aContent, bContent)
|
|
}
|
|
|
|
// both versions must survive somewhere (winner at path, loser as conflict copy)
|
|
all := map[string]bool{aContent: true}
|
|
for _, folder := range []string{a.Folder, b.Folder} {
|
|
entries, err := os.ReadDir(folder)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), ".bdrive-conflict-") {
|
|
all[read(t, folder, e.Name())] = true
|
|
}
|
|
}
|
|
}
|
|
if !all["edit from a"] || !all["edit from b"] {
|
|
t.Fatalf("a version was lost; surviving: %v", all)
|
|
}
|
|
}
|
|
|
|
func TestMountExistingFolderImports(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, "pre-existing.txt", "I was here first")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 || !res.Pushed {
|
|
t.Fatalf("import failed: %+v", res)
|
|
}
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "pre-existing.txt") != "I was here first" {
|
|
t.Fatal("existing file not imported/synced")
|
|
}
|
|
}
|
|
|
|
func TestIgnoredFiles(t *testing.T) {
|
|
a := newDevice(t, "deva", nil)
|
|
write(t, a.Folder, ".DS_Store", "junk")
|
|
write(t, a.Folder, ".git/config", "gitstuff")
|
|
write(t, a.Folder, "real.txt", "data")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 {
|
|
t.Fatalf("ignores leaked into journal: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestOfflineThenReconnect(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
|
|
// work offline
|
|
a.Backend = nil
|
|
write(t, a.Folder, "offline.txt", "written offline")
|
|
cycle(t, a)
|
|
|
|
// reconnect: pending ops push
|
|
a.Backend = be
|
|
res := cycle(t, a)
|
|
if !res.Pushed {
|
|
t.Fatalf("reconnect should push pending ops: %+v", res)
|
|
}
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "offline.txt") != "written offline" {
|
|
t.Fatal("offline edit did not propagate after reconnect")
|
|
}
|
|
}
|
|
|
|
func TestSameVolumeMountedAtTwoFolders(t *testing.T) {
|
|
// One device mounts the same volume at two folders (e.g. ./shared in two
|
|
// repos). They share the store (blobs+journals) but have separate mount
|
|
// caches, and content propagates between them even with no remote.
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"}
|
|
m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev}
|
|
m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev}
|
|
|
|
write(t, m1.Folder, "shared.md", "from folder one")
|
|
cycle(t, m1)
|
|
res := cycle(t, m2)
|
|
if res.Materialized != 1 {
|
|
t.Fatalf("folder two should materialize the file: %+v", res)
|
|
}
|
|
if read(t, m2.Folder, "shared.md") != "from folder one" {
|
|
t.Fatal("content did not propagate between mounts")
|
|
}
|
|
|
|
// edit in folder two propagates back
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, m2.Folder, "shared.md", "edited in folder two")
|
|
cycle(t, m2)
|
|
cycle(t, m1)
|
|
if read(t, m1.Folder, "shared.md") != "edited in folder two" {
|
|
t.Fatal("edit did not propagate back to folder one")
|
|
}
|
|
}
|
|
|
|
func TestExecutableBitPreserved(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
abs := filepath.Join(a.Folder, "run.sh")
|
|
os.WriteFile(abs, []byte("#!/bin/sh\necho hi\n"), 0o755)
|
|
cycle(t, a)
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
fi, err := os.Stat(filepath.Join(b.Folder, "run.sh"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode().Perm()&0o100 == 0 {
|
|
t.Fatalf("exec bit lost: %v", fi.Mode())
|
|
}
|
|
}
|
|
|
|
// TestNestedMountExcluded verifies that a subdirectory which is a BearDrive
|
|
// mount of its own (has .bdrive/config.json) is fenced off from the parent
|
|
// mount: the parent scanner never journals its files, dropping it emits no
|
|
// delete ops toward peers, and remote state is never materialized into it.
|
|
func TestNestedMountExcluded(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// Both devices converge on a folder that includes team/.
|
|
write(t, a.Folder, "readme.md", "root")
|
|
write(t, a.Folder, "team/notes.md", "v1")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("b team/notes.md = %q, want v1", got)
|
|
}
|
|
|
|
// team/ becomes a nested mount on A (its own project).
|
|
write(t, a.Folder, "team/.bdrive/config.json", `{"mount_id":"m-nested"}`)
|
|
write(t, a.Folder, "team/local.md", "only for the nested project")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 0 {
|
|
t.Fatalf("a journaled %d ops for nested-mount content, want 0", res.LocalOps)
|
|
}
|
|
|
|
// B must keep its copy (no delete propagated) and never see new files.
|
|
res = cycle(t, b)
|
|
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("b team/notes.md = %q after a's cycle, want v1 (no deletes)", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(b.Folder, "team/local.md")); !os.IsNotExist(err) {
|
|
t.Fatal("nested-mount file leaked to peer")
|
|
}
|
|
|
|
// B edits inside team/; A must not materialize over its nested mount.
|
|
write(t, b.Folder, "team/notes.md", "v2")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("a team/notes.md = %q, want v1 (nested mount not written)", got)
|
|
}
|
|
|
|
// Paths outside the nested mount keep syncing both ways.
|
|
write(t, a.Folder, "readme.md", "root v2")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if got := read(t, b.Folder, "readme.md"); got != "root v2" {
|
|
t.Fatalf("b readme.md = %q, want root v2", got)
|
|
}
|
|
}
|
|
|
|
// gated wraps a backend and refuses the operations the hub would refuse for a
|
|
// given permission level, with the same sentinel the http backend produces.
|
|
// The flags are read on every call so a test can revoke (or restore) access
|
|
// mid-run, which is exactly the case that used to look like a network fault.
|
|
type gated struct {
|
|
remote.Backend
|
|
read *atomic.Bool // pulls allowed (List/Get)
|
|
write *atomic.Bool // pushes allowed (Put)
|
|
}
|
|
|
|
func newGated(be remote.Backend) *gated {
|
|
g := &gated{Backend: be, read: &atomic.Bool{}, write: &atomic.Bool{}}
|
|
g.read.Store(true)
|
|
g.write.Store(true)
|
|
return g
|
|
}
|
|
|
|
func (g *gated) List(ctx context.Context, prefix string) ([]remote.Object, error) {
|
|
if !g.read.Load() {
|
|
return nil, fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
|
|
}
|
|
return g.Backend.List(ctx, prefix)
|
|
}
|
|
|
|
func (g *gated) Get(ctx context.Context, key string) (io.ReadCloser, error) {
|
|
if !g.read.Load() {
|
|
return nil, fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
|
|
}
|
|
return g.Backend.Get(ctx, key)
|
|
}
|
|
|
|
func (g *gated) Put(ctx context.Context, key string, r io.Reader, size int64) error {
|
|
if !g.write.Load() {
|
|
return fmt.Errorf("%w: server: 403 Forbidden", remote.ErrForbidden)
|
|
}
|
|
return g.Backend.Put(ctx, key, r, size)
|
|
}
|
|
|
|
// A read-only device keeps pulling its teammates' changes and journals its own
|
|
// edits locally, but nothing of its own ever reaches the remote — and the
|
|
// cycle says ReadOnly, never Offline, so the user is told rather than left
|
|
// watching a silent retry loop.
|
|
func TestReadOnlyDevicePullsOnly(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
gate := newGated(be)
|
|
b := newDevice(t, "devb", gate)
|
|
|
|
write(t, a.Folder, "shared.md", "from A")
|
|
cycle(t, a)
|
|
|
|
gate.write.Store(false) // B is downgraded to read
|
|
write(t, b.Folder, "mine.md", "local only")
|
|
res, err := b.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.ReadOnly || res.Offline {
|
|
t.Fatalf("read-only push: %+v, want ReadOnly and not Offline", res)
|
|
}
|
|
if got := read(t, b.Folder, "shared.md"); got != "from A" {
|
|
t.Fatalf("b shared.md = %q — a read-only device must still pull", got)
|
|
}
|
|
// B's own edit is journaled locally...
|
|
ops, err := b.Store.DeviceOps(b.Device.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ops) != 1 || ops[0].Path != "mine.md" {
|
|
t.Fatalf("b's local journal = %+v, want one op for mine.md", ops)
|
|
}
|
|
// ...and never lands in the shared remote, however many cycles run.
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := b.Cycle(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
c := newDevice(t, "devc", be)
|
|
cycle(t, c)
|
|
if _, err := os.Stat(filepath.Join(c.Folder, "mine.md")); !os.IsNotExist(err) {
|
|
t.Fatal("a read-only device's edit reached the remote")
|
|
}
|
|
// The state is persisted so `bdrive status` can report it without a cycle.
|
|
if st, err := b.Store.LoadSync(); err != nil || st.Access != store.AccessReadOnly {
|
|
t.Fatalf("persisted access = %q (%v), want read-only", st.Access, err)
|
|
}
|
|
|
|
// Restoring write self-heals: the held-back op finally goes out.
|
|
gate.write.Store(true)
|
|
if res := cycle(t, b); !res.Pushed {
|
|
t.Fatalf("re-granted device did not push: %+v", res)
|
|
}
|
|
cycle(t, c)
|
|
if got := read(t, c.Folder, "mine.md"); got != "local only" {
|
|
t.Fatalf("c mine.md = %q after the re-grant", got)
|
|
}
|
|
if st, _ := b.Store.LoadSync(); st.Access != store.AccessOK {
|
|
t.Fatalf("persisted access = %q after re-grant, want cleared", st.Access)
|
|
}
|
|
}
|
|
|
|
// A device whose access is revoked entirely pauses: the cycle reports
|
|
// NoAccess (not Offline), the working folder is left byte-for-byte alone —
|
|
// revoking access must never look like the hub deleting someone's files — and
|
|
// re-granting resumes normal sync with no manual step.
|
|
func TestNoAccessPausesSync(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
gate := newGated(be)
|
|
c := newDevice(t, "devc", gate)
|
|
|
|
write(t, a.Folder, "doc.md", "v1")
|
|
cycle(t, a)
|
|
cycle(t, c)
|
|
if got := read(t, c.Folder, "doc.md"); got != "v1" {
|
|
t.Fatalf("c doc.md = %q, want v1", got)
|
|
}
|
|
|
|
// A moves on while C's access is cut.
|
|
write(t, a.Folder, "doc.md", "v2")
|
|
write(t, a.Folder, "new.md", "after the cut")
|
|
cycle(t, a)
|
|
|
|
gate.read.Store(false)
|
|
gate.write.Store(false)
|
|
before := snapshotDir(t, c.Folder)
|
|
write(t, c.Folder, "cs-own.md", "written while cut off")
|
|
before["cs-own.md"] = "written while cut off"
|
|
|
|
for i := 0; i < 3; i++ {
|
|
res, err := c.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.NoAccess || res.Offline {
|
|
t.Fatalf("cycle %d: %+v, want NoAccess and not Offline", i, res)
|
|
}
|
|
if res.Materialized != 0 || res.Pushed {
|
|
t.Fatalf("cycle %d touched the folder or pushed: %+v", i, res)
|
|
}
|
|
}
|
|
if got := snapshotDir(t, c.Folder); !maps.Equal(got, before) {
|
|
t.Fatalf("working folder changed while access was revoked:\n got %v\nwant %v", got, before)
|
|
}
|
|
if st, _ := c.Store.LoadSync(); st.Access != store.AccessNone {
|
|
t.Fatalf("persisted access = %q, want no-access", st.Access)
|
|
}
|
|
|
|
// Re-granting needs no intervention: the next cycle converges both ways.
|
|
gate.read.Store(true)
|
|
gate.write.Store(true)
|
|
cycle(t, c)
|
|
if got := read(t, c.Folder, "doc.md"); got != "v2" {
|
|
t.Fatalf("c doc.md = %q after the re-grant, want v2", got)
|
|
}
|
|
if got := read(t, c.Folder, "new.md"); got != "after the cut" {
|
|
t.Fatalf("c new.md = %q after the re-grant", got)
|
|
}
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "cs-own.md"); got != "written while cut off" {
|
|
t.Fatalf("a cs-own.md = %q — C's held-back edit should arrive", got)
|
|
}
|
|
}
|
|
|
|
// snapshotDir reads every file under folder (excluding .bdrive) as path→content.
|
|
func snapshotDir(t *testing.T, folder string) map[string]string {
|
|
t.Helper()
|
|
out := map[string]string{}
|
|
err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(folder, p)
|
|
rel = filepath.ToSlash(rel)
|
|
if strings.HasPrefix(rel, config.ProjectDir+"/") {
|
|
return nil
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out[rel] = string(b)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func touch(t *testing.T, folder, rel string, when time.Time) {
|
|
t.Helper()
|
|
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
|
if err := os.Chtimes(abs, when, when); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// TestLogDisplayOrder builds exactly the skew that made `bdrive log`
|
|
// unreadable: device B commits after pulling A, so B's op carries the HIGHER
|
|
// lamport, while B's file was written hours EARLIER on the wall clock. Causal
|
|
// order and clock order disagree — the display sort must follow the clock.
|
|
func TestLogDisplayOrder(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a, b := newDevice(t, "deva", be), newDevice(t, "devb", be)
|
|
|
|
write(t, b.Folder, "early.md", "written two hours ago")
|
|
touch(t, b.Folder, "early.md", time.Now().Add(-2*time.Hour))
|
|
write(t, a.Folder, "late.md", "written just now")
|
|
|
|
cycle(t, a) // lamport 1
|
|
cycle(t, b) // pulls A (lamport 1), commits its own op at lamport 2
|
|
cycle(t, a) // pull B, so A's store holds both journals
|
|
|
|
entries, err := LogEntries(a.Store, "", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("got %d entries, want 2: %+v", len(entries), entries)
|
|
}
|
|
// Precondition: the two orders really do disagree. Without this, the
|
|
// assertion below would pass even if SortForDisplay did nothing.
|
|
if entries[0].Path != "early.md" {
|
|
t.Fatalf("causal order should lead with the higher-lamport op early.md, got %q", entries[0].Path)
|
|
}
|
|
|
|
SortForDisplay(entries)
|
|
if entries[0].Path != "late.md" {
|
|
t.Fatalf("display order should lead with the newest file late.md, got %q", entries[0].Path)
|
|
}
|
|
assertNonIncreasing(t, entries)
|
|
}
|
|
|
|
// TestLogDisplayTimeIsEditTime covers the other half of the report: 22 ops of
|
|
// one agent run all printed the same stamp because Time is the commit time.
|
|
// Two files written a minute apart and committed in ONE cycle must carry two
|
|
// different display times.
|
|
func TestLogDisplayTimeIsEditTime(t *testing.T) {
|
|
a := newDevice(t, "deva", sharedRemote(t))
|
|
write(t, a.Folder, "first.md", "one")
|
|
write(t, a.Folder, "second.md", "two")
|
|
touch(t, a.Folder, "first.md", time.Now().Add(-2*time.Minute))
|
|
touch(t, a.Folder, "second.md", time.Now().Add(-1*time.Minute))
|
|
cycle(t, a)
|
|
|
|
entries, err := LogEntries(a.Store, "", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("got %d entries, want 2", len(entries))
|
|
}
|
|
byPath := map[string]journal.Op{}
|
|
for _, op := range entries {
|
|
if op.Mtime.IsZero() {
|
|
t.Fatalf("put op %s has no Mtime", op.Path)
|
|
}
|
|
// Commit times are microseconds apart — indistinguishable at the
|
|
// second resolution `bdrive log` prints, which is why one scan used
|
|
// to collapse a whole agent run onto a single stamp.
|
|
if d := op.Time.Sub(entries[0].Time); d > time.Second || d < -time.Second {
|
|
t.Fatalf("expected both ops committed in one batch, Time differs by %v", d)
|
|
}
|
|
byPath[op.Path] = op
|
|
}
|
|
gap := DisplayTime(byPath["second.md"]).Sub(DisplayTime(byPath["first.md"]))
|
|
if gap < 30*time.Second {
|
|
t.Fatalf("display times only %v apart; one scan collapsed them again", gap)
|
|
}
|
|
|
|
SortForDisplay(entries)
|
|
if entries[0].Path != "second.md" {
|
|
t.Fatalf("newest edit should lead, got %q", entries[0].Path)
|
|
}
|
|
}
|
|
|
|
// TestSortForDisplayFallsBackToTime covers journals written before Op.Mtime
|
|
// existed, and deletes, which never carry one: they sort and print by Time and
|
|
// must not sink to the bottom as zero-time rows.
|
|
func TestSortForDisplayFallsBackToTime(t *testing.T) {
|
|
base := time.Date(2026, 7, 29, 6, 0, 0, 0, time.UTC)
|
|
mk := func(path string, lamport int64, kind string, commit, mtime time.Time) journal.Op {
|
|
return journal.Op{
|
|
Seq: lamport, Lamport: lamport, Time: commit, Mtime: mtime,
|
|
Device: "deva", Kind: kind, Path: path,
|
|
}
|
|
}
|
|
ops := []journal.Op{
|
|
mk("legacy-old.md", 1, journal.KindPut, base, time.Time{}), // no mtime
|
|
mk("gone.md", 2, journal.KindDelete, base.Add(3*time.Minute), time.Time{}),
|
|
mk("legacy-new.md", 3, journal.KindPut, base.Add(4*time.Minute), time.Time{}),
|
|
mk("fresh.md", 4, journal.KindPut, base.Add(9*time.Minute), base.Add(2*time.Minute)),
|
|
}
|
|
SortForDisplay(ops)
|
|
|
|
want := []string{"legacy-new.md", "gone.md", "fresh.md", "legacy-old.md"}
|
|
for i, w := range want {
|
|
if ops[i].Path != w {
|
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, ops[i].Path, w, paths(ops))
|
|
}
|
|
}
|
|
assertNonIncreasing(t, ops)
|
|
}
|
|
|
|
func paths(ops []journal.Op) []string {
|
|
out := make([]string, len(ops))
|
|
for i, op := range ops {
|
|
out[i] = op.Path
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertNonIncreasing(t *testing.T, ops []journal.Op) {
|
|
t.Helper()
|
|
for i := 1; i < len(ops); i++ {
|
|
if DisplayTime(ops[i]).After(DisplayTime(ops[i-1])) {
|
|
t.Fatalf("display order not newest-first at %d: %v then %v",
|
|
i, DisplayTime(ops[i-1]), DisplayTime(ops[i]))
|
|
}
|
|
}
|
|
}
|