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>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-18 16:04:14 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 495f72c43d
commit c3b9fa5858
7 changed files with 319 additions and 6 deletions
+1 -1
View File
@@ -259,7 +259,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each agent platform's user config (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking. Once per machine, covering every session; run automatically by `bdrive init`; idempotent (`--agent` overrides detection) |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
| `bdrive status [folder]` | Projects, daemon state, pending changes, and any synced files that looked like they held credentials when they last changed |
| `bdrive status [folder]` | Projects, daemon state, two separate change counts — `pending` (journalled, not yet pushed) and `local` (on disk, not yet scanned — what a stopped daemon leaves invisible) — and any synced files that looked like they held credentials when they last changed. Pure local read: no ops, no journal writes, no network |
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file — newest first by the time shown, which is when the file was written (ops recorded before this was tracked, and deletes, show their sync time instead) |
| `bdrive restore <file> [version]` | Put an earlier version of a file back, as a new change (`--list` shows the versions; no version = the previous one). Nothing is erased and it syncs everywhere like any edit. To un-create a file a run *created*, use **undo — remove file** on that row in the hub's History view — or **undo this run** in the run card's header to put back every file that run touched at once |
| `bdrive export [folder]` | Export the whole project — every device's journal, all blobs, full history — from its hub to a portable `.tar.gz` (`-o` names the file) |
+8 -1
View File
@@ -98,7 +98,7 @@ classDiagram
+walkFolder(folder, filter, fn)
verdict: vSync vSkipFile vDescend vPruneDir vNested
}
note for walkFolder "walk.go — the ONLY copy of the sync predicate; scan, Explain, Measure and SyncedFiles all go through it, so what --explain reports, what init warns about and what bdrive grep searches cannot drift from what leaves"
note for walkFolder "walk.go — the ONLY copy of the sync predicate; scan, Explain, Measure, SyncedFiles and Drift all go through it, so what --explain reports, what init warns about, what bdrive grep searches and what status calls unscanned cannot drift from what leaves"
class Measure {
+Measure(folder, include) files, bytes
@@ -110,6 +110,11 @@ classDiagram
}
note for SyncedFiles "walk.go — the mount-relative paths that sync, in walk order: what bdrive grep searches, so a .bdriveignore rule or a narrowed scope excludes a file from search exactly as it excludes it from sync. Deliberately NOT Explain, which countFiles every pruned dir — a grep in a repo with node_modules/ would walk it in full for a count it discards"
class Drift {
+Drift(folder, include, accepted, cache) added, modified, removed
}
note for Drift "drift.go — the `local:` line in bdrive status: what is on disk that the state cache has not seen, using the scan's own size+mtime compare. Pure read like its siblings, and load-bearing that it stays one: status is what someone runs when sync is stuck, so it stores no blob, mints no op, rewrites no cache — it does not even mutate the cache map it is handed, which status prints `files:` from"
class Explain {
+Explain(folder, include, accepted) two lists
+NotSyncedFiles(entries) int
@@ -186,6 +191,8 @@ classDiagram
Session --> Filter : SkipUp on scan, Skip on materialize
Session --> walkFolder : scan
Explain --> walkFolder : same predicate
Drift --> walkFolder : same predicate
Drift --> Filter : own fresh instance
SyncedFiles --> walkFolder : same predicate
SyncedFiles --> Filter : own fresh instance
Measure --> walkFolder : same predicate
+16 -2
View File
@@ -207,8 +207,10 @@ func statusCmd() *cobra.Command {
}
first = false
folder := mi.Path
var include []string
if proj, ok, err := config.LoadProject(folder); err == nil && ok {
mi.Volume, mi.Remote = proj.Volume, proj.Remote // folder config wins
include = proj.Include
} else {
fmt.Printf("%s\n (folder missing — moved or deleted; run `bdrive init` at its new location)\n", folder)
continue
@@ -237,8 +239,8 @@ func statusCmd() *cobra.Command {
if err != nil {
continue
}
cache, err := sess.Store.LoadCache(id)
if err == nil {
cache, cacheErr := sess.Store.LoadCache(id)
if cacheErr == nil {
var total int64
for _, c := range cache {
total += c.Size
@@ -258,6 +260,18 @@ func statusCmd() *cobra.Command {
pending = 0
}
fmt.Printf(" pending: %d local change(s) not yet pushed\n", pending)
// `pending` counts what the journal holds; it says nothing
// about the folder, so with the daemon stopped an edit
// nobody has scanned yet is in neither. Drift is that
// second, separate state — a read-only walk, no ops, no
// journal, no hub. It degrades to no line rather than
// failing the command.
if cacheErr == nil {
if added, modified, gone, dErr := syncer.Drift(folder, include, st.IgnoreAccepted, cache); dErr == nil {
fmt.Printf(" local: %d change(s) not yet scanned (%d new, %d edited, %d removed)\n",
added+modified+gone, added, modified, gone)
}
}
switch st.Access {
case store.AccessReadOnly:
fmt.Printf(" access: read-only (pull only) — %d local change(s) stay on this device\n", pending)
+69
View File
@@ -0,0 +1,69 @@
package syncer
import (
"io/fs"
"github.com/runbear-io/beardrive/internal/store"
)
// Drift reports what the working folder holds that the state cache does not
// yet know about: files added, files whose size or mtime moved, and cached
// paths gone from disk.
//
// It is a pure read, the same contract as Explain and SyncedFiles: no Session,
// no volume lock, no network, no writes — and it neither stores blobs nor
// mints ops nor touches the cache it is handed. `bdrive status` is what someone
// runs when sync is stuck; a version of it that scanned-and-committed would
// change the thing it was asked to describe.
//
// The comparison is the scan's own cheap change detection (size + mtime against
// store.CachedFile), reached through walkFolder so the file verdict is the
// cycle's verdict and not a second copy of it.
//
// accepted is the ignore text this device has accepted (store.SyncState's
// IgnoreAccepted; "" when there is none), for the reason Explain documents: the
// scan applies Filter.SkipUp, and a drift count that omitted it would disagree
// with the very next cycle.
func Drift(folder string, include []string, accepted string, cache map[string]store.CachedFile) (added, modified, removed int, err error) {
// A fresh filter: addNestedMount mutates it during the walk, so this must
// never be shared with a live cycle.
filter, err := loadFilter(folder, include)
if err != nil {
return 0, 0, 0, err
}
filter.AcceptRules(accepted)
seen := make(map[string]bool, len(cache))
err = walkFolder(folder, filter, func(_, rel string, d fs.DirEntry, v verdict) error {
if v != vSync {
return nil
}
info, err := d.Info()
if err != nil {
return nil // vanished or unreadable; the next scan retries, as everywhere
}
seen[rel] = true
c, ok := cache[rel]
switch {
case !ok:
added++
case c.Size != info.Size() || c.MTimeNS != info.ModTime().UnixNano():
modified++
}
return nil
})
if err != nil {
return 0, 0, 0, err
}
for rel := range cache {
// The same drops scan applies before it would mint a delete: a path
// the walk cannot have produced, or one that is newly filtered, is
// dropped from the cache without a delete op — so it is not drift.
if seen[rel] || neverSync(rel) || filter.Skip(rel) {
continue
}
removed++
}
return added, modified, removed, nil
}
+137
View File
@@ -0,0 +1,137 @@
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)
}
}
+67
View File
@@ -995,3 +995,70 @@ func TestCLIShareSecretGate(t *testing.T) {
t.Fatalf("forced link does not serve: %d %s", resp.StatusCode, body)
}
}
// TestCLIStatusReportsUnscannedWork is BEA-106: with the daemon stopped,
// `status` answered from the state cache and the journal — neither of which
// has seen an edit nobody scanned — and reported the folder clean. A wrong
// "you're clean" is worse than no answer, so status now walks the folder
// read-only and reports that drift on its own line.
func TestCLIStatusReportsUnscannedWork(t *testing.T) {
e := newCLIEnv(t)
run := e.run
work := t.TempDir()
if err := os.WriteFile(filepath.Join(work, "index.md"), []byte("# Index\n"), 0o644); err != nil {
t.Fatal(err)
}
if out, err := run(work, "init", "--name", "status-drift", "--yes"); err != nil {
t.Fatalf("init: %v\n%s", err, out)
}
defer run(work, "stop", work)
if out, err := run(work, "sync"); err != nil {
t.Fatalf("sync: %v\n%s", err, out)
}
if out, err := run(work, "stop", work); err != nil {
t.Fatalf("stop: %v\n%s", err, out)
}
// Clean and stopped: the line is present and reads zero.
out, err := run(work, "status")
if err != nil {
t.Fatalf("status: %v\n%s", err, out)
}
if !strings.Contains(out, "local: 0 change(s) not yet scanned") {
t.Fatalf("clean status missing a zeroed local line:\n%s", out)
}
// Now the reported case: edit with no daemon to scan it.
if err := os.WriteFile(filepath.Join(work, "index.md"), []byte("# Index\n\nappended by hand\n"), 0o644); err != nil {
t.Fatal(err)
}
out, err = run(work, "status")
if err != nil {
t.Fatalf("status: %v\n%s", err, out)
}
if !strings.Contains(out, "local: 1 change(s) not yet scanned (0 new, 1 edited, 0 removed)") {
t.Fatalf("status did not report the unscanned edit:\n%s", out)
}
// And it stays distinct from `pending`, which is still legitimately zero.
if !strings.Contains(out, "pending: 0 local change(s) not yet pushed") {
t.Fatalf("status conflated drift with pending:\n%s", out)
}
// status is a pure read: the edit is still uncommitted afterwards, so the
// sync that follows is the one that journals it. (`stop` paused this
// folder, so resuming it is `init` again.)
if out, err := run(work, "init", "--yes"); err != nil {
t.Fatalf("init resume: %v\n%s", err, out)
}
if out, err := run(work, "sync"); err != nil {
t.Fatalf("sync: %v\n%s", err, out)
}
out, err = run(work, "status")
if err != nil {
t.Fatalf("status: %v\n%s", err, out)
}
if !strings.Contains(out, "local: 0 change(s) not yet scanned") {
t.Fatalf("drift did not clear after a sync:\n%s", out)
}
}
+21 -2
View File
@@ -241,9 +241,28 @@ on the hub".
If a teammate edits the file between your prune and their next sync, their
version wins and the path comes back. Run `--prune` again once they have synced.
### `bdrive status` — and the two degraded access states
### `bdrive status` the two change counts, and the two degraded access states
Alongside `pending`, `status` prints a `secrets:` block naming any synced file
`status` reports local work in two counts, because they are two different
states and a change can be in either or both:
```
pending: 0 local change(s) not yet pushed
local: 1 change(s) not yet scanned (0 new, 1 edited, 0 removed)
```
- **`pending`** — journalled by a sync cycle, not yet accepted by the hub.
- **`local`** — sitting in the working folder, not yet scanned by any cycle.
This is what a stopped daemon leaves behind: edit a file with `bdrive stop`
in effect and nothing has looked at the folder, so `pending` is honestly
zero while the change is right there. The next sync picks it up.
The `local` count is a read-only walk of the folder — the same filter the
cycle uses, so a `.bdriveignore`d path never counts — and it commits no ops,
writes no journal, and contacts no hub. `status` describes; it never changes
what it is describing.
Alongside those, `status` prints a `secrets:` block naming any synced file
that looked like it held a credential when it last changed, and an `access:`
line whenever the hub is refusing this device. Neither access state is the same
as being offline, and neither ever touches your files: