Files
beardrive/internal/config/project.go
056c883204 fix(sync): anchor --shared include entries to the mount root (BEA-5) (#56)
`bdrive init --shared wiki` wrote `include: ["wiki/"]`, which compile()
treats as an unanchored gitignore pattern — so any nested directory named
`wiki` synced too. Shared-subfolder mode is what people use to keep private
material out of a project, and it was silently widening the scope: 15 files
under .agents/, .claude/ and .gemini/ leaked into a real project from
.../detector/shared/ dirs.

cleanShared now emits "/wiki/", which fixes both callers (init --shared and
bdrive scope add). config.LoadProject anchors legacy single-segment entries
on read, so the existing mounts are fixed without a re-init — and that also
keeps `bdrive scope rm wiki` working against pre-fix configs, with a
belt-and-braces unanchored candidate key in scopeRemove for any config that
bypasses LoadProject.

Not touched: compile() itself, and no delete op for the already-leaked
remote files (BEA-20 — a delete would unlink teammates' local copies).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:44:05 +09:00

121 lines
3.8 KiB
Go

package config
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 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)
}
p.Include = normalizeInclude(p.Include)
return p, true, nil
}
// normalizeInclude anchors bare single-segment include entries to the mount
// root, so a config written before the fix ("wiki/") stops matching nested
// directories of the same name without needing a re-init. Only single-segment
// entries need it: compile() already anchors anything containing a slash.
// Entries with glob syntax are left alone — a hand-written pattern is a
// deliberate pattern.
func normalizeInclude(include []string) []string {
for n, i := range include {
s := strings.TrimSuffix(i, "/")
if s == "" || strings.ContainsAny(s, "/*?[!") {
continue
}
include[n] = "/" + i
}
return include
}
// 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
}