fix(sync): connecting a folder adopts the project's files instead of forking them (#159)

A device's first cycle on a volume was treated as a concurrent edit for every
path the project already held. Whichever side's clock happened to sort higher
won -- so a joiner's seeded .bdriveignore or agent-written AGENTS.md could
replace the team's -- and the loser landed beside it as a
.bdrive-conflict-<device>-<time> file.

A first cycle is a join, not an edit. Cycle step 1b holds the scan's ops back
over the pull and demotes any whose path the project already holds to lamport
0, which sorts under every op a project can carry (scan's clock starts at 1).
The project's version then wins deterministically on every device, and
conflictCopies skips those ops the way it already skips re-asserted ones. The
local content is still journaled and pushed, so it stays in History and
`bdrive restore --list <path>` can bring it back. Reported as `adopted: N`.

Concurrent edits after the join are untouched.


Claude-Session: https://claude.ai/code/session_01GLCyEWP2XZhYjBssvdCDQm

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-12 16:10:10 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 7eabb3baf4
commit 33ca0caeab
8 changed files with 339 additions and 30 deletions
+79 -13
View File
@@ -116,8 +116,11 @@ func TestConnectWithIdenticalLocalContent(t *testing.T) {
}
}
// Same story with a stale divergent copy: nothing is silently lost — the
// devices converge on one version and the other survives as a conflict copy.
// Same story with a stale divergent copy. Joining is an adoption, not a merge:
// the project's version wins on every device — even though the joiner's copy is
// the later write — and no conflict copy is made. The joiner's content is not
// lost, it is journaled (and pushed) as a superseded version, which is what
// `bdrive restore` reads.
func TestConnectWithDivergentLocalContent(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
@@ -127,23 +130,86 @@ func TestConnectWithDivergentLocalContent(t *testing.T) {
b := newDevice(t, "devb", be)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, "docs/guide.md", "stale local version")
cycle(t, b)
if res := cycle(t, b); res.Adopted != 1 {
t.Fatalf("Adopted = %d, want 1", res.Adopted)
}
cycle(t, a)
cycle(t, b)
av, bv := read(t, a.Folder, "docs/guide.md"), read(t, b.Folder, "docs/guide.md")
if av != bv {
t.Fatalf("devices diverged: %q vs %q", av, bv)
}
survived := map[string]bool{av: true}
for _, s := range []*Session{a, b} {
for _, p := range conflictFiles(t, s.Folder) {
rel, _ := filepath.Rel(s.Folder, p)
survived[read(t, s.Folder, rel)] = true
if got := read(t, s.Folder, "docs/guide.md"); got != "hub version" {
t.Fatalf("%s guide.md = %q, want the project's version", s.Device.ID, got)
}
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("%s: joining made conflict copies: %v", s.Device.ID, c)
}
}
if !survived["hub version"] || !survived["stale local version"] {
t.Fatalf("a version was silently lost; surviving: %v", survived)
// The superseded content is still in history, on both devices.
for _, s := range []*Session{a, b} {
ops, err := s.Store.AllOps()
if err != nil {
t.Fatal(err)
}
var found bool
for _, op := range ops {
if op.Path == "docs/guide.md" && s.Store.HasBlob(op.Blob) {
if body, err := os.ReadFile(s.Store.BlobPath(op.Blob)); err == nil && string(body) == "stale local version" {
found = true
}
}
}
if !found {
t.Fatalf("%s: the joiner's version is not recoverable from history", s.Device.ID)
}
}
}
// The reported connect experience: a folder that already holds a .bdriveignore
// (`bdrive init` seeds one) and an agent-written AGENTS.md joins a project that
// has its own versions of both. Neither may fork into a
// `.bdriveignore.bdrive-conflict-<device>-<time>` file, and neither may have the
// joiner's copy overwrite the team's. A real concurrent edit AFTER the join
// still conflict-copies — that is a different situation and keeps its old
// behavior.
func TestConnectDoesNotForkIgnoreAndAgentsFiles(t *testing.T) {
be := sharedRemote(t)
a := newDevice(t, "deva", be)
write(t, a.Folder, ".bdriveignore", "node_modules/\n*.log\n")
write(t, a.Folder, "AGENTS.md", "team instructions\n")
cycle(t, a)
b := newDevice(t, "devb", be)
time.Sleep(10 * time.Millisecond)
write(t, b.Folder, ".bdriveignore", "# BearDrive starter\n.DS_Store\n")
write(t, b.Folder, "AGENTS.md", "notes my agent wrote here\n")
res := cycle(t, b)
if res.Adopted != 2 {
t.Fatalf("Adopted = %d, want 2 (.bdriveignore and AGENTS.md)", res.Adopted)
}
if res.Conflicts != 0 {
t.Fatalf("connecting made %d conflict copies, want 0", res.Conflicts)
}
cycle(t, a)
cycle(t, b)
for _, s := range []*Session{a, b} {
if c := conflictFiles(t, s.Folder); len(c) != 0 {
t.Fatalf("%s: connecting littered the folder: %v", s.Device.ID, c)
}
if got := read(t, s.Folder, "AGENTS.md"); got != "team instructions\n" {
t.Fatalf("%s AGENTS.md = %q, want the team's version", s.Device.ID, got)
}
if got := read(t, s.Folder, ".bdriveignore"); got != "node_modules/\n*.log\n" {
t.Fatalf("%s .bdriveignore = %q, want the team's version", s.Device.ID, got)
}
}
// Now a genuine concurrent edit between two devices that share the project.
write(t, a.Folder, "AGENTS.md", "a's edit\n")
write(t, b.Folder, "AGENTS.md", "b's edit\n")
cycle(t, a)
if res := cycle(t, b); res.Conflicts != 1 {
t.Fatalf("a real concurrent edit made %d conflict copies, want 1", res.Conflicts)
}
}
+15 -5
View File
@@ -162,16 +162,26 @@ func TestSec_SyncPeer_HostileDeviceNameCannotBreakTheConflictCopy(t *testing.T)
be := sharedRemote(t)
victim := newDevice(t, "victim", be)
// The victim syncs the project once first. A collision on a device's very
// first cycle is a JOIN, which Cycle step 1b resolves in the project's
// favour with no copy at all; the conflict copy this test is about is the
// ordinary concurrent edit between two devices that already share a project.
const warm = "already shared"
warmOp := secjrnOp(1, "warm.md", secjrnBlob(t, be, warm), len(warm))
secjrnPush(t, be, "attacker", []journal.Op{warmOp})
if _, err := secpeerCycle(t, victim); err != nil {
t.Fatal(err)
}
const theirs = "the peer's version"
blob := secjrnBlob(t, be, theirs)
op := secjrnOp(1, "shared.md", blob, len(theirs))
op.Lamport = 1
op := secjrnOp(2, "shared.md", blob, len(theirs))
op.Lamport = 2
op.Time = time.Now().UTC().Add(-time.Hour) // loses last-writer-wins to the victim
op.DeviceName = strings.Repeat("A", 300) // lands verbatim in a filename
secjrnPush(t, be, "attacker", []journal.Op{op})
secjrnPush(t, be, "attacker", []journal.Op{warmOp, op})
// The victim edits the same file before its first sync: an ordinary
// concurrent edit, and exactly what makes a conflict copy.
// An ordinary concurrent edit, and exactly what makes a conflict copy.
const mine = "the victim's version"
write(t, victim.Folder, "shared.md", mine)
+91 -9
View File
@@ -8,6 +8,12 @@
// state can overwrite the working folder. Concurrent edits resolve
// deterministically last-writer-wins; the losing local version is preserved
// as a "<name>.bdrive-conflict-<device>-<time>" file that syncs like any other.
//
// The exception is a device's FIRST cycle on a volume, which is a join rather
// than an edit: a local file at a path the project already holds is adopted —
// the project's version wins everywhere and no conflict copy is made — while
// the local content is still journaled (below every clock the project can hold)
// so history keeps it. See step 1b in Cycle.
package syncer
import (
@@ -107,9 +113,14 @@ func (s *Session) mountID() string {
// cycle does nothing at all and leaves the working folder alone. Regaining
// access self-heals on a later cycle with no manual step.
type Result struct {
LocalOps int // local changes committed to the journal
PulledOps int // ops received from other devices
Conflicts int // conflict copies created
LocalOps int // local changes committed to the journal
PulledOps int // ops received from other devices
Conflicts int // conflict copies created
// Adopted counts paths where this folder's own content gave way to the
// project's on join (step 1b). Not a conflict and not an error — the
// superseded content stays in history — but the user asked for none of it,
// so it is worth a line.
Adopted int
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
@@ -160,7 +171,7 @@ func accessReason(err error) string {
func (r *Result) Reason() string { return accessReason(r.AccessErr) }
func (r *Result) Activity() bool {
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Pruned > 0 || r.Materialized > 0
return r.LocalOps > 0 || r.PulledOps > 0 || r.Conflicts > 0 || r.Adopted > 0 || r.Pruned > 0 || r.Materialized > 0
}
// The builtin exclusions (.bdrive — the mount's local identity, syncing it
@@ -229,6 +240,9 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
if err != nil {
return nil, err
}
// This device has never contributed to this volume: it is JOINING a project,
// not editing one. Step 1b is what that changes.
joining := st.Lamport == 0 && st.PushedOps == 0
filter, err := loadFilter(s.Folder, proj.Include)
if err != nil {
return nil, fmt.Errorf("load %s: %w", IgnoreFile, err)
@@ -247,8 +261,7 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
// has already synced is an upgrade, not a new joiner: seed the pair from
// what this mount is demonstrably syncing already (vouchedFloor) instead
// of taking the file's word for it.
if st.IgnoreAccepted == "" && st.IgnorePulled == "" && text != "" &&
(st.Lamport > 0 || st.PushedOps > 0) {
if st.IgnoreAccepted == "" && st.IgnorePulled == "" && text != "" && !joining {
synced := make([]string, 0, len(cache))
for rel := range cache {
synced = append(synced, rel)
@@ -265,12 +278,26 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
if err != nil {
return nil, fmt.Errorf("scan: %w", err)
}
if len(localOps) > 0 {
commitLocal := func() error {
if len(localOps) == 0 {
return nil
}
if err := s.Store.AppendOps(s.Device.ID, localOps); err != nil {
return nil, fmt.Errorf("append journal: %w", err)
return fmt.Errorf("append journal: %w", err)
}
myOps = append(myOps, localOps...)
res.LocalOps = len(localOps)
localOps = nil
return nil
}
if !joining {
// The normal path: journal local edits before the pull, so nothing
// remote can overwrite an edit that was never captured (see the package
// doc). A joining device holds its ops back for the length of the pull
// only — long enough to learn which paths the project already has.
if err := commitLocal(); err != nil {
return nil, err
}
}
// 2. Pull journals + blobs from other devices.
@@ -298,6 +325,12 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
// deleted, and the next cycle re-checks.
res.NoAccess, res.AccessErr = true, err
st.Access, st.AccessReason = store.AccessNone, accessReason(err)
// The scan already claimed these files in the state cache, which
// finish is about to persist — journal them or the next scan sees
// nothing changed and this folder's content is never captured.
if cerr := commitLocal(); cerr != nil {
return nil, cerr
}
return res, s.finish(cache, st)
case errors.Is(err, errBlobContent):
// Reported — it is the only signal a device ever gets that its hub
@@ -319,6 +352,43 @@ func (s *Session) Cycle(ctx context.Context) (*Result, error) {
}
}
// 1b. Adoption. A device joining a project it has never synced is not
// editing that project's files: it is bringing a folder that happens to
// hold some of the same paths — a git checkout of the same docs, an
// agent-written AGENTS.md, the .bdriveignore `bdrive init` seeds. Treating
// those as concurrent edits forked every one of them: whichever side's
// clock sorted higher won, and the other landed beside it as a
// `.bdrive-conflict-<device>-<time>` file. So connecting a folder littered
// it with copies of files nobody had edited, and half the time the joiner's
// stale copy is what won — replacing the team's version for everyone.
//
// The project's version wins instead, deterministically: the local op is
// demoted under every op the project can hold (scan's clock starts at 1, so
// lamport 0 loses to all of them on every device). It is still journaled and
// pushed, so nothing is lost — the folder's content at join time is in
// History and `bdrive restore` brings it back — it just never materializes.
if joining && len(localOps) > 0 && len(pulled) > 0 {
theirs := map[string]journal.Op{}
for _, op := range pulled {
if prev, ok := theirs[op.Path]; !ok || journal.Less(prev, op) {
theirs[op.Path] = op
}
}
for i, op := range localOps {
// Only a path the project actually HOLDS is adopted. Where its
// last op is a delete there is no version to adopt, so the local
// file is this device's own and keeps its clock.
if t, ok := theirs[op.Path]; ok && t.Kind == journal.KindPut {
localOps[i].Lamport = 0
localOps[i].Note = adoptNote
res.Adopted++
}
}
}
if err := commitLocal(); err != nil {
return nil, err
}
// 3. Preserve losing local edits as conflict copies.
if len(pulled) > 0 {
conflictOps, err := s.conflictCopies(myOps, st.PushedOps, pulled, &st)
@@ -683,6 +753,13 @@ const maxPeerJournals = 512
// is in this device's journal, and conflictCopies depends on that distinction.
const reassertNote = "re-asserted: the device that published it withdrew it"
// adoptNote marks a local op demoted by the adoption step (1b): content this
// folder already held at a path the project it just joined also holds. Like
// reassertNote it is what tells conflictCopies the op is not a local edit — an
// adopted op is a loser by construction, so preserving it as a conflict copy is
// exactly the litter adoption exists to remove.
const adoptNote = "kept as history: the project's version of this path was adopted on join"
// errBlobContent marks "a blob's bytes are not its content address" — a
// statement about ONE object, never about whether the hub is reachable.
// Conflating the two let one peer integer (an understated Op.Size truncates an
@@ -997,7 +1074,12 @@ func (s *Session) conflictCopies(myOps []journal.Op, pushed int64, pulled []jour
}
unpushed := map[string]journal.Op{}
for _, op := range myOps[pushed:] {
if op.Note == reassertNote {
// adoptNote: the same reasoning one step removed — an adopted op was
// demoted precisely because the project's version wins, so it is a
// losing unpushed local op by construction and would conflict-copy
// every path a joining folder shares with the project. That is the
// litter step 1b exists to remove.
if op.Note == reassertNote || op.Note == adoptNote {
// A re-asserted op is not an edit this device made — it restates a
// peer's op that the peer withdrew, carrying that op's original
// (and therefore usually losing) clock. Round 9 kept it out of the
+134 -1
View File
@@ -39,6 +39,14 @@ type cliEnv struct {
}
func newCLIEnv(t *testing.T) cliEnv {
t.Helper()
return newCLIEnvOn(t, nil)
}
// newCLIEnvOn is newCLIEnv against an existing hub, so a test can put two
// DEVICES (separate BDRIVE_HOMEs, separate device identities) on one project.
// nil starts a throwaway hub, which is the single-device default.
func newCLIEnvOn(t *testing.T, hub *httptest.Server) cliEnv {
t.Helper()
if testing.Short() {
t.Skip("builds and execs the bdrive binary; skipped with -short")
@@ -49,7 +57,9 @@ func newCLIEnv(t *testing.T) cliEnv {
t.Fatalf("go build: %v\n%s", err, out)
}
hub := startTestHub(t)
if hub == nil {
hub = startTestHub(t)
}
// Isolate the CLI completely: fresh BDRIVE_HOME and a fresh HOME, so
// agent-platform detection can't see or touch the real ~/.codex etc.
@@ -282,6 +292,129 @@ func TestCLISiblingProjectMounts(t *testing.T) {
}
}
// Connecting a second device to an existing project, over a real hub, with the
// files that actually collide in practice: the `.bdriveignore` init seeds and an
// AGENTS.md an agent wrote in the folder before it was ever synced. Neither may
// fork into a `.bdriveignore.bdrive-conflict-<device>-<time>` file, and the
// joiner's copy may not replace the team's. The superseded content stays
// reachable through `bdrive restore --list`.
func TestCLIConnectAdoptsProjectVersions(t *testing.T) {
hub := startTestHub(t)
first := newCLIEnvOn(t, hub)
const teamAgents = "# Team instructions\nask before editing docs/\n"
owner := filepath.Join(t.TempDir(), "team")
if err := os.MkdirAll(owner, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(owner, "AGENTS.md"), []byte(teamAgents), 0o644); err != nil {
t.Fatal(err)
}
if out, err := first.run(owner, "init", "--name", "join-e2e", "--yes"); err != nil {
t.Fatalf("init owner: %v\n%s", err, out)
}
defer first.run(owner, "stop", owner)
// The team's ignore rules: the seeded file plus a line of their own, so the
// joiner's freshly seeded copy really does differ.
teamIgnore, err := os.ReadFile(filepath.Join(owner, ".bdriveignore"))
if err != nil {
t.Fatal(err)
}
teamIgnore = append(teamIgnore, []byte("\n# team rules\nscratch/\n")...)
if err := os.WriteFile(filepath.Join(owner, ".bdriveignore"), teamIgnore, 0o644); err != nil {
t.Fatal(err)
}
if out, err := first.run(owner, "sync"); err != nil {
t.Fatalf("owner sync: %v\n%s", err, out)
}
// A second device, its own HOME and device identity, joining the same
// project from a folder that already holds its own AGENTS.md.
second := newCLIEnvOn(t, hub)
joiner := filepath.Join(t.TempDir(), "joined")
if err := os.MkdirAll(joiner, 0o755); err != nil {
t.Fatal(err)
}
const localAgents = "notes my agent wrote in this folder\n"
if err := os.WriteFile(filepath.Join(joiner, "AGENTS.md"), []byte(localAgents), 0o644); err != nil {
t.Fatal(err)
}
id := projectIDByName(t, first.browser, hub.URL, "join-e2e")
out, err := second.run(joiner, "init", "--project", id, "--yes")
if err != nil {
t.Fatalf("init joiner: %v\n%s", err, out)
}
defer second.run(joiner, "stop", joiner)
if !strings.Contains(out, "adopted:") {
t.Fatalf("init did not report adopting the project's versions:\n%s", out)
}
// Let both devices settle, then look at every folder involved.
if out, err := second.run(joiner, "sync"); err != nil {
t.Fatalf("joiner sync: %v\n%s", err, out)
}
if out, err := first.run(owner, "sync"); err != nil {
t.Fatalf("owner sync back: %v\n%s", err, out)
}
for name, folder := range map[string]string{"joiner": joiner, "owner": owner} {
if c := conflictCopiesUnder(t, folder); len(c) != 0 {
t.Errorf("%s folder has conflict copies after a plain connect: %v", name, c)
}
if got := readFile(t, filepath.Join(folder, "AGENTS.md")); got != teamAgents {
t.Errorf("%s AGENTS.md = %q, want the team's version", name, got)
}
if got := readFile(t, filepath.Join(folder, ".bdriveignore")); got != string(teamIgnore) {
t.Errorf("%s .bdriveignore = %q, want the team's version", name, got)
}
}
// Nothing was dropped: the joiner's own version is a superseded version of
// that path, which is exactly what `bdrive restore --list` reads.
versions, err := second.run(joiner, "restore", "AGENTS.md", "--list")
if err != nil {
t.Fatalf("restore --list: %v\n%s", err, versions)
}
var older string // the one version that is not the current content
for _, line := range strings.Split(versions, "\n") {
if m := restoreIDRe.FindStringSubmatch(line); m != nil && !strings.HasPrefix(strings.TrimSpace(line), "*") {
older = m[1]
}
}
if older == "" {
t.Fatalf("the joiner's pre-join AGENTS.md is not in history:\n%s", versions)
}
if out, err := second.run(joiner, "restore", "AGENTS.md", older); err != nil {
t.Fatalf("restore %s: %v\n%s", older, err, out)
}
if got := readFile(t, filepath.Join(joiner, "AGENTS.md")); got != localAgents {
t.Errorf("restored AGENTS.md = %q, want the joiner's pre-join content", got)
}
}
var restoreIDRe = regexp.MustCompile(`\b([0-9a-f]{8})\b`)
func conflictCopiesUnder(t *testing.T, folder string) []string {
t.Helper()
var out []string
filepath.WalkDir(folder, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() && strings.Contains(d.Name(), ".bdrive-conflict-") {
out = append(out, p)
}
return nil
})
return out
}
func readFile(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
return string(b)
}
// One project mounted from two folders on the SAME device. The remote journal
// key is per-device, so a second mount would restart the sequence and
// overwrite the first mount's ops — its files would vanish from the hub. Init