Skip to content

feat(cli): bdrive grep — find the file by what's inside it (BEA-99) - #136

Open
ssowonny wants to merge 1 commit into
mainfrom
bea-99-ph-idea-find-the-file-by-whats-inside-it
Open

feat(cli): bdrive grep — find the file by what's inside it (BEA-99)#136
ssowonny wants to merge 1 commit into
mainfrom
bea-99-ph-idea-find-the-file-by-whats-inside-it

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • You can finally search the text inside your synced files — bdrive grep 'retention.*fold'. Until now typing a phrase that's literally in a file got you "No matches", which three personas hit independently.
  • It searches exactly what the project syncs: an ignore rule or a narrowed bdrive scope hides a file from search the same way it hides it from the hub.
  • Pure local read — no daemon, no lock, no network, and it now creates nothing at all (not even an empty volume dir). Works offline, doesn't block a sync in progress.
  • Known gap, on purpose: this is CLI-only. ⌘K in the browser still searches names, projects and actions. The hub-side content index is deliberately unbuilt — BEA-99 records why.
  • CLI + docs only. No hub handler, no new table, no frontend change.

What shipped, and what didn't

The issue split one want into two builds. Only the left one is in this PR.

flowchart TB
    Q["find the file by what is inside it"]
    CLI["<div style='text-align:left'><b>bdrive grep</b> (CLI)<br/>walks the working folder<br/>reuses the sync predicate<br/>no index, no hub work<br/><b>THIS PR</b></div>"]
    HUB["<div style='text-align:left'><b>content index</b> (hub)<br/>nothing searches content<br/>MetaStore excludes blobs<br/>N GETs + N sha256 per query<br/><b>not this milestone</b></div>"]
    Q --> CLI
    Q --> HUB
    classDef built fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef deferred fill:#88888822,stroke:#888888,stroke-dasharray:4 3
    class CLI built
    class HUB deferred
Loading

The hub half stays gated no, and the issue keeps its cost record: seam fit 1/5, and on any S3/GCS hub verify() re-hashes a blob on every read, so a server-side content search is N GETs plus N full sha256 passes per query — 5k files × 20 KB is ~100 MB egressed and hashed, serialized, for one keystroke. Two landmines are recorded there too (a cross-project search box would be the first handler to re-derive membership itself instead of using proj(); match counts across a PermNone prefix are a content oracle, while snippets from readable files are fine). Don't let a "small" /api/p/<id>/search sneak in later without reading that.

The command

$ bdrive grep 'retention.*fold'
specs/reads.md:1: retention folding happens at boot
wiki/runbook.md:2: the retention fold collapses day buckets
2 files, 2 matching lines

$ bdrive grep -l -i todo
drafts/scratch.md
Flag Meaning Default
<pattern> [folder] Go RE2 regexp; folder resolves like bdrive log's folder = cwd
-i, --ignore-case case-insensitive off
-F, --fixed-strings literal string, not a regexp off
-l, --files-with-matches matching paths only, one per line off
-n, --limit max matching lines printed (0 = all) 200

Exit status is 0 on match and 1 on no match, with nothing printed — grep's convention, so bdrive grep x || echo none composes. Real errors (bad pattern, not a project) still print and still exit non-zero.

-n means "max rows out", the same as bdrive log -n. That collision with GNU grep's --max-count is deliberate consistency within this CLI, not an oversight.

Three things that make it correct rather than just working

It searches the sync set, not the folder. syncer.SyncedFiles is a ~15-line wrapper over the existing walkFolder — the single copy of the sync predicate that scan, Explain and Measure all go through. That's what gets .bdrive/, .bdrive-tmp-*, nested mounts, journal.SafePath and teammate-negation rules right for free; a hand-rolled walk in cmd/bdrive is how three of the acceptance criteria would have quietly failed. It's deliberately not Explain, which already returns this list but also countFiles every pruned directory — a grep in a repo with node_modules/ would walk it in full for a count it discards.

A read stays a read. config.LoadProject, never ResolveMount, because ResolveMount self-heals the registry path — i.e. it enrolls this device — and a search must not have that side effect. Same rule logReads follows. No session, no flock, so it can't block behind a daemon mid-cycle (there's a test that takes the volume flock and asserts grep still answers).

Output is treated as hostile. Every matched line is content a teammate wrote and synced — the same trust level as a peer's journal strings, over a strictly wider surface. Both the path and the matched text go through safeField, so a lone CR can't repaint the row, U+202E can't reverse it, and OSC 52 can't write your clipboard.

