mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
495f72c43d
commit
c3b9fa5858
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user