mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(sync): bdrive forget + sync --prune to take ignored paths off the hub (BEA-20) (#68)
Adding a path to .bdriveignore only stopped future uploads: anything that synced before the rule existed stayed on the hub forever, with no command that removed it without deleting it from local disk on every device. Two engine changes make an explicit removal safe: - materialize's delete loop now consults the filter. A cached path absent from the replayed target that the rules exclude is dropped from tracking instead of unlinked — without this, any delete op for a now-filtered path wipes every peer's local copy, which is the data loss this issue is about. - the filter is reloaded mid-cycle from the pulled .bdriveignore, before materialize. A peer receiving the new rules and the deletes they justify in one batch would otherwise materialize with stale rules and the guard would never fire. materialize's write side is split into materializeFile so the ignore file can land on its own. On top of that, Session.Prune journals a delete for every path the replayed state still holds that the SHARED rules exclude — reconciling against the replay, not the local cache, because a path filtered out in an earlier cycle was dropped from the cache back then and is invisible locally today. The rules are deliberately ignore-only: the include scope lives in each device's own .bdrive/config.json and does not sync, so pruning against it would let a narrow-scope device delete a whole-folder teammate's files. Plain `bdrive sync` and the daemon are unchanged — pruning is never a side effect of editing .bdriveignore. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
411809a785
commit
9088127176
@@ -141,9 +141,10 @@ hub's own storage, never something a syncing client points at directly:
|
||||
| `bdrive init [folder]` | Create/connect a project and start syncing — interactive on a TTY, flags (`--name/--project/--shared/--yes`) for scripts; re-run to resume |
|
||||
| `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 (the include list set by `init --shared`) — no JSON editing; the daemon picks changes up in seconds. `rm` deletes nothing, locally or on the hub |
|
||||
| `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 share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
|
||||
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking; idempotent (`--agent` overrides detection) |
|
||||
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms (`~/.codex/skills/beardrive/SKILL.md` and friends) so the agent can do the setup itself — sign in, `bdrive init`, and register the sync hooks; idempotent (`--agent` overrides detection) |
|
||||
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
|
||||
@@ -179,7 +180,11 @@ the project:
|
||||
```
|
||||
|
||||
Opting out is non-destructive: when a pattern starts matching an
|
||||
already-synced file, the file stops syncing but is deleted nowhere.
|
||||
already-synced file, the file stops syncing but is deleted nowhere — which
|
||||
also means the hub keeps the copy it already has. `bdrive forget <path>` (or
|
||||
`bdrive sync --prune` for rules you added by hand) takes it off the hub, and
|
||||
still deletes nothing on disk: every device receives the rule alongside the
|
||||
removal and simply stops tracking the path.
|
||||
|
||||
## Web server
|
||||
|
||||
|
||||
@@ -19,14 +19,16 @@ classDiagram
|
||||
+Account config.Settings
|
||||
+Backend remote.Backend
|
||||
+Note string
|
||||
+Prune bool
|
||||
+OnProgress func
|
||||
+Cycle(ctx) Result
|
||||
}
|
||||
note for Session "internal/syncer — scan → commit local ops → pull peer journals → preserve conflicts → materialize → push blobs then own journal"
|
||||
note for Session "internal/syncer — scan → commit local ops → pull peer journals → preserve conflicts → refresh rules → prune → materialize → push blobs then own journal"
|
||||
note for Session "Prune (bdrive forget / sync --prune, never the daemon) journals a delete for every replayed path the SHARED ignore rules exclude — the include scope is per-device and must never prune it"
|
||||
|
||||
class Result {
|
||||
+LocalOps +PulledOps
|
||||
+Conflicts +Materialized
|
||||
+Conflicts +Pruned +Materialized
|
||||
+Pushed +Offline +OfflineErr
|
||||
+ReadOnly +NoAccess +AccessErr
|
||||
}
|
||||
@@ -88,7 +90,7 @@ classDiagram
|
||||
|
||||
class Commands {
|
||||
init login logout
|
||||
sync stop scope status log
|
||||
sync stop scope forget status log
|
||||
url share export import
|
||||
web daemon hooks read-log skill
|
||||
}
|
||||
|
||||
+15
-1
@@ -17,10 +17,22 @@ func syncCmd() *cobra.Command {
|
||||
var note string
|
||||
var noteTTL time.Duration
|
||||
var hookLabel string
|
||||
var prune bool
|
||||
c := &cobra.Command{
|
||||
Use: "sync [folder]",
|
||||
Short: "Sync a mounted folder with its remote now",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Long: `Run one sync cycle now: journal local changes, pull teammates' changes,
|
||||
and push.
|
||||
|
||||
--prune additionally reconciles the hub against .bdriveignore: anything the
|
||||
hub still holds that the ignore rules now exclude is removed from the hub
|
||||
while staying on disk, here and on every teammate's device. That is the
|
||||
cleanup path for files that synced before the rule was added. It never
|
||||
prunes paths excluded only by this device's own sync scope (bdrive scope) —
|
||||
a narrow scope means "not on my disk", not "not on the hub".`,
|
||||
Example: ` bdrive sync
|
||||
bdrive sync --prune # also drop hub files that .bdriveignore now excludes`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
folder, err := absFolder(args)
|
||||
if err != nil {
|
||||
@@ -65,6 +77,7 @@ func syncCmd() *cobra.Command {
|
||||
}
|
||||
sess.Note = note
|
||||
}
|
||||
sess.Prune = prune
|
||||
sess.OnProgress = progressReporter()
|
||||
res, err := sess.Cycle(cmd.Context())
|
||||
if err != nil {
|
||||
@@ -75,6 +88,7 @@ func syncCmd() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().BoolVar(&prune, "prune", false, "also remove from the hub what .bdriveignore now excludes (files stay on disk everywhere)")
|
||||
c.Flags().StringVar(¬e, "note", "", "session context stamped onto changes (e.g. an agent session id); shown in history; empty clears")
|
||||
c.Flags().DurationVar(¬eTTL, "note-ttl", 30*time.Minute, "how long the note keeps applying to daemon-committed changes")
|
||||
c.Flags().StringVar(&hookLabel, "hook", "", "agent-hook mode: read the platform's hook event JSON from stdin, sync with a session note labeled by this value, and emit the project's link-formula context (Claude Code hook JSON) on stdout")
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/syncer"
|
||||
)
|
||||
|
||||
// forgetCmd is the one-step "stop syncing this and clean it up": it writes the
|
||||
// rule into .bdriveignore (which syncs, so every device agrees) and then runs
|
||||
// a prune cycle to remove what already reached the hub. Nothing is deleted
|
||||
// from disk, here or anywhere else.
|
||||
func forgetCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "forget <path>...",
|
||||
Short: "Stop syncing a path and remove it from the hub (keeps local files)",
|
||||
Long: `Add a path to .bdriveignore and remove what already synced from the hub.
|
||||
|
||||
Local files are never touched — not here, not on teammates' devices. Only the
|
||||
hub's copy goes away, and because .bdriveignore syncs, every device stops
|
||||
tracking the path as it picks up the rule.
|
||||
|
||||
Use this to clean up something that synced before you meant to exclude it.
|
||||
Nothing is destroyed: the removal is an ordinary journaled delete, so it shows
|
||||
in bdrive log and the hub keeps every past version in history.`,
|
||||
Example: ` bdrive forget .omc # stop syncing ./.omc and drop it from the hub
|
||||
bdrive forget notes/private.md
|
||||
bdrive sync --prune # re-run the cleanup for rules you added by hand`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, proj, err := findProject(cwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch syncBlocked(proj) {
|
||||
case "init":
|
||||
return fmt.Errorf("%s is not synced on this device yet (run `bdrive init` there to connect it)", root)
|
||||
case "paused":
|
||||
return fmt.Errorf("syncing is paused for %s (run `bdrive init` there to resume)", root)
|
||||
}
|
||||
|
||||
// Resolve every path first: an argument outside the project is an
|
||||
// error that writes nothing at all.
|
||||
rules := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
rule, err := ignoreRule(root, arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
added, err := appendIgnoreRules(root, rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if added[rule] {
|
||||
fmt.Printf("added `%s` to %s\n", rule, syncer.IgnoreFile)
|
||||
} else {
|
||||
fmt.Printf("`%s` was already in %s\n", rule, syncer.IgnoreFile)
|
||||
}
|
||||
}
|
||||
|
||||
sess, proj, err := openSession(cmd.Context(), root, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeSession(sess)
|
||||
sess.Prune = true
|
||||
sess.OnProgress = progressReporter()
|
||||
res, err := sess.Cycle(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("synced %s (project %q)\n", root, proj.Volume)
|
||||
printCycle(res)
|
||||
if res.Pruned == 0 {
|
||||
fmt.Println(" (nothing left to remove from the hub)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ignoreRule turns a command-line path into a .bdriveignore line, relative to
|
||||
// the mount root. Directories get a trailing slash so the rule covers their
|
||||
// contents, matching gitignore's reading of the same syntax.
|
||||
func ignoreRule(root, arg string) (string, error) {
|
||||
abs, err := filepath.Abs(arg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("%s is outside the project at %s", abs, root)
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel == syncer.IgnoreFile {
|
||||
return "", fmt.Errorf("%s carries the rules themselves and always syncs", syncer.IgnoreFile)
|
||||
}
|
||||
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
|
||||
rel += "/"
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
// appendIgnoreRules adds any rules the file does not already carry, and
|
||||
// reports which ones it wrote. Idempotent: re-running only prunes.
|
||||
func appendIgnoreRules(root string, rules []string) (map[string]bool, error) {
|
||||
path := filepath.Join(root, syncer.IgnoreFile)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
present := map[string]bool{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
present[strings.TrimSpace(line)] = true
|
||||
}
|
||||
added := map[string]bool{}
|
||||
body := string(data)
|
||||
for _, rule := range rules {
|
||||
if present[rule] || added[rule] {
|
||||
continue
|
||||
}
|
||||
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
body += rule + "\n"
|
||||
added[rule] = true
|
||||
}
|
||||
if len(added) == 0 {
|
||||
return added, nil
|
||||
}
|
||||
return added, os.WriteFile(path, []byte(body), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIgnoreRule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "notes", ".omc"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "notes", "private.md"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ arg, want string }{
|
||||
{filepath.Join(root, "notes", ".omc"), "notes/.omc/"}, // a directory covers its contents
|
||||
{filepath.Join(root, "notes", "private.md"), "notes/private.md"},
|
||||
{filepath.Join(root, "gone.txt"), "gone.txt"}, // need not exist
|
||||
} {
|
||||
got, err := ignoreRule(root, tc.arg)
|
||||
if err != nil {
|
||||
t.Fatalf("ignoreRule(%s): %v", tc.arg, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ignoreRule(%s) = %q, want %q", tc.arg, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Outside the project, and the rules file itself, are errors.
|
||||
for _, bad := range []string{filepath.Dir(root), root, filepath.Join(root, "..", "elsewhere"), filepath.Join(root, ".bdriveignore")} {
|
||||
if got, err := ignoreRule(root, bad); err == nil {
|
||||
t.Errorf("ignoreRule(%s) = %q, want an error", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendIgnoreRules(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, ".bdriveignore")
|
||||
if err := os.WriteFile(path, []byte("*.tmp"), 0o644); err != nil { // no trailing newline
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
added, err := appendIgnoreRules(root, []string{".omc/", "*.tmp", ".omc/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(added) != 1 || !added[".omc/"] {
|
||||
t.Fatalf("added = %v, want only .omc/", added)
|
||||
}
|
||||
if got := string(mustRead(t, path)); got != "*.tmp\n.omc/\n" {
|
||||
t.Fatalf("file = %q", got)
|
||||
}
|
||||
|
||||
// Idempotent: a second run writes nothing.
|
||||
before := mustRead(t, path)
|
||||
added, err = appendIgnoreRules(root, []string{".omc/"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(added) != 0 {
|
||||
t.Fatalf("added = %v on a repeat run", added)
|
||||
}
|
||||
if string(mustRead(t, path)) != string(before) {
|
||||
t.Fatal("repeat run rewrote the file")
|
||||
}
|
||||
}
|
||||
|
||||
func mustRead(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -122,6 +122,9 @@ func printCycle(res *syncer.Result) {
|
||||
if res.Conflicts > 0 {
|
||||
fmt.Printf(" conflicts: %d (preserved as *.bdrive-conflict-* files)\n", res.Conflicts)
|
||||
}
|
||||
if res.Pruned > 0 {
|
||||
fmt.Printf(" pruned: %d path(s) removed from the hub (kept on disk)\n", res.Pruned)
|
||||
}
|
||||
fmt.Printf(" files updated: %d\n", res.Materialized)
|
||||
switch {
|
||||
case res.NoAccess:
|
||||
|
||||
@@ -53,6 +53,7 @@ everything keeps working offline; changes sync when the remote is reachable.`,
|
||||
urlCmd(),
|
||||
stopCmd(),
|
||||
scopeCmd(),
|
||||
forgetCmd(),
|
||||
syncCmd(),
|
||||
readLogCmd(),
|
||||
hooksCmd(),
|
||||
|
||||
+2
-1
@@ -25,7 +25,8 @@ folder syncs. Run from the mount root; the daemon picks changes up within
|
||||
seconds.
|
||||
|
||||
Removing a folder stops syncing it but deletes nothing — local files stay,
|
||||
and the hub keeps everything already synced.`,
|
||||
and the hub keeps everything already synced. To take something off the hub
|
||||
too, use ` + "`bdrive forget <path>`" + `.`,
|
||||
Example: ` bdrive scope # show what syncs
|
||||
bdrive scope add docs # also sync ./docs
|
||||
bdrive scope rm docs # stop syncing ./docs (files stay everywhere)`,
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package syncer
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
)
|
||||
|
||||
// Multi-device behavior of `bdrive sync --prune`: removing from the hub what
|
||||
// .bdriveignore excludes, without any device losing a byte on disk.
|
||||
|
||||
// hubState is what the devices converge to — the replayed journals, i.e. what
|
||||
// the hub still holds.
|
||||
func hubState(t *testing.T, s *Session) map[string]journal.FileState {
|
||||
t.Helper()
|
||||
all, err := s.Store.AllOps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return journal.Replay(all)
|
||||
}
|
||||
|
||||
func prune(t *testing.T, s *Session) *Result {
|
||||
t.Helper()
|
||||
s.Prune = true
|
||||
defer func() { s.Prune = false }()
|
||||
return cycle(t, s)
|
||||
}
|
||||
|
||||
func exists(t *testing.T, folder, rel string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Stat(filepath.Join(folder, filepath.FromSlash(rel)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// The headline behavior: A prunes an ignored path, the hub loses it, and B
|
||||
// keeps its local copy while dropping it from tracking and pushing nothing
|
||||
// back.
|
||||
func TestPruneRemovesFromHubKeepsLocal(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, "notes.md", "keep me")
|
||||
write(t, a.Folder, ".omc/state.json", "{}")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
if !exists(t, b.Folder, ".omc/state.json") {
|
||||
t.Fatal("setup: the file should have synced to b")
|
||||
}
|
||||
|
||||
write(t, a.Folder, IgnoreFile, ".omc/\n")
|
||||
res := prune(t, a)
|
||||
if res.Pruned != 1 {
|
||||
t.Fatalf("Pruned = %d, want 1", res.Pruned)
|
||||
}
|
||||
if _, ok := hubState(t, a)[".omc/state.json"]; ok {
|
||||
t.Fatal("pruned path should be gone from the replayed state")
|
||||
}
|
||||
if !exists(t, a.Folder, ".omc/state.json") {
|
||||
t.Fatal("prune must not touch the pruning device's own disk")
|
||||
}
|
||||
|
||||
res = cycle(t, b)
|
||||
if !exists(t, b.Folder, ".omc/state.json") {
|
||||
t.Fatal("peer lost its local copy — this is the data loss prune exists to avoid")
|
||||
}
|
||||
if _, ok := hubState(t, b)[".omc/state.json"]; ok {
|
||||
t.Fatal("peer should see the path gone from the merged state")
|
||||
}
|
||||
if read(t, b.Folder, "notes.md") != "keep me" {
|
||||
t.Fatal("unrelated file disturbed")
|
||||
}
|
||||
// ...and nothing gets pushed back: the path is out of scope now.
|
||||
if res = cycle(t, b); res.LocalOps != 0 {
|
||||
t.Fatalf("peer re-journaled %d op(s) for a path it no longer tracks", res.LocalOps)
|
||||
}
|
||||
if _, ok := hubState(t, a)[".omc/state.json"]; ok {
|
||||
t.Fatal("path came back to the hub")
|
||||
}
|
||||
}
|
||||
|
||||
// The motivating case: the path was dropped from the local cache cycles ago,
|
||||
// when the ignore rule was first added, so nothing local remembers it. Prune
|
||||
// reconciles against the replayed state, not the cache, so it still finds it.
|
||||
func TestPruneFindsHistoricallyDroppedPaths(t *testing.T) {
|
||||
a := newDevice(t, "deva", sharedRemote(t))
|
||||
|
||||
write(t, a.Folder, ".omc/a.txt", "one")
|
||||
write(t, a.Folder, ".omc/b.txt", "two")
|
||||
write(t, a.Folder, "keep.md", "kept")
|
||||
cycle(t, a)
|
||||
|
||||
// The rule lands, and plain sync silently stops tracking the paths —
|
||||
// today's behavior, unchanged.
|
||||
write(t, a.Folder, IgnoreFile, ".omc/\n")
|
||||
res := cycle(t, a)
|
||||
if res.LocalOps != 1 { // the .bdriveignore put only, no deletes
|
||||
t.Fatalf("plain sync journaled %d ops, want 1 (the ignore file)", res.LocalOps)
|
||||
}
|
||||
cycle(t, a)
|
||||
cycle(t, a) // several cycles later, nothing local remembers .omc/
|
||||
|
||||
res = prune(t, a)
|
||||
if res.Pruned != 2 {
|
||||
t.Fatalf("Pruned = %d, want 2 (both historically dropped paths)", res.Pruned)
|
||||
}
|
||||
state := hubState(t, a)
|
||||
if _, ok := state[".omc/a.txt"]; ok {
|
||||
t.Fatal(".omc/a.txt still on the hub")
|
||||
}
|
||||
if _, ok := state["keep.md"]; !ok {
|
||||
t.Fatal("prune removed an unrelated path")
|
||||
}
|
||||
if !exists(t, a.Folder, ".omc/a.txt") || !exists(t, a.Folder, ".omc/b.txt") {
|
||||
t.Fatal("prune deleted local files")
|
||||
}
|
||||
}
|
||||
|
||||
// Prune reconciles against the shared rules only. A device's own --shared
|
||||
// scope is a statement about its disk, not about what the team may hold, so
|
||||
// pruning must never act on it.
|
||||
func TestPruneIgnoresPerDeviceScope(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, "docs/guide.md", "shared")
|
||||
write(t, a.Folder, "src/main.go", "package main")
|
||||
write(t, a.Folder, ".omc/state.json", "{}")
|
||||
cycle(t, a)
|
||||
|
||||
// B only wants docs/ locally.
|
||||
write(t, b.Folder, ".bdrive/config.json", `{"include": ["/docs/"]}`)
|
||||
cycle(t, b)
|
||||
if exists(t, b.Folder, "src/main.go") {
|
||||
t.Fatal("setup: src/ is outside b's scope")
|
||||
}
|
||||
|
||||
res := prune(t, b)
|
||||
if res.Pruned != 0 {
|
||||
t.Fatalf("b pruned %d path(s); a narrow scope must never delete a teammate's files", res.Pruned)
|
||||
}
|
||||
if _, ok := hubState(t, b)["src/main.go"]; !ok {
|
||||
t.Fatal("out-of-scope path was removed from the hub")
|
||||
}
|
||||
|
||||
// The shared rule is a different matter: once it exists, either device
|
||||
// may prune it, and b's scope still isn't consulted.
|
||||
write(t, b.Folder, IgnoreFile, ".omc/\n")
|
||||
res = prune(t, b)
|
||||
if res.Pruned != 1 {
|
||||
t.Fatalf("Pruned = %d, want 1 (the ignored path only)", res.Pruned)
|
||||
}
|
||||
state := hubState(t, b)
|
||||
if _, ok := state["src/main.go"]; !ok {
|
||||
t.Fatal("b's scope exclusion got pruned alongside the ignore rule")
|
||||
}
|
||||
cycle(t, a)
|
||||
if read(t, a.Folder, "src/main.go") != "package main" {
|
||||
t.Fatal("a lost a file that only b's scope excluded")
|
||||
}
|
||||
}
|
||||
|
||||
// The race the report called unclosable: a peer receives the new rules and
|
||||
// the deletes they justify in the SAME batch. The filter is reloaded from the
|
||||
// pulled ignore file mid-cycle, so the files survive that cycle — not the
|
||||
// next one.
|
||||
func TestPruneRaceWithPulledIgnoreFile(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, ".omc/state.json", "{}")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
|
||||
write(t, a.Folder, IgnoreFile, ".omc/\n")
|
||||
prune(t, a) // rules and deletes are pushed together
|
||||
|
||||
cycle(t, b) // exactly one cycle
|
||||
if !exists(t, b.Folder, ".omc/state.json") {
|
||||
t.Fatal("peer unlinked the file in the cycle that delivered the rules")
|
||||
}
|
||||
if read(t, b.Folder, IgnoreFile) != ".omc/\n" {
|
||||
t.Fatal("peer should have the new rules after that cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneIsIdempotent(t *testing.T) {
|
||||
a := newDevice(t, "deva", sharedRemote(t))
|
||||
|
||||
write(t, a.Folder, ".omc/state.json", "{}")
|
||||
cycle(t, a)
|
||||
write(t, a.Folder, IgnoreFile, ".omc/\n")
|
||||
|
||||
if res := prune(t, a); res.Pruned != 1 {
|
||||
t.Fatalf("first prune: Pruned = %d, want 1", res.Pruned)
|
||||
}
|
||||
if res := prune(t, a); res.Pruned != 0 {
|
||||
t.Fatalf("second prune: Pruned = %d, want 0", res.Pruned)
|
||||
}
|
||||
}
|
||||
|
||||
// Accepted residual: a peer that edits the file in the window between the
|
||||
// prune and its own pull wins by lamport order and the file returns to the
|
||||
// hub. Nothing is silent about it — it shows in history — and a second prune
|
||||
// removes it again.
|
||||
func TestPeerEditResurrectsUntilPrunedAgain(t *testing.T) {
|
||||
be := sharedRemote(t)
|
||||
a := newDevice(t, "deva", be)
|
||||
b := newDevice(t, "devb", be)
|
||||
|
||||
write(t, a.Folder, "debug.log", "v1")
|
||||
cycle(t, a)
|
||||
cycle(t, b)
|
||||
|
||||
// B works offline for a while, so its clock runs ahead of A's.
|
||||
b.Backend = nil
|
||||
for i := range 5 {
|
||||
write(t, b.Folder, "work/f"+string(rune('a'+i))+".txt", "busy")
|
||||
cycle(t, b)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(t, b.Folder, "debug.log", "edited while a was pruning")
|
||||
cycle(t, b)
|
||||
|
||||
write(t, a.Folder, IgnoreFile, "*.log\n")
|
||||
if res := prune(t, a); res.Pruned != 1 {
|
||||
t.Fatalf("Pruned = %d, want 1", res.Pruned)
|
||||
}
|
||||
|
||||
b.Backend = be
|
||||
cycle(t, b) // b's later put beats a's delete
|
||||
if !exists(t, b.Folder, "debug.log") {
|
||||
t.Fatal("b lost its own edit")
|
||||
}
|
||||
cycle(t, a)
|
||||
if _, ok := hubState(t, a)["debug.log"]; !ok {
|
||||
t.Fatal("the peer's concurrent edit should have resurrected the path")
|
||||
}
|
||||
|
||||
// A second prune settles it, and still nobody loses a file.
|
||||
if res := prune(t, a); res.Pruned != 1 {
|
||||
t.Fatalf("second prune: Pruned = %d, want 1", res.Pruned)
|
||||
}
|
||||
if _, ok := hubState(t, a)["debug.log"]; ok {
|
||||
t.Fatal("still on the hub after a second prune")
|
||||
}
|
||||
cycle(t, b)
|
||||
if !exists(t, b.Folder, "debug.log") || !exists(t, a.Folder, "debug.log") {
|
||||
t.Fatal("a prune deleted a local file")
|
||||
}
|
||||
}
|
||||
+156
-30
@@ -21,6 +21,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -61,7 +62,14 @@ type Session struct {
|
||||
// store's persisted session note (store.LoadNote), which lets a one-shot
|
||||
// `bdrive sync --note` leave context that the daemon's later scans also
|
||||
// stamp. Conflict-copy ops keep their own explanatory note.
|
||||
Note string
|
||||
Note string
|
||||
// Prune makes this cycle reconcile the hub against the shared ignore
|
||||
// rules: every path the remote still holds that .bdriveignore (or a
|
||||
// builtin never-sync rule) now excludes is journaled as a delete, so it
|
||||
// leaves the hub while staying on disk on every device. Off by default —
|
||||
// plain `bdrive sync` and the daemon never set it, because pruning must
|
||||
// be a deliberate act, never a side effect of editing .bdriveignore.
|
||||
Prune bool
|
||||
Backend remote.Backend // nil = work offline
|
||||
// OnProgress, when set, is called during push with upload progress. It may
|
||||
// be invoked concurrently from upload workers, so it must be safe to call
|
||||
@@ -91,6 +99,7 @@ type Result struct {
|
||||
LocalOps int // local changes committed to the journal
|
||||
PulledOps int // ops received from other devices
|
||||
Conflicts int // conflict copies created
|
||||
Pruned int // paths removed from the hub by --prune (kept on disk)
|
||||
Materialized int // files written/removed in the working folder
|
||||
Pushed bool // own journal/blobs uploaded
|
||||
Offline bool // remote configured but unreachable this cycle
|
||||
@@ -101,7 +110,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
func (r *Result) Activity() bool {
|
||||
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Materialized > 0
|
||||
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Pruned > 0 || r.Materialized > 0
|
||||
}
|
||||
|
||||
// The .bdrive settings dir (config.ProjectDir) never syncs: it is the
|
||||
@@ -204,11 +213,49 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
|
||||
return nil, fmt.Errorf("read journals: %w", err)
|
||||
}
|
||||
target := journal.Replay(all)
|
||||
|
||||
// The ignore rules sync like any other file, so a peer can receive the new
|
||||
// .bdriveignore and the delete ops it justifies in the same batch. The
|
||||
// filter was loaded at the top of the cycle, before the pull, so write the
|
||||
// rules first and reload from them — otherwise materialize's delete loop
|
||||
// runs against stale rules, its filter guard never fires, and it unlinks
|
||||
// files that merely left sync scope.
|
||||
if want, ok := target[IgnoreFile]; ok && len(pulled) > 0 {
|
||||
wrote, err := s.materializeFile(IgnoreFile, want, cache)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("materialize %s: %w", IgnoreFile, err)
|
||||
}
|
||||
if wrote {
|
||||
res.Materialized++
|
||||
if filter, err = loadFilter(s.Folder, proj.Include); err != nil {
|
||||
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
|
||||
}
|
||||
}
|
||||
// If the blob isn't fetched yet materializeFile skips it and the old
|
||||
// rules stand: the usual retry-next-cycle posture, and the guard in
|
||||
// materialize still protects the files either way.
|
||||
}
|
||||
|
||||
// 4b. Prune: remove from the hub what the shared rules now exclude.
|
||||
if s.Prune {
|
||||
pruneOps, err := s.pruneOps(target, &st, int64(len(myOps)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pruneOps) > 0 {
|
||||
if err := s.Store.AppendOps(s.Device.ID, pruneOps); err != nil {
|
||||
return nil, fmt.Errorf("append prune ops: %w", err)
|
||||
}
|
||||
myOps = append(myOps, pruneOps...)
|
||||
res.Pruned = len(pruneOps)
|
||||
}
|
||||
}
|
||||
|
||||
n, err := s.materialize(target, cache, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("materialize: %w", err)
|
||||
}
|
||||
res.Materialized = n
|
||||
res.Materialized += n
|
||||
|
||||
// 5. Push our blobs and journal.
|
||||
if s.Backend != nil && !res.Offline && int64(len(myOps)) > st.PushedOps {
|
||||
@@ -516,42 +563,28 @@ func (s *Session) materialize(target map[string]journal.FileState, cache map[str
|
||||
if filter.Skip(rel) {
|
||||
continue
|
||||
}
|
||||
c, ok := cache[rel]
|
||||
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
||||
if fi, err := os.Stat(abs); err == nil {
|
||||
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
|
||||
continue // dirty: changed mid-cycle, next scan commits it
|
||||
}
|
||||
if !ok {
|
||||
// Untracked file already at this path: adopt if identical,
|
||||
// otherwise leave it for the next scan to journal.
|
||||
sum, err := hashFile(abs)
|
||||
if err != nil || sum != want.Blob {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.Store.HasBlob(want.Blob) {
|
||||
continue // content not fetched yet; retry next cycle
|
||||
}
|
||||
if err := s.writeFile(abs, want); err != nil {
|
||||
return changed, fmt.Errorf("write %s: %w", rel, err)
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
wrote, err := s.materializeFile(rel, want, cache)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
|
||||
changed++
|
||||
if wrote {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
for rel, c := range cache {
|
||||
if _, ok := target[rel]; ok {
|
||||
continue
|
||||
}
|
||||
if filter.Skip(rel) {
|
||||
// The path left sync scope rather than being deleted — someone
|
||||
// ignored it, or `--prune` removed it from the hub. Stop tracking
|
||||
// it; the file itself is ours to keep. Without this guard a prune
|
||||
// (or any delete op for a now-filtered path) unlinks every peer's
|
||||
// local copy, which is the data loss the feature exists to avoid.
|
||||
delete(cache, rel)
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
||||
if fi, err := os.Stat(abs); err == nil {
|
||||
if fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS {
|
||||
@@ -568,6 +601,99 @@ func (s *Session) materialize(target map[string]journal.FileState, cache map[str
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// materializeFile writes one path of the merged state into the working
|
||||
// folder, reporting whether it wrote. It never clobbers a file that changed
|
||||
// since the scan earlier in this cycle. Split out of materialize so the cycle
|
||||
// can land .bdriveignore on its own, before the rules are needed.
|
||||
func (s *Session) materializeFile(rel string, want journal.FileState, cache map[string]store.CachedFile) (bool, error) {
|
||||
c, ok := cache[rel]
|
||||
if ok && c.Blob == want.Blob && c.Mode == want.Mode {
|
||||
return false, nil
|
||||
}
|
||||
abs := filepath.Join(s.Folder, filepath.FromSlash(rel))
|
||||
if fi, err := os.Stat(abs); err == nil {
|
||||
if ok && (fi.Size() != c.Size || fi.ModTime().UnixNano() != c.MTimeNS) {
|
||||
return false, nil // dirty: changed mid-cycle, next scan commits it
|
||||
}
|
||||
if !ok {
|
||||
// Untracked file already at this path: adopt if identical,
|
||||
// otherwise leave it for the next scan to journal.
|
||||
sum, err := hashFile(abs)
|
||||
if err != nil || sum != want.Blob {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if !s.Store.HasBlob(want.Blob) {
|
||||
return false, nil // content not fetched yet; retry next cycle
|
||||
}
|
||||
if err := s.writeFile(abs, want); err != nil {
|
||||
return false, fmt.Errorf("write %s: %w", rel, err)
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
cache[rel] = store.CachedFile{Blob: want.Blob, Size: fi.Size(), Mode: want.Mode, MTimeNS: fi.ModTime().UnixNano()}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pruneOps journals a delete for every path the hub still holds that the
|
||||
// shared rules now exclude, and drops it from target so this cycle does not
|
||||
// write it back. Peers keep their copies: the delete arrives alongside the
|
||||
// rules that explain it, and materialize's filter guard turns it into
|
||||
// "stop tracking" rather than "unlink".
|
||||
//
|
||||
// It reconciles against the replayed remote state, not the local cache. A
|
||||
// path filtered out in some earlier cycle was dropped from the cache back
|
||||
// then and is invisible locally today — which is exactly the leak --prune
|
||||
// exists to clean up.
|
||||
//
|
||||
// The rules are deliberately ignore-only. .bdriveignore syncs, so every
|
||||
// device agrees on it; the include list lives in this device's own
|
||||
// .bdrive/config.json and does not sync. Never reuse the cycle's main filter
|
||||
// here: a device with a narrow --shared scope would delete files a
|
||||
// whole-folder teammate legitimately syncs.
|
||||
func (s *Session) pruneOps(target map[string]journal.FileState, st *store.SyncState, seqBase int64) ([]journal.Op, error) {
|
||||
shared, err := loadFilter(s.Folder, nil) // nil include: shared rules only
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
|
||||
}
|
||||
var paths []string
|
||||
for rel := range target {
|
||||
if shared.Skip(rel) || neverSync(rel) {
|
||||
paths = append(paths, rel)
|
||||
}
|
||||
}
|
||||
sort.Strings(paths) // map order is random; keep the journal reproducible
|
||||
ops := make([]journal.Op, 0, len(paths))
|
||||
for _, rel := range paths {
|
||||
st.Lamport++
|
||||
seqBase++
|
||||
ops = append(ops, journal.Op{
|
||||
Seq: seqBase, Lamport: st.Lamport, Time: time.Now().UTC(),
|
||||
Device: s.Device.ID, DeviceName: s.Device.Name, Author: s.Device.Author,
|
||||
User: s.Account.Email, UserName: s.Account.Name,
|
||||
Kind: journal.KindDelete, Path: rel,
|
||||
Note: "pruned: excluded by " + IgnoreFile,
|
||||
})
|
||||
delete(target, rel)
|
||||
}
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// neverSync reports whether a path is one the scan walk never uploads at all
|
||||
// — the builtin exclusions, which prune treats exactly like ignore rules.
|
||||
func neverSync(rel string) bool {
|
||||
parts := strings.Split(rel, "/")
|
||||
for _, dir := range parts[:len(parts)-1] {
|
||||
if ignoreDirs[dir] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return ignoredFile(parts[len(parts)-1])
|
||||
}
|
||||
|
||||
func (s *Session) writeFile(abs string, want journal.FileState) error {
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
return err
|
||||
|
||||
@@ -17,7 +17,8 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
||||
| 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) |
|
||||
| Show/change which subfolders sync | `bdrive scope` / `bdrive scope add <dirs...>` / `bdrive scope rm <dirs...>` — edits the include list set by `init --shared` (run from the mount root; NEVER hand-edit config.json for this). 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) |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--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 (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 |
|
||||
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
|
||||
| Install this skill on another agent (Codex, Gemini CLI, Hermes, Claude Code) | `bdrive skill install [<folder>]` — writes the binary's own copy of this skill to each detected platform's user-level skills dir (`~/.codex/skills/beardrive/SKILL.md` and friends), idempotently; bare `bdrive skill` shows the status table. Then the user asks that agent to set the folder up and it runs `init` + `hooks install` itself |
|
||||
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally (native reads, grep matches, and files named in shell commands); drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
|
||||
@@ -65,6 +66,9 @@ Selective-sync semantics — important when advising users:
|
||||
- A path syncs when it is **not ignored** and (if `include` is non-empty) **matches an include pattern**. Ignore beats include.
|
||||
- Adding a pattern for an already-synced file makes this device **stop tracking it without deleting it anywhere** — the file stays on disk locally and on every other device. Deleting it locally after that does not propagate either.
|
||||
- Because `.bdriveignore` syncs, adding a rule on one device applies it everywhere on the next cycle.
|
||||
- Stopping tracking is not cleanup: **the hub keeps everything that synced before the rule existed**. `bdrive forget <path>` (or `bdrive sync --prune`) removes it from the hub while leaving every device's disk untouched. Reach for it whenever a user says a file "should not be up there".
|
||||
- Prune reconciles against `.bdriveignore` **only**, never against this device's `include` scope — ignore rules are shared, the scope is per-device, and a narrow scope means "not on my disk", not "not on the hub". To clean up something the scope excludes (or a leak into it), `bdrive forget` it, which writes the exclusion into the shared rules first.
|
||||
- Nothing is destroyed by a prune: it is an ordinary journaled delete, so it shows in `bdrive log` and every past version stays in the hub's history. If a teammate edits the file in the window before they sync, their version wins and the path returns — run `--prune` again once they have synced.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
|
||||
| `bdrive init [folder]` | Create or connect a project and start syncing. Interactive on a TTY; flags (`--name`, `--project`, `--shared`, `--yes`) for scripts. Re-run to resume |
|
||||
| `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 — the include list set by `init --shared`. 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 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 share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--hook <label>` is agent-hook plumbing |
|
||||
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing |
|
||||
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms. Idempotent; `--agent` overrides detection |
|
||||
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms so the agent can do setup itself. Idempotent; `--agent` overrides detection |
|
||||
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads for the hub's read heatmap. Registered by `bdrive hooks install` |
|
||||
@@ -51,6 +52,42 @@ Stamps session context — an agent session id, say — onto changes. It shows u
|
||||
`bdrive log` and hub history, and keeps applying to daemon-committed changes
|
||||
until `--note-ttl` expires.
|
||||
|
||||
### `bdrive forget` and `bdrive sync --prune` — cleaning up the hub
|
||||
|
||||
Adding a rule to `.bdriveignore` only stops *future* uploads. Anything that
|
||||
synced before the rule existed stays on the hub. These two commands are how it
|
||||
comes off:
|
||||
|
||||
```
|
||||
$ bdrive forget .omc
|
||||
added `.omc/` to .bdriveignore
|
||||
synced /Users/you/notes (project "notes")
|
||||
...
|
||||
pruned: 72 path(s) removed from the hub (kept on disk)
|
||||
|
||||
$ bdrive sync --prune # same cleanup for rules you added by hand
|
||||
```
|
||||
|
||||
`forget` writes the rule (a trailing `/` for a directory) and prunes in the
|
||||
same run; it is idempotent, so re-running it just prunes. A path outside the
|
||||
project is an error and writes nothing.
|
||||
|
||||
**No device loses a file.** The removal is journaled as an ordinary delete, and
|
||||
because `.bdriveignore` syncs, every device receives the rule alongside the
|
||||
delete and simply stops tracking the path — the file itself stays on disk here
|
||||
and on every teammate's machine. Nothing is destroyed either: blobs are
|
||||
retained forever, so the removal shows in `bdrive log` and every past version
|
||||
stays in the hub's history.
|
||||
|
||||
Prune reconciles against `.bdriveignore` only, never against this device's own
|
||||
sync scope (`bdrive scope` / `init --shared`). Ignore rules are shared; the
|
||||
scope is per-device, and a narrow scope means "not on my disk", not "not on the
|
||||
hub". To clean up something your scope excludes, `bdrive forget` it — that
|
||||
writes the exclusion into the shared rules first, which is what makes it safe.
|
||||
|
||||
If a teammate edits the file between your prune and their next sync, their
|
||||
version wins and the path comes back. Run `--prune` again once they have synced.
|
||||
|
||||
### `bdrive status` — and the two degraded access states
|
||||
|
||||
Alongside `pending`, `status` prints an `access:` line whenever the hub is
|
||||
|
||||
Reference in New Issue
Block a user