feat(sync): fence nested mounts; knowledge-init flows in plugin docs

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
This commit is contained in:
Snow Lee
2026-07-10 21:42:36 -07:00
co-authored by Claude Fable 5
parent b461ad4c97
commit 10a9998672
8 changed files with 361 additions and 14 deletions
+5 -3
View File
@@ -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
+8
View File
@@ -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 <folder>/.bdrive/config.json; ok is false if it does not
// exist.
func LoadProject(folder string) (Project, bool, error) {
+209
View File
@@ -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)
}
}
+23 -1
View File
@@ -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 {
+5
View File
@@ -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) {
+52
View File
@@ -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)
}
}
+35 -9
View File
@@ -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 -- <dir>`; 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 <dir>` + gitignore;
stage it, let the user commit). Offer one-way git snapshots if they
want a git record; `bdrive log -p <path>` covers history for most.
```sh
bdrive init --name <project> --yes # whole folder
bdrive init --name <project> --shared wiki # only ./wiki syncs
bdrive init --name <project> --yes # dedicated knowledge folder
bdrive init --name <project> --shared wiki # in a repo: only ./wiki syncs
```
4. **Verify**: run `bdrive status <folder>` and confirm the daemon is
5. **Verify**: run `bdrive status <folder>` 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.
+24 -1
View File
@@ -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 <dir>` + add `<dir>/` 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 <path>`) usually covers the need.
- **Knowledge syncs as a scoped folder.** Inside a repo, always `--shared <dir>` — 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 <dir>` 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 -- <dir>`: 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 <dir>`) 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 `<file>.bdrive-conflict-<device>-<timestamp>` 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.
---