mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
A subdirectory with its own .bdrive/config.json now syncs only through its own project: the parent mount never scans into it, materializes over it, or emits deletes for paths under it (cached paths are dropped without delete ops, the same posture as newly ignored files). Detection is presence-based (config.IsMount) so even a corrupt nested config still fences. This makes the team-knowledge-folder-inside-a-personal- mount topology safe. The plugin's init command and skill gain a "connecting knowledge tooling" playbook: a detection ladder (gbrain -> OKF wiki -> plain docs folder -> starting-point menu) and two hard rules — one transport per folder (git-tracked dirs are handed off via git rm --cached + gitignore before syncing) and knowledge-as-scoped-subfolder (never a repo root). Conflict copies documented with their exact glob (*.bdrive-conflict-*), since openknowledge validate cannot see them. New flow tests: shared-subfolder scope both ways, teammate connect over identical/divergent local content, nested mount syncing independently on two projects, and a guard pinning the conflict-copy filename to the documented glob. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
385 lines
11 KiB
Go
385 lines
11 KiB
Go
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/remote"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
)
|
|
|
|
// TestPushProgress verifies the push phase reports upload progress: the total
|
|
// is the number of unique blobs, Done climbs to that total, and byte totals
|
|
// are populated. (Done isn't strictly ordered across the parallel workers, so
|
|
// we only assert it reaches the total.)
|
|
func TestPushProgress(t *testing.T) {
|
|
a := newDevice(t, "deva", sharedRemote(t))
|
|
const n = 25
|
|
for i := 0; i < n; i++ {
|
|
write(t, a.Folder, fmt.Sprintf("f%02d.txt", i), fmt.Sprintf("unique content for file %d — pad pad pad", i))
|
|
}
|
|
var mu sync.Mutex
|
|
var total, maxDone int
|
|
var toBytes int64
|
|
a.OnProgress = func(p Progress) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
total = p.Total
|
|
toBytes = p.ToBytes
|
|
if p.Done > maxDone {
|
|
maxDone = p.Done
|
|
}
|
|
}
|
|
cycle(t, a)
|
|
if total != n {
|
|
t.Fatalf("progress Total = %d, want %d", total, n)
|
|
}
|
|
if maxDone != n {
|
|
t.Fatalf("progress reached Done = %d, want %d", maxDone, n)
|
|
}
|
|
if toBytes == 0 {
|
|
t.Fatal("progress ToBytes should be > 0")
|
|
}
|
|
}
|
|
|
|
// newDevice simulates one device: its own folder, volume store, and identity,
|
|
// all syncing through a shared file:// remote.
|
|
func newDevice(t *testing.T, name 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: t.TempDir(),
|
|
Store: st,
|
|
Device: config.Device{ID: name, Name: name, Author: name + "@test"},
|
|
Backend: backend,
|
|
}
|
|
}
|
|
|
|
func sharedRemote(t *testing.T) remote.Backend {
|
|
t.Helper()
|
|
be, err := remote.Open(context.Background(), "file://"+t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return be
|
|
}
|
|
|
|
func write(t *testing.T, folder, rel, content string) {
|
|
t.Helper()
|
|
abs := filepath.Join(folder, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func read(t *testing.T, folder, rel string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join(folder, filepath.FromSlash(rel)))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", rel, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func cycle(t *testing.T, s *Session) *Result {
|
|
t.Helper()
|
|
res, err := s.Cycle(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Offline {
|
|
t.Fatalf("unexpected offline: %v", res.OfflineErr)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func TestOfflineCycle(t *testing.T) {
|
|
a := newDevice(t, "deva", nil)
|
|
write(t, a.Folder, "notes/hello.md", "hi")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 {
|
|
t.Fatalf("LocalOps = %d, want 1", res.LocalOps)
|
|
}
|
|
// idempotent: second cycle sees no changes
|
|
res = cycle(t, a)
|
|
if res.Activity() {
|
|
t.Fatalf("second cycle should be quiet, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestTwoDeviceSync(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// A creates files, B receives them
|
|
write(t, a.Folder, "doc.txt", "v1")
|
|
write(t, a.Folder, "sub/nested.txt", "deep")
|
|
cycle(t, a)
|
|
res := cycle(t, b)
|
|
if res.PulledOps != 2 || res.Materialized != 2 {
|
|
t.Fatalf("b pull: %+v", res)
|
|
}
|
|
if read(t, b.Folder, "doc.txt") != "v1" || read(t, b.Folder, "sub/nested.txt") != "deep" {
|
|
t.Fatal("content mismatch after sync")
|
|
}
|
|
|
|
// B edits, A receives the update
|
|
time.Sleep(10 * time.Millisecond) // ensure mtime moves
|
|
write(t, b.Folder, "doc.txt", "v2 from b")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "doc.txt"); got != "v2 from b" {
|
|
t.Fatalf("a got %q", got)
|
|
}
|
|
|
|
// B deletes, A's copy disappears
|
|
os.Remove(filepath.Join(b.Folder, "sub", "nested.txt"))
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if _, err := os.Stat(filepath.Join(a.Folder, "sub", "nested.txt")); !os.IsNotExist(err) {
|
|
t.Fatal("delete did not propagate")
|
|
}
|
|
// empty dir pruned
|
|
if _, err := os.Stat(filepath.Join(a.Folder, "sub")); !os.IsNotExist(err) {
|
|
t.Fatal("empty dir not pruned")
|
|
}
|
|
}
|
|
|
|
func TestHistoryTracksDeviceAndAuthor(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
write(t, a.Folder, "f.txt", "from a")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, b.Folder, "f.txt", "from b")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
|
|
entries, err := LogEntries(a.Store, "f.txt", 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 2 {
|
|
t.Fatalf("want 2 history entries, got %d: %+v", len(entries), entries)
|
|
}
|
|
// newest first
|
|
if entries[0].Author != "devb@test" || entries[0].DeviceName != "devb" {
|
|
t.Fatalf("newest entry should be devb's: %+v", entries[0])
|
|
}
|
|
if entries[1].Author != "deva@test" {
|
|
t.Fatalf("oldest entry should be deva's: %+v", entries[1])
|
|
}
|
|
}
|
|
|
|
func TestConcurrentEditConflictPreserved(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// shared base
|
|
write(t, a.Folder, "shared.txt", "base")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
|
|
// both edit before syncing
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, a.Folder, "shared.txt", "edit from a")
|
|
write(t, b.Folder, "shared.txt", "edit from b")
|
|
cycle(t, a) // a pushes first
|
|
cycle(t, b) // b scans its edit, pulls a's, loses or wins deterministically
|
|
cycle(t, a) // a converges
|
|
cycle(t, b)
|
|
|
|
aContent := read(t, a.Folder, "shared.txt")
|
|
bContent := read(t, b.Folder, "shared.txt")
|
|
if aContent != bContent {
|
|
t.Fatalf("devices diverged: %q vs %q", aContent, bContent)
|
|
}
|
|
|
|
// both versions must survive somewhere (winner at path, loser as conflict copy)
|
|
all := map[string]bool{aContent: true}
|
|
for _, folder := range []string{a.Folder, b.Folder} {
|
|
entries, err := os.ReadDir(folder)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.Contains(e.Name(), ".bdrive-conflict-") {
|
|
all[read(t, folder, e.Name())] = true
|
|
}
|
|
}
|
|
}
|
|
if !all["edit from a"] || !all["edit from b"] {
|
|
t.Fatalf("a version was lost; surviving: %v", all)
|
|
}
|
|
}
|
|
|
|
func TestMountExistingFolderImports(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
write(t, a.Folder, "pre-existing.txt", "I was here first")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 || !res.Pushed {
|
|
t.Fatalf("import failed: %+v", res)
|
|
}
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "pre-existing.txt") != "I was here first" {
|
|
t.Fatal("existing file not imported/synced")
|
|
}
|
|
}
|
|
|
|
func TestIgnoredFiles(t *testing.T) {
|
|
a := newDevice(t, "deva", nil)
|
|
write(t, a.Folder, ".DS_Store", "junk")
|
|
write(t, a.Folder, ".git/config", "gitstuff")
|
|
write(t, a.Folder, "real.txt", "data")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 1 {
|
|
t.Fatalf("ignores leaked into journal: %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestOfflineThenReconnect(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
|
|
// work offline
|
|
a.Backend = nil
|
|
write(t, a.Folder, "offline.txt", "written offline")
|
|
cycle(t, a)
|
|
|
|
// reconnect: pending ops push
|
|
a.Backend = be
|
|
res := cycle(t, a)
|
|
if !res.Pushed {
|
|
t.Fatalf("reconnect should push pending ops: %+v", res)
|
|
}
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
if read(t, b.Folder, "offline.txt") != "written offline" {
|
|
t.Fatal("offline edit did not propagate after reconnect")
|
|
}
|
|
}
|
|
|
|
func TestSameVolumeMountedAtTwoFolders(t *testing.T) {
|
|
// One device mounts the same volume at two folders (e.g. ./shared in two
|
|
// repos). They share the store (blobs+journals) but have separate mount
|
|
// caches, and content propagates between them even with no remote.
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "volume"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dev := config.Device{ID: "dev1", Name: "dev1", Author: "dev1@test"}
|
|
m1 := &Session{Folder: t.TempDir(), MountID: "mount1", Store: st, Device: dev}
|
|
m2 := &Session{Folder: t.TempDir(), MountID: "mount2", Store: st, Device: dev}
|
|
|
|
write(t, m1.Folder, "shared.md", "from folder one")
|
|
cycle(t, m1)
|
|
res := cycle(t, m2)
|
|
if res.Materialized != 1 {
|
|
t.Fatalf("folder two should materialize the file: %+v", res)
|
|
}
|
|
if read(t, m2.Folder, "shared.md") != "from folder one" {
|
|
t.Fatal("content did not propagate between mounts")
|
|
}
|
|
|
|
// edit in folder two propagates back
|
|
time.Sleep(10 * time.Millisecond)
|
|
write(t, m2.Folder, "shared.md", "edited in folder two")
|
|
cycle(t, m2)
|
|
cycle(t, m1)
|
|
if read(t, m1.Folder, "shared.md") != "edited in folder two" {
|
|
t.Fatal("edit did not propagate back to folder one")
|
|
}
|
|
}
|
|
|
|
func TestExecutableBitPreserved(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
abs := filepath.Join(a.Folder, "run.sh")
|
|
os.WriteFile(abs, []byte("#!/bin/sh\necho hi\n"), 0o755)
|
|
cycle(t, a)
|
|
|
|
b := newDevice(t, "devb", be)
|
|
cycle(t, b)
|
|
fi, err := os.Stat(filepath.Join(b.Folder, "run.sh"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fi.Mode().Perm()&0o100 == 0 {
|
|
t.Fatalf("exec bit lost: %v", fi.Mode())
|
|
}
|
|
}
|
|
|
|
// TestNestedMountExcluded verifies that a subdirectory which is a BearDrive
|
|
// mount of its own (has .bdrive/config.json) is fenced off from the parent
|
|
// mount: the parent scanner never journals its files, dropping it emits no
|
|
// delete ops toward peers, and remote state is never materialized into it.
|
|
func TestNestedMountExcluded(t *testing.T) {
|
|
be := sharedRemote(t)
|
|
a := newDevice(t, "deva", be)
|
|
b := newDevice(t, "devb", be)
|
|
|
|
// Both devices converge on a folder that includes team/.
|
|
write(t, a.Folder, "readme.md", "root")
|
|
write(t, a.Folder, "team/notes.md", "v1")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("b team/notes.md = %q, want v1", got)
|
|
}
|
|
|
|
// team/ becomes a nested mount on A (its own project).
|
|
write(t, a.Folder, "team/.bdrive/config.json", `{"mount_id":"m-nested"}`)
|
|
write(t, a.Folder, "team/local.md", "only for the nested project")
|
|
res := cycle(t, a)
|
|
if res.LocalOps != 0 {
|
|
t.Fatalf("a journaled %d ops for nested-mount content, want 0", res.LocalOps)
|
|
}
|
|
|
|
// B must keep its copy (no delete propagated) and never see new files.
|
|
res = cycle(t, b)
|
|
if got := read(t, b.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("b team/notes.md = %q after a's cycle, want v1 (no deletes)", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(b.Folder, "team/local.md")); !os.IsNotExist(err) {
|
|
t.Fatal("nested-mount file leaked to peer")
|
|
}
|
|
|
|
// B edits inside team/; A must not materialize over its nested mount.
|
|
write(t, b.Folder, "team/notes.md", "v2")
|
|
cycle(t, b)
|
|
cycle(t, a)
|
|
if got := read(t, a.Folder, "team/notes.md"); got != "v1" {
|
|
t.Fatalf("a team/notes.md = %q, want v1 (nested mount not written)", got)
|
|
}
|
|
|
|
// Paths outside the nested mount keep syncing both ways.
|
|
write(t, a.Folder, "readme.md", "root v2")
|
|
cycle(t, a)
|
|
cycle(t, b)
|
|
if got := read(t, b.Folder, "readme.md"); got != "root v2" {
|
|
t.Fatalf("b readme.md = %q, want root v2", got)
|
|
}
|
|
}
|