Merge pull request #11 from runbear-io/feat/knowledge-init-flows

feat(sync): fence nested mounts; knowledge-init flows in plugin docs
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-10 21:43:32 -07:00
committed by GitHub
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.
---