mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
5f1ac98dae
commit
594a027c15
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user