Files
c3b9fa5858 fix(cli): status looks at the folder, not just the cache (BEA-106) (#171)
`bdrive status` answered from the state cache and the journal and never
looked at the working folder. With the daemon stopped, an edit nobody has
scanned is in neither — so the one command that answers "is this folder in
sync?" reported `pending: 0` with the change sitting right there. A wrong
"you're clean" is worse than no answer.

syncer.Drift is a sibling of Explain/SyncedFiles with the same contract:
loadFilter + walkFolder + the scan's own size+mtime compare, and nothing
else. status prints it as a `local:` line, distinct from `pending` — they
are different states and a change can be in either or both.

The load-bearing property is that it stays a pure read. status is what
someone runs when sync is stuck; a version that scanned-and-committed would
change what it was asked to describe, and would write ops from a command
nobody expects to write. Pinned by a test hashing the device journal and the
state cache before and after.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:04:14 -07:00

138 lines
4.1 KiB
Go

package syncer
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func drift(t *testing.T, s *Session) (added, modified, removed int) {
t.Helper()
cache, err := s.Store.LoadCache(s.MountID)
if err != nil {
t.Fatal(err)
}
st, err := s.Store.LoadSync()
if err != nil {
t.Fatal(err)
}
a, m, r, err := Drift(s.Folder, nil, st.IgnoreAccepted, cache)
if err != nil {
t.Fatal(err)
}
return a, m, r
}
func fileSum(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return "(absent)"
}
t.Fatal(err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// TestDriftSeesUnscannedWork is the bug BEA-106 reports: with no cycle in
// between (the daemon stopped), work on disk is in neither the cache nor the
// journal, and `bdrive status` called it clean.
func TestDriftSeesUnscannedWork(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
a.MountID = "m1"
write(t, a.Folder, "index.md", "one")
write(t, a.Folder, "docs/keep.md", "keep")
write(t, a.Folder, "docs/gone.md", "gone")
cycle(t, a)
if add, mod, rm := drift(t, a); add|mod|rm != 0 {
t.Fatalf("clean folder drifted: %d added, %d modified, %d removed", add, mod, rm)
}
// No cycle from here on: this is the stopped-daemon case.
write(t, a.Folder, "index.md", "one\ntwo") // modified
write(t, a.Folder, "notes/new.md", "new") // added
os.Remove(filepath.Join(a.Folder, "docs/gone.md")) // removed
add, mod, rm := drift(t, a)
if add != 1 || mod != 1 || rm != 1 {
t.Fatalf("drift = %d added, %d modified, %d removed; want 1, 1, 1", add, mod, rm)
}
// And the cycle that follows agrees: three ops, no more, no fewer.
res := cycle(t, a)
if res.LocalOps != 3 {
t.Fatalf("cycle after drift journalled %d ops, want 3", res.LocalOps)
}
if add, mod, rm := drift(t, a); add|mod|rm != 0 {
t.Fatalf("drift after cycle = %d, %d, %d; want all zero", add, mod, rm)
}
}
// TestDriftWritesNothing is the load-bearing one: `status` must stay a pure
// read. Any op, any journal line, any cache rewrite from this call would mean
// the command someone runs when sync is stuck changed what it was describing.
func TestDriftWritesNothing(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
a.MountID = "m1"
write(t, a.Folder, "index.md", "one")
cycle(t, a)
statePath := filepath.Join(a.Store.Dir(), "state-m1.json")
journalPath := a.Store.JournalPath(a.Device.ID)
beforeState, beforeJournal := fileSum(t, statePath), fileSum(t, journalPath)
write(t, a.Folder, "index.md", "one\ntwo")
write(t, a.Folder, "brand-new.md", "new")
cache, err := a.Store.LoadCache("m1")
if err != nil {
t.Fatal(err)
}
nCache := len(cache)
if _, _, _, err := Drift(a.Folder, nil, "", cache); err != nil {
t.Fatal(err)
}
if len(cache) != nCache {
t.Fatalf("Drift mutated the cache it was handed: %d entries, was %d", len(cache), nCache)
}
if got := fileSum(t, statePath); got != beforeState {
t.Fatal("Drift rewrote the state cache")
}
if got := fileSum(t, journalPath); got != beforeJournal {
t.Fatal("Drift wrote to the device journal")
}
// Nothing was committed, so the very next cycle still has both changes.
if res := cycle(t, a); res.LocalOps != 2 {
t.Fatalf("cycle after Drift journalled %d ops, want 2", res.LocalOps)
}
}
// TestDriftRespectsIgnore: an edit the cycle would never send is not drift.
func TestDriftRespectsIgnore(t *testing.T) {
a := newDevice(t, "deva", sharedRemote(t))
a.MountID = "m1"
write(t, a.Folder, ".bdriveignore", "build/\n")
write(t, a.Folder, "index.md", "one")
cycle(t, a)
write(t, a.Folder, "build/out.bin", "junk")
write(t, a.Folder, "build/nested/more.bin", "junk")
if add, mod, rm := drift(t, a); add|mod|rm != 0 {
t.Fatalf("ignored paths counted as drift: %d, %d, %d", add, mod, rm)
}
// A path that becomes ignored after it was synced is dropped from the
// cache without a delete op — so it is not "removed" drift either.
write(t, a.Folder, "secret.md", "s")
cycle(t, a)
write(t, a.Folder, ".bdriveignore", "build/\nsecret.md\n")
if _, _, rm := drift(t, a); rm != 0 {
t.Fatalf("newly ignored path counted as removed: %d", rm)
}
}