2026-07-10 21:42:36 -07:00
|
|
|
package syncer
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
2026-07-11 10:56:53 -07:00
|
|
|
"github.com/runbear-io/beardrive/internal/journal"
|
2026-07-10 21:42:36 -07:00
|
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
|
|
|
"github.com/runbear-io/beardrive/internal/store"
|
2026-07-31 14:56:58 +09:00
|
|
|
"github.com/runbear-io/beardrive/internal/templates"
|
2026-07-10 21:42:36 -07:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 10:08:45 +09:00
|
|
|
// A repo shares only its knowledge subfolder (a legacy include-list mount);
|
2026-07-10 21:42:36 -07:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 16:10:10 -07:00
|
|
|
// 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.
|
2026-07-10 21:42:36 -07:00
|
|
|
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")
|
2026-08-12 16:10:10 -07:00
|
|
|
if res := cycle(t, b); res.Adopted != 1 {
|
|
|
|
|
t.Fatalf("Adopted = %d, want 1", res.Adopted)
|
|
|
|
|
}
|
2026-07-10 21:42:36 -07:00
|
|
|
cycle(t, a)
|
|
|
|
|
cycle(t, b)
|
|
|
|
|
|
|
|
|
|
for _, s := range []*Session{a, b} {
|
2026-08-12 16:10:10 -07:00
|
|
|
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)
|
2026-07-10 21:42:36 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-12 16:10:10 -07:00
|
|
|
// 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)
|
2026-07-10 21:42:36 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 10:56:53 -07:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-31 14:56:58 +09:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|