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>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-11 04:27:20 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 5f1ac98dae
commit 594a027c15
9 changed files with 758 additions and 4 deletions
+125
View File
@@ -0,0 +1,125 @@
package syncer
import (
"os"
"path/filepath"
"testing"
)
// SyncedFiles is what `bdrive grep` searches, so it has to be exactly the set
// the cycle uploads — and it has to get there without descending a pruned
// directory, which is the whole reason it exists instead of Explain.
func TestSyncedFilesMatchesTheSyncSet(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, IgnoreFile, "node_modules/\n*.log\n")
write(t, a.Folder, "docs/guide.md", "yes")
write(t, a.Folder, "docs/deep/spec.md", "yes")
write(t, a.Folder, "README.md", "yes")
write(t, a.Folder, "debug.log", "no")
write(t, a.Folder, ".DS_Store", "no")
write(t, a.Folder, ".bdrive-tmp-x", "no")
write(t, a.Folder, ".bdrive/config.json", `{}`)
write(t, a.Folder, ".git/HEAD", "no")
write(t, a.Folder, "node_modules/pkg/index.js", "no")
// A nested mount syncs through its own project, not this one.
nested := filepath.Join(a.Folder, "sub")
write(t, a.Folder, "sub/inner.md", "own project")
if err := os.MkdirAll(filepath.Join(nested, ".bdrive"), 0o755); err != nil {
t.Fatal(err)
}
write(t, a.Folder, "sub/.bdrive/config.json", `{"id":"m-other"}`)
got, err := SyncedFiles(a.Folder, nil, "")
if err != nil {
t.Fatal(err)
}
set := map[string]bool{}
for _, p := range got {
set[p] = true
}
for _, want := range []string{"docs/guide.md", "docs/deep/spec.md", "README.md", IgnoreFile} {
if !set[want] {
t.Errorf("%s should sync, got %v", want, got)
}
}
for _, never := range []string{
"debug.log", ".DS_Store", ".bdrive-tmp-x", ".bdrive/config.json",
".git/HEAD", "node_modules/pkg/index.js", "sub/inner.md",
} {
if set[never] {
t.Errorf("%s must not be listed, got %v", never, got)
}
}
// The same answer Explain gives, since both go through walkFolder — if
// these ever disagree, `bdrive grep` and `bdrive scope --explain` are
// telling the operator two different stories about one folder.
synced, _, err := Explain(a.Folder, nil, "")
if err != nil {
t.Fatal(err)
}
if len(synced) != len(got) {
t.Fatalf("SyncedFiles %v != Explain %v", got, synced)
}
for i := range synced {
if synced[i] != got[i] {
t.Fatalf("SyncedFiles %v != Explain %v", got, synced)
}
}
}
// The reason SyncedFiles is not Explain: Explain counts the files inside a
// pruned directory, so it reads every entry of node_modules/. A grep must not.
func TestSyncedFilesDoesNotDescendPrunedDirs(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, IgnoreFile, "heavy/\n")
write(t, a.Folder, "keep.md", "yes")
write(t, a.Folder, "heavy/a.md", "no")
write(t, a.Folder, "heavy/deep/b.md", "no")
got, err := SyncedFiles(a.Folder, nil, "")
if err != nil {
t.Fatal(err)
}
for _, p := range got {
if p != "keep.md" && p != IgnoreFile {
t.Fatalf("walked into a pruned dir: %v", got)
}
}
}
// A rule a TEAMMATE pushed must not widen what this device reports as synced
// until this device accepts it — the SkipUp asymmetry, which is why accepted
// is a parameter at all.
func TestSyncedFilesHonorsAcceptedRules(t *testing.T) {
a := newDevice(t, "deva", nil)
write(t, a.Folder, IgnoreFile, "*.log\n!keep.log\n")
write(t, a.Folder, "keep.log", "negated")
// Local rules only: the negation is this device's own file, so it applies.
got, err := SyncedFiles(a.Folder, nil, "*.log\n!keep.log\n")
if err != nil {
t.Fatal(err)
}
found := false
for _, p := range got {
if p == "keep.log" {
found = true
}
}
if !found {
t.Fatalf("accepted negation should sync keep.log, got %v", got)
}
// The negation is new and unaccepted: the upload door stays shut.
got, err = SyncedFiles(a.Folder, nil, "*.log\n")
if err != nil {
t.Fatal(err)
}
for _, p := range got {
if p == "keep.log" {
t.Fatalf("unaccepted negation must not widen the sync set, got %v", got)
}
}
}
+32
View File
@@ -71,6 +71,38 @@ func walkFolder(folder string, filter *Filter, fn func(abs, rel string, d fs.Dir
})
}
// 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