diff --git a/README.md b/README.md index 119437f..9e395a0 100644 --- a/README.md +++ b/README.md @@ -460,9 +460,11 @@ working folder ←materialize/scan→ local volume store ←push/pull→ obj ### What beardrive does not sync `.git` directories (per-file LWW would corrupt repositories), `.DS_Store`, -the `.bdrive` settings file, its own temp files, and anything excluded by -`.bdriveignore` or omitted from an `include` list. Empty directories are not -tracked (like git). +the `.bdrive` settings file, its own temp files, nested mounts (a +subdirectory with its own `.bdrive/config.json` syncs only through its own +project — the parent never scans into it, writes over it, or propagates +deletes for it), and anything excluded by `.bdriveignore` or omitted from an +`include` list. Empty directories are not tracked (like git). ## Roadmap diff --git a/internal/config/project.go b/internal/config/project.go index 13897d3..8407b98 100644 --- a/internal/config/project.go +++ b/internal/config/project.go @@ -41,6 +41,14 @@ func projectConfigPath(folder string) string { return filepath.Join(folder, ProjectDir, "config.json") } +// IsMount reports whether folder is a BearDrive mount root, i.e. has a +// .bdrive/config.json — even an unparseable one, so callers that must not +// treat a mount as plain files (e.g. a parent mount's scanner) stay safe. +func IsMount(folder string) bool { + _, err := os.Stat(projectConfigPath(folder)) + return err == nil +} + // LoadProject reads /.bdrive/config.json; ok is false if it does not // exist. func LoadProject(folder string) (Project, bool, error) { diff --git a/internal/syncer/flows_test.go b/internal/syncer/flows_test.go new file mode 100644 index 0000000..050b6b6 --- /dev/null +++ b/internal/syncer/flows_test.go @@ -0,0 +1,209 @@ +package syncer + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/remote" + "github.com/runbear-io/beardrive/internal/store" +) + +// 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 (`bdrive init --shared Wiki`); +// 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: nothing is silently lost — the +// devices converge on one version and the other survives as a conflict copy. +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") + cycle(t, b) + cycle(t, a) + cycle(t, b) + + av, bv := read(t, a.Folder, "docs/guide.md"), read(t, b.Folder, "docs/guide.md") + if av != bv { + t.Fatalf("devices diverged: %q vs %q", av, bv) + } + survived := map[string]bool{av: true} + for _, s := range []*Session{a, b} { + for _, p := range conflictFiles(t, s.Folder) { + rel, _ := filepath.Rel(s.Folder, p) + survived[read(t, s.Folder, rel)] = true + } + } + if !survived["hub version"] || !survived["stale local version"] { + t.Fatalf("a version was silently lost; surviving: %v", survived) + } +} + +// 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) + } +} diff --git a/internal/syncer/ignore.go b/internal/syncer/ignore.go index 5967873..b142605 100644 --- a/internal/syncer/ignore.go +++ b/internal/syncer/ignore.go @@ -24,6 +24,28 @@ type Filter struct { ignore []pattern include []pattern negated bool // any `!` rules → directory pruning is unsafe + + // nested holds subdirectories that are BearDrive mounts of their own + // (they contain .bdrive/config.json), discovered during the scan walk. + // A nested mount syncs through its own project: the parent never scans + // into it, never materializes over it, and drops cached paths under it + // without a delete op (same posture as newly ignored paths). + nested []string +} + +// addNestedMount records a nested mount root (slash-relative to the parent +// mount) so Skip excludes everything under it for the rest of the cycle. +func (f *Filter) addNestedMount(rel string) { + f.nested = append(f.nested, rel+"/") +} + +func (f *Filter) underNestedMount(rel string) bool { + for _, root := range f.nested { + if strings.HasPrefix(rel, root) { + return true + } + } + return false } type pattern struct { @@ -117,7 +139,7 @@ func compile(line string) (pattern, bool) { // Skip reports whether a file path should not sync. func (f *Filter) Skip(rel string) bool { - if f.ignoredFile(rel) { + if f.underNestedMount(rel) || f.ignoredFile(rel) { return true } if len(f.include) == 0 { diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go index ce9cf9c..199d2c6 100644 --- a/internal/syncer/syncer.go +++ b/internal/syncer/syncer.go @@ -234,6 +234,11 @@ func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, s if ignoreDirs[d.Name()] || filter.PruneDir(rel) { return fs.SkipDir } + if config.IsMount(p) { + // A mount of its own: it syncs through its own project. + filter.addNestedMount(rel) + return fs.SkipDir + } return nil } if !d.Type().IsRegular() || ignoredFile(d.Name()) || filter.Skip(rel) { diff --git a/internal/syncer/syncer_test.go b/internal/syncer/syncer_test.go index 6464717..7ec62da 100644 --- a/internal/syncer/syncer_test.go +++ b/internal/syncer/syncer_test.go @@ -330,3 +330,55 @@ func TestExecutableBitPreserved(t *testing.T) { 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) + } +} diff --git a/plugin/commands/init.md b/plugin/commands/init.md index 261512d..a6eb740 100644 --- a/plugin/commands/init.md +++ b/plugin/commands/init.md @@ -18,20 +18,46 @@ Follow these steps: coming; it completes by itself). Default server is beardrive.ai; pass a self-hosted URL if the user mentioned one. -3. **Initialize**: if the folder already contains `.bdrive/`, just run - `bdrive init --yes` there — it resumes syncing (including after a - rename/move). Otherwise decide the project name (argument, or ask, or - default to the folder name) and scope (whole folder, or only a shared - subfolder like `./wiki` via `--shared`), then run it non-interactively: +3. **Detect knowledge tooling** (skip if the folder already contains + `.bdrive/` — then just run `bdrive init --yes`; it resumes syncing, + including after a rename/move). Check, in order — first match wins, + ask if two match (full playbook: the beardrive skill's "Connecting + knowledge tooling" section): + + - **gbrain** (`gbrain.yml`, or a gbrain MCP server / brain-first + CLAUDE.md block) → offer to sync the brain's shared subfolder as its + own project; never a brain root. + - **OKF wiki** (markdown with OKF frontmatter) → offer: connect the + wiki dir via `--shared`, or keep it PR-gated and create a new shared + folder. + - **Wiki-ish folder** (`docs`/`wiki`/`notes` full of markdown) → check + `git log -- `; dormant → recommend connecting it, active PR + traffic → recommend a new shared folder. Offer an OKF upgrade + (`openknowledge from`) after connecting, as a separate consent. + - **Nothing / empty** → offer a starting point in this order: + OKF (recommended), gbrain, blank, describe-it. + +4. **Initialize** — two hard rules: + + - **Never sync a repo root**: inside a repo, knowledge syncs as a + scoped subfolder via `--shared`. A dedicated knowledge folder + (empty dir, standalone vault) may be the mount itself. + - **One transport per folder**: a git-tracked dir must leave git + tracking before it syncs (`git rm -r --cached ` + gitignore; + stage it, let the user commit). Offer one-way git snapshots if they + want a git record; `bdrive log -p ` covers history for most. ```sh - bdrive init --name --yes # whole folder - bdrive init --name --shared wiki # only ./wiki syncs + bdrive init --name --yes # dedicated knowledge folder + bdrive init --name --shared wiki # in a repo: only ./wiki syncs ``` -4. **Verify**: run `bdrive status ` and confirm the daemon is +5. **Verify**: run `bdrive status ` and confirm the daemon is running and pending is 0. Summarize: project name/id, what syncs, and - that edits propagate to every team member within seconds. + that edits propagate to every team member within seconds. Offer a + consent-gated CLAUDE.md note and tell the user how teammates connect + (invite link → `bdrive init` → same `--shared` scope, which is + per-device). For the full team setup (CLAUDE.md guidance + per-project sync hooks in `.claude/settings.json`), suggest `/beardrive:install` instead. diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index 173181e..5829acf 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -133,9 +133,32 @@ bdrive stop ~/agent-workspace bdrive stop ./notes --forget ``` +### Connecting knowledge tooling (gbrain, OKF, docs folders) + +When guiding `bdrive init`, detect existing knowledge tooling and connect it instead of blind-syncing the folder. Two rules govern every case: + +- **One transport per folder.** Never sync a folder that has another writer. Git-tracked paths: a teammate's `git pull` or branch switch rewrites files with older content, and sync broadcasts that as a fresh edit — silently reverting the team's latest pages. A gbrain brain root: every private capture and overnight enrichment would become team-visible, and each member's cron rewriting the same pages fills the project with conflict copies. Moving a folder from git to beardrive is a **handoff**: `git rm -r --cached ` + add `/` to `.gitignore`, stage the change but let the user commit it (teammates then pull and re-init; identical content converges with no conflicts). If they want a git record anyway, offer **one-way snapshots** (a scheduled job commits the synced folder's state to an archive branch — git only ever reads the folder) and note that hub history (`bdrive log -p `) usually covers the need. +- **Knowledge syncs as a scoped folder.** Inside a repo, always `--shared ` — never the repo root. A dedicated knowledge folder (an empty dir, a standalone vault) may be the mount itself. The sync scope is per-device (`.bdrive/` never syncs), so recommend the same `--shared ` when each teammate connects. + +Detection ladder — first match wins; if two rungs match, ask which to connect: + +1. **gbrain** — `gbrain.yml` in the folder, or a gbrain MCP server in `.mcp.json` / a brain-first protocol block in CLAUDE.md (brain root is then found via `~/.gbrain/config.json` or the MCP config). gbrain's own architecture states the markdown repo is the system of record and the DB a derived cache — beardrive replaces the git hop as the brain's sync transport: + - The shared team folder is **never anyone's brain root**. First user: share a subfolder of their brain (e.g. `~/brain/team/`) as its own project. Followers: mount that project as a subfolder of their own brain (or a sibling dir) — never merge roots; "merge my existing brain into it" is a manual migration, not an init option. + - Register the shared folder as its **own gbrain source** (`gbrain sources add`; `.gbrain-source` dotfile routes writes) — slugs are per-source, the `federated` flag controls blending into personal search, and the schema pack is settable per source so team pages keep their types (`person`, `company`, …) instead of degrading to untyped notes. + - Recommend `gbrain config set link_resolution.global_basename true` so the team's `[[wikilinks]]` resolve from any mount path (`gbrain doctor` reports the edge gain first). + - Mirror the brain's `db_only` dirs from `gbrain.yml` into `.bdriveignore` (machine-generated, restorable from the DB — and often a privacy fence; ask before syncing them). + - gbrain's sync cron keeps re-indexing what beardrive pulls in; suggest switching it to `gbrain sync --no-pull` (ask before editing a crontab). On PGLite, remind: stop `gbrain serve` before large syncs (single-writer contention). +2. **OKF** — markdown with OKF v0.1 frontmatter (confirm with `openknowledge validate` if the CLI is present; don't install just to detect). Offer: (a) connect the wiki dir via `--shared` — with the git handoff if tracked — or (b) keep the wiki PR-gated in git and create a new shared folder (starting-point menu below). Recommend (a) when the wiki is the team's knowledge, (b) when it's review-gated repo documentation. +3. **Wiki-ish folder** — a markdown-dense dir named `docs`/`wiki`/`notes`/`kb` with no knowledge tooling. Check `git log -- `: dormant → recommend connecting it as the live team space (handoff included); active PR traffic → recommend a new shared folder instead, and say why. After connecting, offer — as a separate consent, it rewrites their files — an in-place upgrade to OKF (`openknowledge from `) for validation and agent-readability. +4. **Nothing** — empty or unstructured folder: offer a starting point, in this order: **(a) OKF (recommended** — open spec, plain files, zero runtime, upgradeable to gbrain later**)**, (b) gbrain (full agent brain; heavier — per-member local DB), (c) blank, (d) describe-it (user describes the purpose; scaffold a custom OKF shape, redirecting to gbrain if the description is graph-shaped: entities, relationships, "who/what" queries). + +Conflict copies are named `.bdrive-conflict--` and sync like normal files. `openknowledge validate` does **not** flag them (they aren't `.md`) — pair validate with a `*.bdrive-conflict-*` glob check when offering a post-edit validation hook. + +Every branch ends the same way: verify (`bdrive status`, pending 0), a consent-gated CLAUDE.md note describing what syncs and how teammates connect, and the teammate onboarding sentence (invite link → `bdrive init` → same `--shared` scope). + ### What beardrive does not sync -`.git` directories, `.DS_Store`, the `.bdrive` settings file, beardrive's own temp files, empty directories, and anything excluded by `.bdriveignore` or left out of an `include` list. Don't suggest mounting a folder where `.git` is the content the user expects synced — they want git, not beardrive. +`.git` directories, `.DS_Store`, the `.bdrive` settings file, beardrive's own temp files, empty directories, nested mounts (a subdirectory with its own `.bdrive/config.json` syncs only through its own project — the parent never scans into it, materializes over it, or propagates deletes for it), and anything excluded by `.bdriveignore` or left out of an `include` list. Don't suggest mounting a folder where `.git` is the content the user expects synced — they want git, not beardrive. ---