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

126 lines
3.8 KiB
Go

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)
}
}
}