Deviations from the reviewed plan

Two, both small, both flagged rather than slipped in:

  1. A Stat guard before opening the volume store. The plan copied scope --explain's best-effort store.Open for IgnoreAccepted. But store.Open MkdirAlls the volume directory, so a search on a never-synced project created one — a write from a read-only command. One line: open it only if it already exists. No store means no accepted rules, which is what "" already says. (scope --explain still has the original behavior; not touched here.)
  2. Skipped the optional Palette DialogTitle retitle. The spec noted the sr-only title still reads "Search and quick actions" while the visible placeholder is accurate, and said to fix it "if you are in the file anyway". Doing it would pull an npm run build and a committed static/ diff into an otherwise CLI-only PR. Left for whoever next touches the frontend.

Verification

  • go test ./... — all packages pass.
  • New: internal/syncer/syncedfiles_test.go (sync set matches Explain; pruned dirs are not descended; unaccepted teammate ! rules don't widen the set) and cmd/bdrive/grep_test.go (output format, every flag, the ignore rule hiding a previously-matching file, .bdrive/ and .bdrive-tmp-* never appearing, binary skip via a NUL in the first 8 KB, ESC/CR/U+202E stripped from both path and text, no registry write outside a project, exit codes, -n limit and -n 0, and no blocking under the volume flock).
  • Smoke-tested with the real binary end to end, including the cat -v check on planted terminal-escape content.
  • No e2e run and no screenshots: nothing in internal/webapp or the frontend changed.

Architecture changes

internal/syncer gains one exported function and cmd/bdrive one command, so architecture/cli-sync.md is the only affected diagram (committed on the branch). overview.md doesn't change — no new package, no new wiring.

SyncedFiles is new: a filter-aware pure read over the same walkFolder predicate as Explain and Measure, with its own fresh Filter; Commands gains grep, which is its only caller.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    walkFolder["<div style='text-align:left'><b>walkFolder</b><br/>+walkFolder(folder, filter, fn)<br/>verdict: vSync vSkipFile vDescend vPruneDir vNested</div>"]
    Measure["<div style='text-align:left'><b>Measure</b><br/>+Measure(folder, include) files, bytes</div>"]
    SyncedFiles["<div style='text-align:left'><b>SyncedFiles</b><br/>+SyncedFiles(folder, include, accepted) paths</div>"]
    Explain["<div style='text-align:left'><b>Explain</b><br/>+Explain(folder, include, accepted) two lists<br/>+NotSyncedFiles(entries) int</div>"]
    Filter["Filter"]
    Commands["<div style='text-align:left'><b>Commands</b><br/>init login logout<br/>sync stop scope <span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ grep</span> forget status log<br/>restore url share export import</div>"]
    Why["Not Explain: Explain countFiles every pruned dir,<br/>so a grep in a repo with node_modules/<br/>would walk it in full for a count it discards"]
    Explain --> walkFolder
    Measure --> walkFolder
    SyncedFiles -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ same predicate</span>" --> walkFolder
    Explain --> Filter
    Measure --> Filter
    SyncedFiles -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ own fresh instance</span>" --> Filter
    walkFolder -- "SkipUp / PruneDir / addNestedMount" --> Filter
    Commands -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ bdrive grep</span>" --> SyncedFiles
    SyncedFiles -.- Why
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class SyncedFiles added
    class Why noteBox
    linkStyle 2 stroke:#22c55e,stroke-width:2px
    linkStyle 5 stroke:#22c55e,stroke-width:2px
    linkStyle 7 stroke:#22c55e,stroke-width:2px
Loading

Docs

README.md and web/docs/.../reference/cli.md document the command, its flags and the output format; the docs page gets a worked example plus a note that this is local, not hub-wide. ROADMAP.md's "Search across the hub" line is reworded, not deleted — content search now works locally, but the hub half is still exploring, and the public file shouldn't imply either that nothing exists or that the hub half shipped.

The open question this ships to answer

Priya and Mira live in the browser; this is a terminal command. If they don't reach for it, the XL hub version was the only version that ever mattered — and that's worth knowing for the price of an M.

Build session

cd $(git worktree list | grep bea-99-ph-idea-find-the-file-by-whats-inside-it | awk '{print $1}') && claude --resume 76f6e895-a30c-4f88-9c7e-374a56611961

(Only works on the machine that ran the build.)

…yncs (BEA-99)

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>
@ssowonny
ssowonny requested a review from thefron August 6, 2026 17:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant