Files
beardrive/internal/config/project.go
Snow LeeandClaude Fable 5 10a9998672 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
2026-07-10 21:42:36 -07:00

102 lines
3.2 KiB
Go

package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ProjectDir is the per-folder settings directory at the mount root. It
// carries the mount's stable identity, so a project keeps syncing after the
// folder is renamed or moved — nothing is keyed by the path. It travels with
// the folder (copy the folder to a new machine and `bdrive init` resumes the
// same project) but is never synced, and it holds no session credentials —
// those stay in the bdrive home.
const ProjectDir = ".bdrive"
// Project holds the settings stored in <folder>/.bdrive/config.json.
type Project struct {
// ID is the stable mount identity (m-xxxxxxxx). The volume store, the
// daemon, and the registry are keyed by it, never by the folder path.
ID string `json:"id"`
Volume string `json:"volume,omitempty"`
Remote string `json:"remote,omitempty"`
// Include optionally narrows what syncs: when non-empty, only paths
// matching one of these patterns (gitignore-style, same syntax as
// .bdriveignore) are scanned and materialized.
Include []string `json:"include,omitempty"`
}
// NewMountID mints a stable mount identity.
func NewMountID() string {
b := make([]byte, 4)
rand.Read(b)
return "m-" + hex.EncodeToString(b)
}
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) {
var p Project
data, err := os.ReadFile(projectConfigPath(folder))
if err != nil {
if os.IsNotExist(err) {
return p, false, nil
}
return p, false, err
}
if err := json.Unmarshal(data, &p); err != nil {
return p, false, fmt.Errorf("parse %s: %w", projectConfigPath(folder), err)
}
return p, true, nil
}
// SaveProject writes <folder>/.bdrive/config.json, assigning a mount ID on
// first save.
func SaveProject(folder string, p Project) (Project, error) {
if p.ID == "" {
p.ID = NewMountID()
}
if err := os.MkdirAll(filepath.Join(folder, ProjectDir), 0o755); err != nil {
return p, err
}
return p, writeJSON(projectConfigPath(folder), p)
}
// ResolveMount loads a folder's project settings and self-heals the
// registry: if the folder was renamed or moved, the registry entry is
// updated to the new path so `bdrive status` and the daemon find it again.
func ResolveMount(folder string) (Project, bool, error) {
p, ok, err := LoadProject(folder)
if err != nil || !ok {
return p, ok, err
}
mounts, err := LoadMounts()
if err != nil {
return p, true, err
}
mi, registered := mounts[p.ID]
if !registered || mi.Path != folder || mi.Volume != p.Volume || mi.Remote != p.Remote {
mounts[p.ID] = MountInfo{Path: folder, Volume: p.Volume, Remote: p.Remote}
if err := SaveMounts(mounts); err != nil {
return p, true, err
}
}
return p, true, nil
}