Files
594a027c15 feat(cli): bdrive grep — search the text inside the files a project syncs (BEA-99) (#136)
The ⌘K palette searches file names, projects and actions; nothing in the
product searched file contents. Three personas independently typed a phrase
that lives inside a synced file and got "No matches".

`bdrive grep <pattern> [folder]` searches the working folder — RE2 or -F
literal, -i, -l, -n (default 200, 0 = all), output `path:line: text`, exit 0
on match and 1 on none.

It searches exactly what the project syncs, via a new syncer.SyncedFiles that
wraps the existing walkFolder: the one copy of the sync predicate, so an
ignore rule or a narrowed scope excludes a file from search the same way it
excludes it from sync, and .bdrive/ state can never surface. Not Explain,
which countFiles every pruned dir — a grep in a repo with node_modules/ would
walk it in full for a count it discards.

A read stays a read: LoadProject, not ResolveMount (no registry self-heal, no
device enrollment), no session, no flock, and the volume store is opened for
IgnoreAccepted only when it already exists, so a search creates nothing.
Both the path and the matched line go through safeField — a matched line is a
teammate's file content, the widest version of the surface that function
exists for.

The hub-side content index stays deliberately unbuilt; the issue records its
cost. ROADMAP's "Search across the hub" line is reworded rather than removed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 04:27:20 +09:00

138 lines
4.7 KiB
Go

package syncer
import (
"io/fs"
"path/filepath"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
)
// verdict is what the sync predicate decided about one entry on disk.
type verdict int
const (
vDescend verdict = iota // directory, walk into it
vPruneDir // directory excluded whole (.git, .bdrive, PruneDir)
vNested // directory is a mount of its own (syncs separately)
vSkipFile // file excluded (non-regular, .DS_Store/.bdrive-tmp-*, Skip)
vSync // file syncs
)
// walkFolder walks a mount applying the exact predicate the sync cycle uses.
// fn sees every entry with its verdict; pruning happens here, so no caller can
// descend where scan would not. This is the only copy of the rules: scan and
// Explain both go through it, so what `bdrive scope --explain` reports cannot
// drift from what actually leaves the machine.
func walkFolder(folder string, filter *Filter, fn func(abs, rel string, d fs.DirEntry, v verdict) error) error {
return filepath.WalkDir(folder, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // skip unreadable entries
}
rel, err := filepath.Rel(folder, p)
if err != nil || rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
var v verdict
switch {
case !d.IsDir():
// ReservedPath, not ignoredFile(name): the builtin exclusions
// include a whole-path rule (agent hook config) that a base name
// cannot express, and the outbound half has to match the inbound
// one or a file lands on the hub that no peer will ever materialize.
// SkipUp, not Skip: this walk is the upload door, and a negation a
// TEAMMATE pushed must not widen what leaves this machine. See
// Filter.SkipUp.
if !d.Type().IsRegular() || !journal.SafePath(rel) ||
config.ReservedPath(rel) || filter.SkipUp(rel) {
v = vSkipFile
} else {
v = vSync
}
case ignoredDir(d.Name()) || filter.PruneDir(rel):
v = vPruneDir
case config.IsMount(p):
// A mount of its own: it syncs through its own project.
filter.addNestedMount(rel)
v = vNested
default:
v = vDescend
}
if err := fn(p, rel, d, v); err != nil {
return err
}
if v == vPruneDir || v == vNested {
return fs.SkipDir
}
return nil
})
}
// SyncedFiles lists the mount-relative paths that sync, in walk order. It is
// the same pure read as Explain — no Session, no volume lock, no network — but
// without Explain's not-synced accounting, which calls countFiles on every
// pruned directory: a `bdrive grep` in a repo with node_modules/ would walk
// node_modules/ in full just to produce a count it throws away.
//
// accepted is the ignore text this device has accepted (store.SyncState's
// IgnoreAccepted; "" when there is none), for the reason Explain documents:
// the walk applies Filter.SkipUp, and omitting it would list a file a peer's
// `!` rule points at that the cycle will not actually send.
//
// Unreadable entries are skipped rather than failing, as everywhere else in
// this walk.
func SyncedFiles(folder string, include []string, accepted string) ([]string, error) {
// A fresh filter: addNestedMount mutates it during the walk, so this must
// never be shared with a live cycle.
filter, err := loadFilter(folder, include)
if err != nil {
return nil, err
}
filter.AcceptRules(accepted)
var out []string
err = walkFolder(folder, filter, func(_, rel string, _ fs.DirEntry, v verdict) error {
if v == vSync {
out = append(out, rel)
}
return nil
})
return out, err
}
// Measure reports what a first sync of this folder would actually upload:
// the number of files and their total bytes, after the same filter the cycle
// uses. It exists so `bdrive init` can warn about a folder nobody meant to
// sync (a home directory, a video library, a checkout whose .bdriveignore
// doesn't cover its build output) BEFORE the first push rather than after.
//
// It is deliberately filter-aware: a 40 GB repo whose bulk is node_modules
// measures as the few MB that really sync, so the warning fires on the cases
// that are actually expensive and stays quiet on the ones the starter rules
// already handle.
//
// Unreadable entries are skipped rather than failing — this is advice, and a
// permission error in one subtree must never block init.
func Measure(folder string, include []string) (files int, bytes int64, err error) {
filter, err := LoadFilter(folder, include)
if err != nil {
return 0, 0, err
}
err = walkFolder(folder, filter, func(_, _ string, d fs.DirEntry, v verdict) error {
if v != vSync {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
files++
bytes += info.Size()
return nil
})
return files, bytes, err
}