mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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>
255 lines
7.3 KiB
Go
255 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"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
|
|
// narrowing is ordinary .bdriveignore rules in a bdrive-managed block (see
|
|
// scopefile.go) — there is no separate scope setting to keep in step, and no
|
|
// reason to hand-write the negation syntax. The daemon re-reads the rules
|
|
// every tick, so changes apply within seconds.
|
|
func scopeCmd() *cobra.Command {
|
|
var explain bool
|
|
c := &cobra.Command{
|
|
Use: "scope",
|
|
Short: "Show or change which subfolders sync",
|
|
Long: `Show or change what syncs inside this mount.
|
|
|
|
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
|
|
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,
|
|
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 --explain # list every path: synced vs not synced
|
|
bdrive scope add docs # also sync ./docs
|
|
bdrive scope rm docs # stop syncing ./docs (files stay everywhere)`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
folder, err := absFolder(nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
proj, err := mustProject(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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())
|
|
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 {
|
|
return &cobra.Command{
|
|
Use: "add <dir>...",
|
|
Short: "Add subfolders to what syncs",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
folder, proj, err := scopeTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dirs, scoped, err := readScopeDirs(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !scoped {
|
|
return fmt.Errorf("this project syncs the whole folder already, so %s is included;\n"+
|
|
"to narrow it instead, re-run `bdrive init . --only %s`",
|
|
strings.Join(args, ", "), strings.Join(args, ","))
|
|
}
|
|
add, err := cleanScopeDirs(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, d := range dirs {
|
|
seen[d] = true
|
|
}
|
|
added := 0
|
|
for _, d := range add {
|
|
if seen[d] {
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(folder, filepath.FromSlash(d)), 0o755); err != nil {
|
|
return err
|
|
}
|
|
dirs = append(dirs, d)
|
|
seen[d] = true
|
|
added++
|
|
}
|
|
if added == 0 {
|
|
fmt.Println("already syncing")
|
|
} else if err := writeScopeDirs(folder, dirs); err != nil {
|
|
return err
|
|
}
|
|
return printScope(folder, proj)
|
|
},
|
|
}
|
|
}
|
|
|
|
func scopeRmCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "rm <dir>...",
|
|
Short: "Stop syncing subfolders (deletes nothing)",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
folder, proj, err := scopeTarget()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dirs, scoped, err := readScopeDirs(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !scoped {
|
|
return fmt.Errorf("this project syncs the whole folder; add a plain .bdriveignore rule to exclude %s",
|
|
strings.Join(args, ", "))
|
|
}
|
|
kept, err := scopeRemove(dirs, args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(kept) == 0 {
|
|
return fmt.Errorf("removing the last folder would switch to syncing the whole mount; run `bdrive stop` to stop syncing instead")
|
|
}
|
|
if err := writeScopeDirs(folder, kept); err != nil {
|
|
return err
|
|
}
|
|
fmt.Println("removed from the sync scope — nothing was deleted, locally or on the hub")
|
|
return printScope(folder, proj)
|
|
},
|
|
}
|
|
}
|
|
|
|
// scopeTarget resolves the mount the scope commands act on.
|
|
func scopeTarget() (string, config.Project, error) {
|
|
folder, err := absFolder(nil)
|
|
if err != nil {
|
|
return "", config.Project{}, err
|
|
}
|
|
proj, err := mustProject(folder)
|
|
return folder, proj, err
|
|
}
|
|
|
|
// scopeRemove drops the named dirs from the scoped list, tolerating the
|
|
// slash-decorated forms people type. Unknown entries are an error.
|
|
func scopeRemove(dirs, args []string) ([]string, error) {
|
|
remove := map[string]bool{}
|
|
for _, a := range args {
|
|
want, err := cleanScopeDirs([]string{a})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
found := false
|
|
for _, d := range dirs {
|
|
if d == want[0] {
|
|
remove[d] = true
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("%q is not in the sync scope (see `bdrive scope`)", a)
|
|
}
|
|
}
|
|
var kept []string
|
|
for _, d := range dirs {
|
|
if !remove[d] {
|
|
kept = append(kept, d)
|
|
}
|
|
}
|
|
return kept, nil
|
|
}
|
|
|
|
func printScope(folder string, proj config.Project) error {
|
|
dirs, scoped, err := readScopeDirs(folder)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Mounts created before the scope moved into .bdriveignore still carry an
|
|
// include list in .bdrive/config.json; it is still honored, so report it.
|
|
if len(proj.Include) > 0 {
|
|
fmt.Println("syncing only (legacy include list in .bdrive/config.json):")
|
|
for _, i := range proj.Include {
|
|
fmt.Println(" ./" + strings.Trim(i, "/"))
|
|
}
|
|
fmt.Println("re-run `bdrive init . --only <dirs>` to move these into .bdriveignore")
|
|
return nil
|
|
}
|
|
if !scoped {
|
|
fmt.Println("the whole folder syncs")
|
|
return nil
|
|
}
|
|
fmt.Println("syncing only:")
|
|
for _, d := range dirs {
|
|
fmt.Println(" ./" + d)
|
|
}
|
|
return nil
|
|
}
|