mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(cli): bdrive scope --explain — prove what leaves this machine (BEA-24) (#70)
The local-first claim was asserted, never demonstrated: nothing anywhere told you what your laptop chose *not* to send. `bdrive scope --explain` walks the folder and prints two sorted lists — synced and not synced — with counts and a pointer at what it does not answer. The decisions come from the same walk the sync cycle uses. scan()'s WalkDir decision tree moves into walkFolder (internal/syncer/walk.go), the only copy of the rules; scan and Explain both go through it, so the output provably cannot drift from real sync behavior. Pure read: its own Filter, no Session, no volume flock, no network. Fully-excluded directories collapse to one counted line; nested mounts are annotated as syncing through their own project rather than called "not synced", which would be a lie in a trust surface. Known gap, deliberate: this answers "what leaves from now on", not "what is already on the hub" — the footer points at `bdrive forget`. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecc8328512
commit
dcd0517e92
@@ -143,7 +143,7 @@ hub's own storage, never something a syncing client points at directly:
|
|||||||
| `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) |
|
| `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) |
|
||||||
| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; installs the agent skill, registers agent sync hooks in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume |
|
| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; installs the agent skill, registers agent sync hooks in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume |
|
||||||
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
|
| `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) |
|
||||||
| `bdrive scope [add\|rm <dirs...>]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes, so no one hand-writes negation syntax. The daemon picks changes up in seconds; `rm` deletes nothing, locally or on the hub |
|
| `bdrive scope [add\|rm <dirs...>]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes, so no one hand-writes negation syntax. The daemon picks changes up in seconds; `rm` deletes nothing, locally or on the hub. `--explain` lists every path in the folder split into what syncs and what does not, so you can verify what leaves this machine (pure read — no daemon, no lock, no network) |
|
||||||
| `bdrive forget <path>...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
|
| `bdrive forget <path>...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
|
||||||
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
|
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
|
||||||
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
||||||
|
|||||||
@@ -38,7 +38,24 @@ classDiagram
|
|||||||
+Skip(rel) bool
|
+Skip(rel) bool
|
||||||
+PruneDir(rel) bool
|
+PruneDir(rel) bool
|
||||||
}
|
}
|
||||||
note for Filter "ignore.go — .bdriveignore rules (incl. the managed `# bdrive scope` negation block written by init --only / bdrive scope) + a legacy .bdrive include list, applied symmetrically in scan and materialize; Negated() is what makes sync --prune refuse on a scoped project"
|
note for Filter "ignore.go — .bdriveignore rules (incl. the managed `# bdrive scope` negation block written by init --only / bdrive scope) + a legacy .bdrive include list, applied symmetrically in scan and materialize; Negated() is what makes sync --prune refuse on a scoped project. NOT the whole predicate: walkFolder adds .git/.bdrive pruning, non-regular and .DS_Store/.bdrive-tmp-* skips, and nested-mount handoff"
|
||||||
|
|
||||||
|
class walkFolder {
|
||||||
|
+walkFolder(folder, filter, fn)
|
||||||
|
verdict: vSync vSkipFile vDescend vPruneDir vNested
|
||||||
|
}
|
||||||
|
note for walkFolder "walk.go — the ONLY copy of the sync predicate; scan and Explain both go through it, so what --explain reports cannot drift from what leaves"
|
||||||
|
|
||||||
|
class Explain {
|
||||||
|
+Explain(folder, include) two lists
|
||||||
|
+NotSyncedFiles(entries) int
|
||||||
|
}
|
||||||
|
class Entry {
|
||||||
|
+Path string
|
||||||
|
+Files int
|
||||||
|
+Nested bool
|
||||||
|
}
|
||||||
|
note for Explain "explain.go — bdrive scope --explain. Pure read: own Filter, no Session, no flock, no network. Collapses fully-excluded dirs to one counted line; nested mounts annotated, counted as zero (they sync via their own project)"
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
-dir volume dir
|
-dir volume dir
|
||||||
@@ -76,6 +93,11 @@ classDiagram
|
|||||||
Session --> Store : volume state
|
Session --> Store : volume state
|
||||||
Session --> Backend : pull and push
|
Session --> Backend : pull and push
|
||||||
Session --> Filter : scan and materialize
|
Session --> Filter : scan and materialize
|
||||||
|
Session --> walkFolder : scan
|
||||||
|
Explain --> walkFolder : same predicate
|
||||||
|
Explain --> Filter : own fresh instance
|
||||||
|
Explain ..> Entry : not-synced lines
|
||||||
|
walkFolder --> Filter : Skip / PruneDir / addNestedMount
|
||||||
Session ..> Op : commits, replays
|
Session ..> Op : commits, replays
|
||||||
Session --> Result
|
Session --> Result
|
||||||
Store o-- Op : journal files
|
Store o-- Op : journal files
|
||||||
|
|||||||
+57
-1
@@ -4,11 +4,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/runbear-io/beardrive/internal/config"
|
"github.com/runbear-io/beardrive/internal/config"
|
||||||
|
"github.com/runbear-io/beardrive/internal/syncer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bdrive scope shows and edits which of the mount's subfolders sync. The
|
// bdrive scope shows and edits which of the mount's subfolders sync. The
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
// reason to hand-write the negation syntax. The daemon re-reads the rules
|
// reason to hand-write the negation syntax. The daemon re-reads the rules
|
||||||
// every tick, so changes apply within seconds.
|
// every tick, so changes apply within seconds.
|
||||||
func scopeCmd() *cobra.Command {
|
func scopeCmd() *cobra.Command {
|
||||||
|
var explain bool
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "scope",
|
Use: "scope",
|
||||||
Short: "Show or change which subfolders sync",
|
Short: "Show or change which subfolders sync",
|
||||||
@@ -26,10 +29,15 @@ The whole mount syncs by default. Narrowing it writes a managed block of
|
|||||||
.bdriveignore rules ("only these folders"), which syncs to the team like any
|
.bdriveignore rules ("only these folders"), which syncs to the team like any
|
||||||
other rule — so everyone sees the same scope. Run from the mount root.
|
other rule — so everyone sees the same scope. Run from the mount root.
|
||||||
|
|
||||||
|
--explain walks the folder and prints every path it found, split into what
|
||||||
|
syncs and what does not, so you can verify what leaves this machine instead
|
||||||
|
of taking it on trust. It is a pure read: no daemon, no lock, no network.
|
||||||
|
|
||||||
Removing a folder stops syncing it but deletes nothing — local files stay,
|
Removing a folder stops syncing it but deletes nothing — local files stay,
|
||||||
and the hub keeps everything already synced. To take something off the hub
|
and the hub keeps everything already synced. To take something off the hub
|
||||||
too, use ` + "`bdrive forget <path>`" + `.`,
|
too, use ` + "`bdrive forget <path>`" + `.`,
|
||||||
Example: ` bdrive scope # show what syncs
|
Example: ` bdrive scope # show what syncs
|
||||||
|
bdrive scope --explain # list every path: synced vs not synced
|
||||||
bdrive scope add docs # also sync ./docs
|
bdrive scope add docs # also sync ./docs
|
||||||
bdrive scope rm docs # stop syncing ./docs (files stay everywhere)`,
|
bdrive scope rm docs # stop syncing ./docs (files stay everywhere)`,
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
@@ -42,13 +50,61 @@ too, use ` + "`bdrive forget <path>`" + `.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return printScope(folder, proj)
|
if err := printScope(folder, proj); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !explain {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
synced, notSynced, err := syncer.Explain(folder, proj.Include)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
printExplain(synced, notSynced)
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
// Flags, not PersistentFlags: add/rm must not inherit --explain.
|
||||||
|
c.Flags().BoolVar(&explain, "explain", false, "list every path, split into what syncs and what does not")
|
||||||
c.AddCommand(scopeAddCmd(), scopeRmCmd())
|
c.AddCommand(scopeAddCmd(), scopeRmCmd())
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printExplain renders the two lists. All the filtering rules live in
|
||||||
|
// internal/syncer — this only formats what the walk decided.
|
||||||
|
func printExplain(synced []string, notSynced []syncer.Entry) {
|
||||||
|
fmt.Printf("\nsynced (%s)\n", comma(len(synced)))
|
||||||
|
for _, p := range synced {
|
||||||
|
fmt.Println(" " + p)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nnot synced (%s)\n", comma(syncer.NotSyncedFiles(notSynced)))
|
||||||
|
for _, e := range notSynced {
|
||||||
|
note := ""
|
||||||
|
switch {
|
||||||
|
case e.Nested:
|
||||||
|
note = "(own project — syncs separately)"
|
||||||
|
case e.IsDir():
|
||||||
|
note = fmt.Sprintf("(%s files)", comma(e.Files))
|
||||||
|
if e.Files == 1 {
|
||||||
|
note = "(1 file)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(strings.TrimRight(fmt.Sprintf(" %-30s %s", e.Path, note), " "))
|
||||||
|
}
|
||||||
|
fmt.Printf("\n%s files sync, %s do not.\n\n", comma(len(synced)), comma(syncer.NotSyncedFiles(notSynced)))
|
||||||
|
fmt.Println("Excluded paths never leave this machine. Anything that synced before its rule")
|
||||||
|
fmt.Println("existed may still be on the hub — `bdrive forget <path>` takes it off.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// comma groups thousands: 2486 -> "2,486".
|
||||||
|
func comma(n int) string {
|
||||||
|
s := strconv.Itoa(n)
|
||||||
|
for i := len(s) - 3; i > 0; i -= 3 {
|
||||||
|
s = s[:i] + "," + s[i:]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func scopeAddCmd() *cobra.Command {
|
func scopeAddCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "add <dir>...",
|
Use: "add <dir>...",
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry is one line of the not-synced list. Path ends in "/" when a whole
|
||||||
|
// directory collapsed to a single line; Files is how many files it holds.
|
||||||
|
type Entry struct {
|
||||||
|
Path string
|
||||||
|
Files int
|
||||||
|
Nested bool // syncs through its own project — not excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDir reports whether the entry stands for a whole directory rather than a
|
||||||
|
// single file.
|
||||||
|
func (e Entry) IsDir() bool { return strings.HasSuffix(e.Path, "/") }
|
||||||
|
|
||||||
|
// Explain reports what the sync cycle would and would not send for a folder.
|
||||||
|
// It is a pure read: no Session, no volume lock, no network, no writes — the
|
||||||
|
// answer comes from the same walk the cycle itself uses, so it cannot drift.
|
||||||
|
func Explain(folder string, include []string) (synced []string, notSynced []Entry, err error) {
|
||||||
|
// A fresh filter: addNestedMount mutates it during the walk, so this must
|
||||||
|
// never be shared with a live cycle.
|
||||||
|
filter, err := loadFilter(folder, include)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var skipped []string
|
||||||
|
dirs := map[string]*Entry{}
|
||||||
|
var dirOrder []string // walk order: parents before children
|
||||||
|
keep := map[string]bool{} // dir holds something that must stay visible
|
||||||
|
count := map[string]int{} // files under a dir that do not sync
|
||||||
|
|
||||||
|
err = walkFolder(folder, filter, func(abs, rel string, d fs.DirEntry, v verdict) error {
|
||||||
|
switch v {
|
||||||
|
case vSync:
|
||||||
|
synced = append(synced, rel)
|
||||||
|
for _, a := range ancestors(rel) {
|
||||||
|
keep[a] = true
|
||||||
|
}
|
||||||
|
case vSkipFile:
|
||||||
|
skipped = append(skipped, rel)
|
||||||
|
for _, a := range ancestors(rel) {
|
||||||
|
count[a]++
|
||||||
|
}
|
||||||
|
case vDescend:
|
||||||
|
dirs[rel] = &Entry{Path: rel + "/"}
|
||||||
|
dirOrder = append(dirOrder, rel)
|
||||||
|
case vPruneDir:
|
||||||
|
n := countFiles(abs)
|
||||||
|
dirs[rel] = &Entry{Path: rel + "/", Files: n}
|
||||||
|
dirOrder = append(dirOrder, rel)
|
||||||
|
for _, a := range ancestors(rel) {
|
||||||
|
count[a] += n
|
||||||
|
}
|
||||||
|
case vNested:
|
||||||
|
// Not excluded — it syncs through its own project, so its files
|
||||||
|
// are not counted as "do not sync" and its parents stay visible
|
||||||
|
// rather than collapsing the annotation away.
|
||||||
|
dirs[rel] = &Entry{Path: rel + "/", Nested: true}
|
||||||
|
dirOrder = append(dirOrder, rel)
|
||||||
|
for _, a := range ancestors(rel) {
|
||||||
|
keep[a] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse: a directory with nothing to show individually prints as one
|
||||||
|
// counted line, and everything under it is dropped. Parents come first in
|
||||||
|
// walk order, so the topmost such directory wins.
|
||||||
|
collapsed := map[string]bool{}
|
||||||
|
for _, rel := range dirOrder {
|
||||||
|
if keep[rel] || underCollapsed(rel, collapsed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
collapsed[rel] = true
|
||||||
|
e := dirs[rel]
|
||||||
|
if !e.Nested && e.Files == 0 {
|
||||||
|
e.Files = count[rel]
|
||||||
|
}
|
||||||
|
notSynced = append(notSynced, *e)
|
||||||
|
}
|
||||||
|
for _, rel := range skipped {
|
||||||
|
if !underCollapsed(rel, collapsed) {
|
||||||
|
notSynced = append(notSynced, Entry{Path: rel})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(synced)
|
||||||
|
sort.Slice(notSynced, func(i, j int) bool { return notSynced[i].Path < notSynced[j].Path })
|
||||||
|
return synced, notSynced, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotSyncedFiles is how many files the not-synced list stands for: collapsed
|
||||||
|
// directories count their whole subtree, nested mounts count zero because
|
||||||
|
// they do sync — through their own project.
|
||||||
|
func NotSyncedFiles(notSynced []Entry) int {
|
||||||
|
n := 0
|
||||||
|
for _, e := range notSynced {
|
||||||
|
if e.IsDir() {
|
||||||
|
n += e.Files
|
||||||
|
} else {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func ancestors(rel string) []string {
|
||||||
|
var out []string
|
||||||
|
for d := path.Dir(rel); d != "."; d = path.Dir(d) {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func underCollapsed(rel string, collapsed map[string]bool) bool {
|
||||||
|
for _, a := range ancestors(rel) {
|
||||||
|
if collapsed[a] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFiles counts the files under an already-excluded directory. Readdir
|
||||||
|
// only — never Stat — because this runs over trees like .git and
|
||||||
|
// node_modules; a partial count on an unreadable subtree is fine.
|
||||||
|
func countFiles(abs string) int {
|
||||||
|
n := 0
|
||||||
|
filepath.WalkDir(abs, func(_ string, d fs.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/runbear-io/beardrive/internal/journal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExplainMatchesScan is the whole point of `bdrive scope --explain`: the
|
||||||
|
// paths it calls "synced" must be exactly the paths the sync cycle journals,
|
||||||
|
// and exactly the paths that show up on a peer device. If these ever diverge
|
||||||
|
// the command is lying in a trust surface, so this asserts the identity from
|
||||||
|
// both ends for a whole-folder project and for a scoped one.
|
||||||
|
func TestExplainMatchesScan(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
include []string
|
||||||
|
setup func(t *testing.T, folder string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "whole folder",
|
||||||
|
setup: func(t *testing.T, folder string) {
|
||||||
|
write(t, folder, IgnoreFile, "node_modules/\n*.log\n")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// PruneDir refuses to prune when an include list exists, so the
|
||||||
|
// walk descends node_modules in full — this is the case the
|
||||||
|
// collapse pass has to handle.
|
||||||
|
name: "scoped",
|
||||||
|
include: []string{"/docs/"},
|
||||||
|
setup: func(t *testing.T, folder string) {
|
||||||
|
write(t, folder, ".bdrive/config.json", `{"include":["/docs/"]}`)
|
||||||
|
write(t, folder, IgnoreFile, "*.log\n")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
be := sharedRemote(t)
|
||||||
|
a := newDevice(t, "deva", be)
|
||||||
|
b := newDevice(t, "devb", be)
|
||||||
|
tc.setup(t, a.Folder)
|
||||||
|
|
||||||
|
write(t, a.Folder, "docs/guide.md", "synced")
|
||||||
|
write(t, a.Folder, "docs/deep/spec.md", "synced too")
|
||||||
|
write(t, a.Folder, "src/main.go", "maybe")
|
||||||
|
write(t, a.Folder, "scratch/notes.md", "maybe")
|
||||||
|
write(t, a.Folder, "debug.log", "never")
|
||||||
|
write(t, a.Folder, ".DS_Store", "never")
|
||||||
|
write(t, a.Folder, ".bdrive-tmp-x", "never")
|
||||||
|
write(t, a.Folder, ".git/HEAD", "never")
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
write(t, a.Folder, filepath.Join("node_modules/pkg", "f"+string(rune('a'+i))+".js"), "never")
|
||||||
|
}
|
||||||
|
write(t, a.Folder, "vendor/acme/.bdrive/config.json", `{"mount_id":"m-nested"}`)
|
||||||
|
write(t, a.Folder, "vendor/acme/inner.md", "own project")
|
||||||
|
if err := os.Symlink(filepath.Join(a.Folder, "docs/guide.md"), filepath.Join(a.Folder, "docs/link.md")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
synced, notSynced, err := Explain(a.Folder, tc.include)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. explain's synced set == the paths scan journals.
|
||||||
|
cycle(t, a)
|
||||||
|
ops, err := a.Store.DeviceOps(a.Device.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var put []string
|
||||||
|
for _, op := range ops {
|
||||||
|
if op.Kind == journal.KindPut {
|
||||||
|
put = append(put, op.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(put)
|
||||||
|
if !reflect.DeepEqual(synced, put) {
|
||||||
|
t.Fatalf("explain synced != journaled puts\n explain: %v\n scan: %v", synced, put)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. and == what actually landed on a second device.
|
||||||
|
cycle(t, b)
|
||||||
|
var got []string
|
||||||
|
filepath.WalkDir(b.Folder, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(b.Folder, p)
|
||||||
|
got = append(got, filepath.ToSlash(rel))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(got)
|
||||||
|
if !reflect.DeepEqual(synced, got) {
|
||||||
|
t.Fatalf("explain synced != files on peer\n explain: %v\n peer: %v", synced, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. the nested mount is annotated, never a plain exclusion.
|
||||||
|
nested := find(notSynced, "vendor/acme/")
|
||||||
|
if nested == nil {
|
||||||
|
t.Fatalf("nested mount missing from not-synced: %v", notSynced)
|
||||||
|
}
|
||||||
|
if !nested.Nested {
|
||||||
|
t.Fatal("nested mount must be marked Nested, not listed as excluded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. node_modules collapses to one counted line.
|
||||||
|
nm := find(notSynced, "node_modules/")
|
||||||
|
if nm == nil || nm.Files != 12 {
|
||||||
|
t.Fatalf("node_modules should be one entry with 12 files, got %+v (all: %v)", nm, notSynced)
|
||||||
|
}
|
||||||
|
if len(notSynced) > 12 {
|
||||||
|
t.Fatalf("not-synced list should stay small, got %d entries: %v", len(notSynced), notSynced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the builtin exclusions are visible — that is the reassurance
|
||||||
|
for _, want := range []string{".git/", ".DS_Store", ".bdrive-tmp-x", "docs/link.md"} {
|
||||||
|
if find(notSynced, want) == nil {
|
||||||
|
t.Fatalf("%s missing from not-synced: %v", want, notSynced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. byte-stable across runs.
|
||||||
|
synced2, notSynced2, err := Explain(a.Folder, tc.include)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(synced, synced2) || !reflect.DeepEqual(notSynced, notSynced2) {
|
||||||
|
t.Fatal("Explain output is not stable across runs")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplainScopedCollapsesUnsharedDirs pins the scoped-project shape: a
|
||||||
|
// directory outside the include list prints as one counted line rather than
|
||||||
|
// every file under it.
|
||||||
|
func TestExplainScopedCollapsesUnsharedDirs(t *testing.T) {
|
||||||
|
a := newDevice(t, "deva", nil)
|
||||||
|
write(t, a.Folder, ".bdrive/config.json", `{"include":["/docs/"]}`)
|
||||||
|
write(t, a.Folder, "docs/guide.md", "synced")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
write(t, a.Folder, filepath.Join("private/deep", "f"+string(rune('a'+i))+".txt"), "no")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, notSynced, err := Explain(a.Folder, []string{"/docs/"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
e := find(notSynced, "private/")
|
||||||
|
if e == nil || e.Files != 5 {
|
||||||
|
t.Fatalf("private/ should collapse to one line with 5 files, got %+v (all: %v)", e, notSynced)
|
||||||
|
}
|
||||||
|
if n := NotSyncedFiles(notSynced); n < 5 {
|
||||||
|
t.Fatalf("NotSyncedFiles = %d, should count the collapsed subtree", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func find(entries []Entry, path string) *Entry {
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Path == path {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -333,27 +333,8 @@ func (s *Session) scan(cache map[string]store.CachedFile, st *store.SyncState, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := filepath.WalkDir(s.Folder, func(p string, d fs.DirEntry, walkErr error) error {
|
err := walkFolder(s.Folder, filter, func(p, rel string, d fs.DirEntry, v verdict) error {
|
||||||
if walkErr != nil {
|
if v != vSync {
|
||||||
return nil // skip unreadable entries
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(s.Folder, p)
|
|
||||||
if err != nil || rel == "." {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
rel = filepath.ToSlash(rel)
|
|
||||||
if d.IsDir() {
|
|
||||||
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) {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
info, err := d.Info()
|
info, err := d.Info()
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package syncer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/runbear-io/beardrive/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verdict is what the sync predicate decided about one entry on disk.
|
||||||
|
type verdict int
|
||||||
|
|
||||||
|
const (
|
||||||
|
vDescend verdict = iota // directory, walk into it
|
||||||
|
vPruneDir // directory excluded whole (.git, .bdrive, PruneDir)
|
||||||
|
vNested // directory is a mount of its own (syncs separately)
|
||||||
|
vSkipFile // file excluded (non-regular, .DS_Store/.bdrive-tmp-*, Skip)
|
||||||
|
vSync // file syncs
|
||||||
|
)
|
||||||
|
|
||||||
|
// walkFolder walks a mount applying the exact predicate the sync cycle uses.
|
||||||
|
// fn sees every entry with its verdict; pruning happens here, so no caller can
|
||||||
|
// descend where scan would not. This is the only copy of the rules: scan and
|
||||||
|
// Explain both go through it, so what `bdrive scope --explain` reports cannot
|
||||||
|
// drift from what actually leaves the machine.
|
||||||
|
func walkFolder(folder string, filter *Filter, fn func(abs, rel string, d fs.DirEntry, v verdict) error) error {
|
||||||
|
return filepath.WalkDir(folder, func(p string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return nil // skip unreadable entries
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(folder, p)
|
||||||
|
if err != nil || rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
|
||||||
|
var v verdict
|
||||||
|
switch {
|
||||||
|
case !d.IsDir():
|
||||||
|
if !d.Type().IsRegular() || ignoredFile(d.Name()) || filter.Skip(rel) {
|
||||||
|
v = vSkipFile
|
||||||
|
} else {
|
||||||
|
v = vSync
|
||||||
|
}
|
||||||
|
case ignoreDirs[d.Name()] || filter.PruneDir(rel):
|
||||||
|
v = vPruneDir
|
||||||
|
case config.IsMount(p):
|
||||||
|
// A mount of its own: it syncs through its own project.
|
||||||
|
filter.addNestedMount(rel)
|
||||||
|
v = vNested
|
||||||
|
default:
|
||||||
|
v = vDescend
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fn(p, rel, d, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v == vPruneDir || v == vNested {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
|||||||
| Run the daemon in the foreground | `bdrive init -f` |
|
| Run the daemon in the foreground | `bdrive init -f` |
|
||||||
| Stop syncing | `bdrive stop [<folder>]` — pauses daemon *and* agent hooks; `bdrive init` resumes (`--forget` also unregisters) |
|
| Stop syncing | `bdrive stop [<folder>]` — pauses daemon *and* agent hooks; `bdrive init` resumes (`--forget` also unregisters) |
|
||||||
| Show/change which subfolders sync | `bdrive scope` / `bdrive scope add <dirs...>` / `bdrive scope rm <dirs...>` — edits the managed block of `.bdriveignore` rules that `init --only` writes (run from the mount root; never hand-write the negation syntax). The daemon applies it within seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub; removing the last entry is refused (that would flip to whole-folder sync — use `bdrive stop` instead) |
|
| Show/change which subfolders sync | `bdrive scope` / `bdrive scope add <dirs...>` / `bdrive scope rm <dirs...>` — edits the managed block of `.bdriveignore` rules that `init --only` writes (run from the mount root; never hand-write the negation syntax). The daemon applies it within seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub; removing the last entry is refused (that would flip to whole-folder sync — use `bdrive stop` instead) |
|
||||||
|
| Verify what actually leaves this machine | `bdrive scope --explain` — walks the folder and prints two sorted lists, `synced` and `not synced`, with counts; fully-excluded directories collapse to one counted line and nested mounts are marked as syncing through their own project. Pure read: safe with the daemon running and offline, takes no lock, makes no network call. Answers "what leaves from now on", NOT "what is already on the hub" — that's `bdrive forget` below. Output is stable, so `bdrive scope --explain > before.txt` + `diff` is a real audit |
|
||||||
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--prune` also removes from the hub whatever `.bdriveignore` now excludes (refuses when `.bdriveignore` narrows the scope with `!` rules — see below); `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
|
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--prune` also removes from the hub whatever `.bdriveignore` now excludes (refuses when `.bdriveignore` narrows the scope with `!` rules — see below); `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
|
||||||
| Stop syncing a path **and** take it off the hub | `bdrive forget <path>...` — appends the rule to `.bdriveignore` (trailing `/` for a directory) and prunes in the same run. **Deletes nothing on disk**, here or on teammates' devices: they receive the rule with the removal and just stop tracking the path. Idempotent; a path outside the project errors and writes nothing. This is the ONLY way to clean up something that synced before you excluded it — plain `.bdriveignore` edits and `bdrive scope rm` leave the hub's copy in place |
|
| Stop syncing a path **and** take it off the hub | `bdrive forget <path>...` — appends the rule to `.bdriveignore` (trailing `/` for a directory) and prunes in the same run. **Deletes nothing on disk**, here or on teammates' devices: they receive the rule with the removal and just stop tracking the path. Idempotent; a path outside the project errors and writes nothing. This is the ONLY way to clean up something that synced before you excluded it — plain `.bdriveignore` edits and `bdrive scope rm` leave the hub's copy in place |
|
||||||
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install` — merges pull/push/session-note/read-tracking hooks into each platform's USER config (`~/.claude/settings.json` and friends), once per machine, idempotently. `bdrive init` runs it automatically, so this is mainly for retries or `--agent`-targeting an undetected platform; bare `bdrive hooks` shows the status table; `bdrive hooks uninstall` removes only our entries |
|
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install` — merges pull/push/session-note/read-tracking hooks into each platform's USER config (`~/.claude/settings.json` and friends), once per machine, idempotently. `bdrive init` runs it automatically, so this is mainly for retries or `--agent`-targeting an undetected platform; bare `bdrive hooks` shows the status table; `bdrive hooks uninstall` removes only our entries |
|
||||||
|
|||||||
@@ -94,6 +94,56 @@ the hub keeps everything already synced (the same
|
|||||||
block means the whole folder syncs; if you want to stop syncing entirely, that's
|
block means the whole folder syncs; if you want to stop syncing entirely, that's
|
||||||
`bdrive stop`.
|
`bdrive stop`.
|
||||||
|
|
||||||
|
## Check what actually leaves your machine
|
||||||
|
|
||||||
|
Rules are one thing; their effect is another. `bdrive scope --explain` walks the
|
||||||
|
folder and prints every path it found, split into what syncs and what does not:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bdrive scope --explain
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
synced (4)
|
||||||
|
.bdriveignore
|
||||||
|
docs/architecture.md
|
||||||
|
docs/onboarding.md
|
||||||
|
specs/BEA-24.md
|
||||||
|
|
||||||
|
not synced (2,486)
|
||||||
|
.DS_Store
|
||||||
|
.bdrive/ (1 file)
|
||||||
|
.env
|
||||||
|
.git/ (312 files)
|
||||||
|
node_modules/ (2,481 files)
|
||||||
|
scratch/notes.md
|
||||||
|
vendor/acme/ (own project — syncs separately)
|
||||||
|
|
||||||
|
4 files sync, 2,486 do not.
|
||||||
|
```
|
||||||
|
|
||||||
|
A directory that is excluded whole collapses to one counted line, so a folder
|
||||||
|
with `node_modules` in it prints a handful of lines, not thousands. A nested
|
||||||
|
mount is labelled rather than called "not synced" — it *does* sync, through its
|
||||||
|
own project.
|
||||||
|
|
||||||
|
The decisions come from the same walk the sync cycle itself uses, so what this
|
||||||
|
prints cannot drift from what actually leaves. It is a pure read: safe to run
|
||||||
|
while the daemon is running and while you are offline, it takes no lock and
|
||||||
|
makes no network call. Output is sorted and stable, which makes it diffable —
|
||||||
|
the way to prove a rule change did what you meant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bdrive scope --explain > before.txt
|
||||||
|
# edit .bdriveignore
|
||||||
|
bdrive scope --explain > after.txt
|
||||||
|
diff before.txt after.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
One thing it does **not** answer: whether a path you exclude *today* is already
|
||||||
|
on the hub from before the rule existed. Excluding it stops future syncs but
|
||||||
|
leaves the copy up there — `bdrive forget <path>` is what takes it off.
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
A scoped mount is also where the two-file
|
A scoped mount is also where the two-file
|
||||||
[`AGENTS.md` pattern](/guides/shared-agent-memory/) earns its keep — the synced
|
[`AGENTS.md` pattern](/guides/shared-agent-memory/) earns its keep — the synced
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
|
|||||||
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also installs the agent skill, registers agent sync hooks for detected platforms (`--no-hooks` skips the hooks only), and prints the project's hub link. Re-run to resume |
|
| `bdrive init [folder]` | Create or connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY; flags (`--name`, `--project`, `--server`, `--only`, `--yes`) for scripts. Also installs the agent skill, registers agent sync hooks for detected platforms (`--no-hooks` skips the hooks only), and prints the project's hub link. Re-run to resume |
|
||||||
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
|
| `bdrive stop [folder]` | Stop syncing — daemon and agent sync hooks both pause. Files stay on disk; `bdrive init` resumes |
|
||||||
| `bdrive scope [add\|rm <dirs...>]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes. Run from the mount root; the daemon picks changes up in seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub |
|
| `bdrive scope [add\|rm <dirs...>]` | Show or change which subfolders sync — edits the managed block of `.bdriveignore` rules that `init --only` writes. Run from the mount root; the daemon picks changes up in seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub |
|
||||||
|
| `bdrive scope --explain` | List every path in the folder, split into what syncs and what does not, with counts — the verifiable answer to "what leaves this machine". Pure read: no daemon, no lock, no network |
|
||||||
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
|
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
|
||||||
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
|
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
|
||||||
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link) |
|
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link) |
|
||||||
|
|||||||
Reference in New Issue
Block a user