mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A device's first cycle on a volume was treated as a concurrent edit for every path the project already held. Whichever side's clock happened to sort higher won -- so a joiner's seeded .bdriveignore or agent-written AGENTS.md could replace the team's -- and the loser landed beside it as a .bdrive-conflict-<device>-<time> file. A first cycle is a join, not an edit. Cycle step 1b holds the scan's ops back over the pull and demotes any whose path the project already holds to lamport 0, which sorts under every op a project can carry (scan's clock starts at 1). The project's version then wins deterministically on every device, and conflictCopies skips those ops the way it already skips re-asserted ones. The local content is still journaled and pushed, so it stays in History and `bdrive restore --list <path>` can bring it back. Reported as `adopted: N`. Concurrent edits after the join are untouched. Claude-Session: https://claude.ai/code/session_01GLCyEWP2XZhYjBssvdCDQm Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
406 lines
14 KiB
Go
406 lines
14 KiB
Go
package syncer
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
"github.com/runbear-io/beardrive/internal/templates"
|
|
)
|
|
|
|
// End-to-end scenarios for the init knowledge flows documented in
|
|
// plugin/commands/init.md and the beardrive skill ("Connecting knowledge
|
|
// tooling"): a shared subfolder carved out of a repo, a teammate connecting
|
|
// over pre-existing local content, and a nested mount (e.g. a team knowledge
|
|
// folder inside a personal brain that is itself a mount) syncing through its
|
|
// own project.
|
|
|
|
// deviceAt is newDevice with an explicit working folder, for topologies where
|
|
// the folder's location matters (nested mounts).
|
|
func deviceAt(t *testing.T, name, folder string, backend remote.Backend) *Session {
|
|
t.Helper()
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Session{
|
|
Folder: folder,
|
|
Store: st,
|
|
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
|
|
Backend: backend,
|
|
}
|
|
}
|
|
|
|
func conflictFiles(t *testing.T, folder string) []string {
|
|
t.Helper()
|
|
var out []string
|
|
err := filepath.WalkDir(folder, func(p string, d os.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() && strings.Contains(d.Name(), ".bdrive-conflict-") {
|
|
out = append(out, p)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// A repo shares only its knowledge subfolder (a legacy include-list mount);
|
|
// a teammate connects from their own checkout with the same scope. Wiki
|
|
// content flows both ways; each side's code never leaves its machine.
|
|
func TestSharedSubfolderScopeBothDevices(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
|
|
write(t, a.Folder, "Wiki/home.md", "welcome")
|
|
write(t, a.Folder, "src/code.go", "package main")
|
|
cycle(t, a)
|
|
|
|
write(t, b.Folder, ".bdrive/config.json", `{"include": ["Wiki/"]}`)
|
|
write(t, b.Folder, "main.py", "print('local only')")
|
|
res := cycle(t, b)
|
|
if res.LocalOps != 0 {
|
|
t.Fatalf("b journaled %d ops for out-of-scope files, want 0", res.LocalOps)
|
|
}
|
|
if got := read(t, b.Folder, "Wiki/home.md"); got != "welcome" {
|
|
t.Fatalf("Wiki/home.md = %q, want welcome", got)
|
|
}
|
|
|
|
// Wiki edits flow back; code never crosses in either direction.
|
|
write(t, b.Folder, "Wiki/home.md", "welcome v2")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "Wiki/home.md"); got != "welcome v2" {
|
|
t.Fatalf("a Wiki/home.md = %q, want welcome v2", got)
|
|
}
|
|
for folder, absent := range map[string]string{a.Folder: "main.py", b.Folder: "src/code.go"} {
|
|
if _, err := os.Stat(filepath.Join(folder, absent)); !os.IsNotExist(err) {
|
|
t.Fatalf("%s leaked across devices", absent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The git-handoff teammate story: after the first user connects docs/, a
|
|
// teammate's checkout already holds the same files. Connecting must converge
|
|
// with zero conflict copies (identical content is adopted, not duplicated).
|
|
func TestConnectWithIdenticalLocalContent(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, "docs/guide.md", "v1")
|
|
write(t, a.Folder, "docs/setup.md", "steps")
|
|
cycle(t, a)
|
|
|
|
b := newDevice(t, "devb", be)
|
|
write(t, b.Folder, "docs/guide.md", "v1")
|
|
write(t, b.Folder, "docs/setup.md", "steps")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
for _, s := range []*Session{a, b} {
|
|
if got := read(t, s.Folder, "docs/guide.md"); got != "v1" {
|
|
t.Fatalf("guide.md = %q, want v1", got)
|
|
}
|
|
if c := conflictFiles(t, s.Folder); len(c) != 0 {
|
|
t.Fatalf("identical content produced conflict copies: %v", c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Same story with a stale divergent copy. Joining is an adoption, not a merge:
|
|
// the project's version wins on every device — even though the joiner's copy is
|
|
// the later write — and no conflict copy is made. The joiner's content is not
|
|
// lost, it is journaled (and pushed) as a superseded version, which is what
|
|
// `bdrive restore` reads.
|
|
func TestConnectWithDivergentLocalContent(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, "docs/guide.md", "hub version")
|
|
cycle(t, a)
|
|
|
|
b := newDevice(t, "devb", be)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, b.Folder, "docs/guide.md", "stale local version")
|
|
if res := cycle(t, b); res.Adopted != 1 {
|
|
t.Fatalf("Adopted = %d, want 1", res.Adopted)
|
|
}
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
for _, s := range []*Session{a, b} {
|
|
if got := read(t, s.Folder, "docs/guide.md"); got != "hub version" {
|
|
t.Fatalf("%s guide.md = %q, want the project's version", s.Device.ID, got)
|
|
}
|
|
if c := conflictFiles(t, s.Folder); len(c) != 0 {
|
|
t.Fatalf("%s: joining made conflict copies: %v", s.Device.ID, c)
|
|
}
|
|
}
|
|
// The superseded content is still in history, on both devices.
|
|
for _, s := range []*Session{a, b} {
|
|
ops, err := s.Store.AllOps()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var found bool
|
|
for _, op := range ops {
|
|
if op.Path == "docs/guide.md" && s.Store.HasBlob(op.Blob) {
|
|
if body, err := os.ReadFile(s.Store.BlobPath(op.Blob)); err == nil && string(body) == "stale local version" {
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("%s: the joiner's version is not recoverable from history", s.Device.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The reported connect experience: a folder that already holds a .bdriveignore
|
|
// (`bdrive init` seeds one) and an agent-written AGENTS.md joins a project that
|
|
// has its own versions of both. Neither may fork into a
|
|
// `.bdriveignore.bdrive-conflict-<device>-<time>` file, and neither may have the
|
|
// joiner's copy overwrite the team's. A real concurrent edit AFTER the join
|
|
// still conflict-copies — that is a different situation and keeps its old
|
|
// behavior.
|
|
func TestConnectDoesNotForkIgnoreAndAgentsFiles(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, ".bdriveignore", "node_modules/\n*.log\n")
|
|
write(t, a.Folder, "AGENTS.md", "team instructions\n")
|
|
cycle(t, a)
|
|
|
|
b := newDevice(t, "devb", be)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, b.Folder, ".bdriveignore", "# BearDrive starter\n.DS_Store\n")
|
|
write(t, b.Folder, "AGENTS.md", "notes my agent wrote here\n")
|
|
res := cycle(t, b)
|
|
if res.Adopted != 2 {
|
|
t.Fatalf("Adopted = %d, want 2 (.bdriveignore and AGENTS.md)", res.Adopted)
|
|
}
|
|
if res.Conflicts != 0 {
|
|
t.Fatalf("connecting made %d conflict copies, want 0", res.Conflicts)
|
|
}
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
for _, s := range []*Session{a, b} {
|
|
if c := conflictFiles(t, s.Folder); len(c) != 0 {
|
|
t.Fatalf("%s: connecting littered the folder: %v", s.Device.ID, c)
|
|
}
|
|
if got := read(t, s.Folder, "AGENTS.md"); got != "team instructions\n" {
|
|
t.Fatalf("%s AGENTS.md = %q, want the team's version", s.Device.ID, got)
|
|
}
|
|
if got := read(t, s.Folder, ".bdriveignore"); got != "node_modules/\n*.log\n" {
|
|
t.Fatalf("%s .bdriveignore = %q, want the team's version", s.Device.ID, got)
|
|
}
|
|
}
|
|
|
|
// Now a genuine concurrent edit between two devices that share the project.
|
|
write(t, a.Folder, "AGENTS.md", "a's edit\n")
|
|
write(t, b.Folder, "AGENTS.md", "b's edit\n")
|
|
cycle(t, a)
|
|
if res := cycle(t, b); res.Conflicts != 1 {
|
|
t.Fatalf("a real concurrent edit made %d conflict copies, want 1", res.Conflicts)
|
|
}
|
|
}
|
|
|
|
// The follower-brain topology: a personal folder is a mount on one project
|
|
// while a team knowledge folder nested inside it is a mount on another.
|
|
// Each project sees only its own files, in both directions, even as both
|
|
// actively sync.
|
|
func TestNestedMountSyncsIndependently(t *testing.T) {
|
|
personal := sharedRemote(t)
|
|
team := sharedRemote(t)
|
|
|
|
// Alice: personal brain mount with a nested team mount inside it.
|
|
aliceRoot := t.TempDir()
|
|
alice := deviceAt(t, "alice", aliceRoot, personal)
|
|
aliceTeam := deviceAt(t, "alice-team", filepath.Join(aliceRoot, "team"), team)
|
|
write(t, aliceRoot, "private.md", "my captures")
|
|
write(t, aliceRoot, "team/.bdrive/config.json", `{"mount_id":"m-team"}`)
|
|
write(t, aliceRoot, "team/plan.md", "roadmap v1")
|
|
cycle(t, alice)
|
|
cycle(t, aliceTeam)
|
|
|
|
// Bob syncs only the team project; Alice's laptop syncs only personal.
|
|
bobTeam := newDevice(t, "bob-team", team)
|
|
cycle(t, bobTeam)
|
|
if got := read(t, bobTeam.Folder, "plan.md"); got != "roadmap v1" {
|
|
t.Fatalf("team plan.md = %q, want roadmap v1", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(bobTeam.Folder, "private.md")); !os.IsNotExist(err) {
|
|
t.Fatal("personal file leaked into the team project")
|
|
}
|
|
laptop := newDevice(t, "alice-laptop", personal)
|
|
cycle(t, laptop)
|
|
if got := read(t, laptop.Folder, "private.md"); got != "my captures" {
|
|
t.Fatalf("private.md = %q, want my captures", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(laptop.Folder, "team")); !os.IsNotExist(err) {
|
|
t.Fatal("team folder leaked into the personal project")
|
|
}
|
|
|
|
// Bob's team edit reaches Alice's nested mount; her personal project
|
|
// must not journal the change it can see on disk.
|
|
write(t, bobTeam.Folder, "plan.md", "roadmap v2")
|
|
cycle(t, bobTeam)
|
|
cycle(t, aliceTeam)
|
|
if got := read(t, aliceRoot, "team/plan.md"); got != "roadmap v2" {
|
|
t.Fatalf("alice team/plan.md = %q, want roadmap v2", got)
|
|
}
|
|
if res := cycle(t, alice); res.LocalOps != 0 {
|
|
t.Fatalf("personal mount journaled %d ops for nested team content, want 0", res.LocalOps)
|
|
}
|
|
}
|
|
|
|
// The conflict-copy filename must keep matching the glob documented for the
|
|
// OKF validation hook (SKILL.md): validate alone can't see conflict copies,
|
|
// so agents check `*.bdrive-conflict-*` — renaming the pattern breaks them.
|
|
func TestConflictCopyNameMatchesDocumentedGlob(t *testing.T) {
|
|
name := conflictName("Wiki/page.md", "Snow's MacBook", time.Now())
|
|
ok, err := filepath.Match("*.bdrive-conflict-*", filepath.Base(name))
|
|
if err != nil || !ok {
|
|
t.Fatalf("conflict copy %q no longer matches the documented glob *.bdrive-conflict-*", name)
|
|
}
|
|
if strings.ContainsAny(filepath.Base(name), " '") {
|
|
t.Fatalf("conflict copy name %q should sanitize device names", name)
|
|
}
|
|
}
|
|
|
|
// Session-linked notes: a note set for the session (explicitly, or persisted
|
|
// in the store by `bdrive sync --note`) is stamped onto every op that scan
|
|
// commits, travels through the remote to peers, and expires with its TTL —
|
|
// so history can link each change to the agent session that made it.
|
|
func TestSessionNoteStampsOps(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// Explicit note on the session (one-shot `bdrive sync --note`).
|
|
a.Note = "claude-code session s-123"
|
|
write(t, a.Folder, "wiki/plan.md", "v1")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
ops, err := b.Store.DeviceOps("deva")
|
|
if err != nil || len(ops) != 1 {
|
|
t.Fatalf("peer copy of deva journal: %v (%d ops)", err, len(ops))
|
|
}
|
|
if ops[0].Note != "claude-code session s-123" {
|
|
t.Fatalf("note on peer = %q", ops[0].Note)
|
|
}
|
|
|
|
// Persisted note: an empty Session.Note falls back to the store note, the
|
|
// path the daemon takes after a one-shot sync persisted it.
|
|
a.Note = ""
|
|
if err := a.Store.SaveNote("claude-code session s-456", time.Hour); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
write(t, a.Folder, "wiki/plan.md", "v2")
|
|
cycle(t, a)
|
|
ops, _ = a.Store.DeviceOps("deva")
|
|
if got := ops[len(ops)-1].Note; got != "claude-code session s-456" {
|
|
t.Fatalf("persisted-note op = %q", got)
|
|
}
|
|
|
|
// Deletes carry the note too.
|
|
if err := os.Remove(filepath.Join(a.Folder, "wiki", "plan.md")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, a)
|
|
ops, _ = a.Store.DeviceOps("deva")
|
|
last := ops[len(ops)-1]
|
|
if last.Kind != journal.KindDelete || last.Note != "claude-code session s-456" {
|
|
t.Fatalf("delete op = %+v", last)
|
|
}
|
|
|
|
// An expired note stops applying: later edits stay unlabeled.
|
|
if err := a.Store.SaveNote("stale", time.Millisecond); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, a.Folder, "wiki/new.md", "x")
|
|
cycle(t, a)
|
|
ops, _ = a.Store.DeviceOps("deva")
|
|
if got := ops[len(ops)-1].Note; got != "" {
|
|
t.Fatalf("expired note leaked onto op: %q", got)
|
|
}
|
|
|
|
// Clearing removes the note file entirely.
|
|
if err := a.Store.SaveNote("", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := a.Store.LoadNote(); got != "" {
|
|
t.Fatalf("cleared note = %q", got)
|
|
}
|
|
}
|
|
|
|
// A template is inert files, so it has to converge like any other content —
|
|
// and the double-seed case has to be a no-op rather than a divergence. One
|
|
// device seeds a structure, a teammate connects and gets it byte-for-byte;
|
|
// then the teammate seeds the same template again (an agent that asked when
|
|
// it shouldn't have) and nothing forks: no second copy, no conflict copies.
|
|
func TestTemplateSeedConverges(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
tpl, err := templates.Get("docs")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tpl.WriteTo(a.Folder); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
for _, f := range tpl.Files {
|
|
if got := read(t, b.Folder, f.Path); got != f.Content {
|
|
t.Fatalf("%s did not reach devb intact:\n%q", f.Path, got)
|
|
}
|
|
}
|
|
|
|
// devb seeds the same template on top of what it just pulled. WriteTo
|
|
// skips every existing path, so this writes nothing at all.
|
|
wrote, err := tpl.WriteTo(b.Folder)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(wrote) != 0 {
|
|
t.Fatalf("re-seeding an already-seeded folder wrote %v", wrote)
|
|
}
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
|
|
for _, d := range []*Session{a, b} {
|
|
if got := conflictFiles(t, d.Folder); len(got) != 0 {
|
|
t.Fatalf("%s: double-seed made conflict copies: %v", d.Device.ID, got)
|
|
}
|
|
for _, f := range tpl.Files {
|
|
if read(t, d.Folder, f.Path) != f.Content {
|
|
t.Fatalf("%s: %s diverged after the second seed", d.Device.ID, f.Path)
|
|
}
|
|
}
|
|
}
|
|
// One op per file per device that wrote it: no path was journaled twice.
|
|
ops, err := a.Store.DeviceOps("deva")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ops) != len(tpl.Files) {
|
|
t.Fatalf("deva journaled %d ops for a %d-file template: %+v", len(ops), len(tpl.Files), ops)
|
|
}
|
|
if ops, err := b.Store.DeviceOps("devb"); err != nil || len(ops) != 0 {
|
|
t.Fatalf("devb journaled %d ops for files it only pulled (err %v)", len(ops), err)
|
|
}
|
|
}
|