Files
beardrive/internal/syncer/prune_test.go
T
4e34d03e14 feat(hooks): user-scope agent sync hooks, one-command setup, --only scoping (#71)
* feat(hooks): register agent sync hooks per machine, not per project

Agent platforms read hook config only from the directory a session starts
in — never a parent, never a subfolder. Project-level hooks therefore fired
only for sessions that happened to start at the mount, and, living inside a
synced folder, they replicated one machine's agent config to the whole team
(a second writer of a file bdrive already owns). Claude Code additionally
ignores project hooks until the folder is trusted, so in practice they were
often inert without any visible sign.

Hooks now go to each platform's user config, once per machine, covering
every session in every folder; the existing shell guard keeps them a no-op
outside BearDrive projects. Install migrates away blocks older versions
wrote into projects, and `bdrive hooks uninstall` removes ours while leaving
foreign hooks untouched.

Setup is also one command now. init absorbs the skill install, prints the
hub link, and takes --server, so connecting to a named hub no longer needs a
separate login; the runbook forbids preflight and command chaining, since
each distinct command costs the user a permission prompt. For plugin users a
PreToolUse hook auto-approves bdrive's own setup subcommands — narrowly: any
shell operator in the command disqualifies it.

Also drops --shared in favor of `init . --only wiki,docs`, which writes a
managed block of .bdriveignore rules instead of a second scope mechanism.
Because those rules sync, `sync --prune` now refuses on a scoped project
rather than stripping everything outside the scope from the hub for everyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

* docs: fix stale claims an audit found against the new CLI

An audit of every doc surface against the code turned up claims that the
user-scope hook move and the one-command init made false: project-level
hooks "riding the repo", the Claude trust prompt, Codex's //hooks project
layer, `--no-hooks` skipping the skill (it does not), prune reconciling
against a per-device scope (it now refuses on a scoped project), and
`--scan-interval`/`--remote-interval` documented as init flags when they
only exist on `bdrive daemon run`.

Also documents the surface added today — `--server`, `bdrive hooks
uninstall`, and the plugin's PreToolUse auto-approval — refreshes the two
sample `init` transcripts to the real output, and corrects hook matchers
that had drifted from agenthooks.go.

`bdrive scope` told users to narrow an existing mount with `bdrive init .
--only <dirs>`, which resume then ignored — a dead end. Init now applies
--only on resume, writing the scope block, so the advice works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aYntCWwdUhpzUfEk3ddyJ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 10:08:45 +09:00

258 lines
7.8 KiB
Go

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 legacy
// 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")
}
}