Files
beardrive/cmd/bdrive/helpers.go
33ca0caeab 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>
2026-08-12 16:10:10 -07:00

223 lines
7.0 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/mattn/go-isatty"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/remote"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
// repoURL is the project's public home — the one place the CLI points at
// GitHub, from `init`'s success block.
const repoURL = "https://github.com/runbear-io/beardrive"
// stdinIsTTY is the one answer to "is this an interactive shell?" — used both
// to decide whether init may prompt and whether login can drive a browser.
func stdinIsTTY() bool {
return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())
}
func absFolder(args []string) (string, error) {
arg := "."
if len(args) > 0 {
arg = args[0]
}
return filepath.Abs(arg)
}
// findMountRoot walks up from folder to the nearest mount root. An agent
// session usually starts wherever the user opened their editor, which may be
// inside a synced subfolder rather than at its root.
func findMountRoot(folder string) (string, bool) {
for cur := folder; ; cur = filepath.Dir(cur) {
if config.IsMount(cur) {
return cur, true
}
if filepath.Dir(cur) == cur {
return "", false
}
}
}
// mountsUnder lists the registered mount roots at or below folder, sorted.
// The registry is the only thing that knows about mounts a walk up can't see.
func mountsUnder(folder string) []string {
mounts, err := config.LoadMounts()
if err != nil {
return nil
}
root := resolvePath(folder) + string(filepath.Separator)
var out []string
for _, mi := range mounts {
if mi.Path != "" && strings.HasPrefix(resolvePath(mi.Path)+string(filepath.Separator), root) {
out = append(out, mi.Path)
}
}
sort.Strings(out)
return out
}
// resolvePath expands symlinks so paths that name the same directory compare
// equal: a mount registered as /private/tmp/x must still be found from a
// session in /tmp/x (macOS symlinks /tmp, /var, and /etc).
func resolvePath(p string) string {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r
}
return p
}
// syncTargets resolves which mount roots a sync at folder covers: the folder
// itself, else the mount enclosing it (session started inside a synced
// subfolder), else every mount below it (session started at a repo root whose
// wiki/ and docs/ are the mounts). Without this, turn hooks fire only when the
// session's directory happens to be exactly the mount root.
func syncTargets(folder string) []string {
if config.IsMount(folder) {
return []string{folder}
}
if root, ok := findMountRoot(folder); ok {
return []string{root}
}
return mountsUnder(folder)
}
// mustProject resolves a folder's project settings (from .bdrive/config.json,
// self-healing the registry when the folder moved).
func mustProject(folder string) (config.Project, error) {
proj, found, err := config.ResolveMount(folder)
if err != nil {
return proj, err
}
if !found {
return proj, fmt.Errorf("%s is not a beardrive project (run `bdrive init` there first)", folder)
}
if proj.Volume == "" {
proj.Volume = filepath.Base(folder)
}
return proj, nil
}
// syncBlocked reports why syncing must not run for a project on this device:
// "init" when the mount was never enrolled here (.bdrive/config.json travels
// with the folder — e.g. arrives in a git clone — so its presence alone is
// not consent to sync; only `bdrive init` enrolls a device), "paused" after
// `bdrive stop`, "" to proceed. Deliberately reads the registry without
// ResolveMount's self-heal, which would enroll as a side effect.
func syncBlocked(proj config.Project) string {
mounts, err := config.LoadMounts()
if err != nil {
return "init"
}
if _, enrolled := mounts[proj.ID]; !enrolled {
return "init"
}
if vdir, err := config.VolumeDir(proj.ID); err == nil && store.Paused(vdir) {
return "paused"
}
return ""
}
// openSession builds a syncer session for a project folder. When withRemote
// is set and the remote is unreachable, it degrades to offline with a warning
// rather than failing.
func openSession(ctx context.Context, folder string, withRemote bool) (*syncer.Session, config.Project, error) {
proj, err := mustProject(folder)
if err != nil {
return nil, proj, err
}
dev, err := config.LoadDevice()
if err != nil {
return nil, proj, err
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
return nil, proj, err
}
st, err := store.Open(vdir)
if err != nil {
return nil, proj, err
}
settings, _ := config.LoadSettings()
sess := &syncer.Session{Folder: folder, MountID: proj.ID, Store: st, Device: dev, Account: settings}
if withRemote && proj.Remote != "" {
be, err := remote.Open(ctx, proj.Remote)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: remote unavailable, working offline: %v\n", err)
} else {
sess.Backend = be
}
}
return sess, proj, nil
}
func closeSession(sess *syncer.Session) {
if sess != nil && sess.Backend != nil {
sess.Backend.Close()
}
}
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for m := n / unit; m >= unit; m /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGTPE"[exp])
}
// printReason prints the hub's stated reason under a refusal line. "read-only"
// is a summary of the STATUS code; the sentence under it is the only thing that
// tells a device-registration 403 apart from a project the user really is a
// reader on — and only one of those is fixed by signing in again.
func printReason(reason string) {
if reason != "" {
fmt.Printf(" %s\n", safeField(reason, 300))
}
}
func printCycle(res *syncer.Result) {
fmt.Printf(" local changes: %d\n", res.LocalOps)
fmt.Printf(" pulled changes: %d\n", res.PulledOps)
if res.Conflicts > 0 {
fmt.Printf(" conflicts: %d (preserved as *.bdrive-conflict-* files)\n", res.Conflicts)
}
if res.Adopted > 0 {
fmt.Printf(" adopted: %d file(s) replaced by the project's version (yours: `bdrive restore --list <path>`)\n", res.Adopted)
}
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:
fmt.Printf(" remote: no access — sync paused (ask a project admin for access)\n")
printReason(res.Reason())
case res.ReadOnly:
fmt.Printf(" remote: read-only (pull only) — local changes stay on this device\n")
printReason(res.Reason())
case res.Pushed && res.OfflineErr != nil:
// Offline is a report, not a gate (see syncer.Result): a content-level
// problem with one object no longer withholds this device's push, so
// "pushed" and a remote warning are both true.
fmt.Printf(" remote: pushed, with a warning: %v\n", res.OfflineErr)
case res.Pushed:
fmt.Printf(" remote: pushed\n")
case res.Offline:
fmt.Printf(" remote: offline (%v)\n", res.OfflineErr)
}
}