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

70 lines
2.3 KiB
Go

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
}