mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
`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>
70 lines
2.3 KiB
Go
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
|
|
}
|