Documentation

How greppy works

greppy is a native Rust binary with two modes. An invocation that is not one of greppy's commands runs the real system grep. Everything else is a structured command over an index that greppy builds and keeps for each repository.

Ordinary grep

Any invocation that is not a greppy command runs the real grep and returns its output and exit code unchanged. It opens no index, loads no model and touches no greppy cache: in an empty HOME, greppy -rn hello . does not create a single file. ripgrep-style invocations go to a real rg when one is installed. The details are in grep and ripgrep passthrough.

A precomputed code graph

An indexed, typed symbol graph with the edges CALLS, USAGE, TYPE_ASSIGN and IMPORTS answers who-calls, callees, impact and path directly. You get resolved relationships with file:line, not text matches. Structural edges such as DEFINES and CONTAINS_FILE come on top. greppy stats shows what is in the graph.

$ greppy stats
project: serde
files: 339
nodes: 4573
…
  Function 731
…
  Method 950
…
edges: 9427
  BOUND 31
  CALLS 1410
  CONTAINS_FILE 221
  CONTAINS_FOLDER 43
  DEFINES 4814
  IMPLEMENTS 11
  IMPORTS 72
  TYPE_ASSIGN 77
  USAGE 2748

Tree-sitter parsers for more than 60 languages are compiled into the binary; Languages lists what each tier extracts. The graph lives in a local SQLite store outside your repository. greppy does not copy full source text into it: exact code search (search-pattern) reads the current worktree through the real grep.

Semantic search and navigation hints

Every binary contains two models:

Model Job
Google's EmbeddingGemma (300M) search finds definitions by their meaning, that is, code you can describe but not name
Qwen3.5-0.8B (Q4_K_M, MTP), fine-tuned by greppy through distillation writes a short purpose hint under every returned function signature and for every definition brief prints

The weights are on Hugging Face (EmbeddingGemma, greppy-Qwen3.5), pinned by SHA-256 in MODEL_ASSETS.json and fetched before the build. Release packages already contain them. greppy downloads nothing at runtime.

Inference is local Rust with bundled Metal and CUDA kernels: no llama.cpp runtime, no Python, no HTTP, no model server. In production it runs on Metal on Apple Silicon and on CUDA on Linux with an NVIDIA GPU.

Evidence and hints

Source bytes and graph relations are the evidence. The hints are not: in the output, a sentence after an em dash is a generated hint, not source. greppy treats hints as untrusted and leaves them out when inference or validation fails. A failed inference never removes deterministic source or graph output.

brief shows both side by side. visit_content_map has no doc comment, so the first sentence is a generated hint. The signature is literal source, line 1111 names the call site with the resolved symbol, and a hint follows it again.

$ greppy brief visit_content_map
Visits a map of content items and returns the resulting value.

serde/src/private/de.rs:1103-1110
    fn visit_content_map<'de, V, E>(
        content: Vec<(Content<'de>, Content<'de>)>,
        visitor: V,
    ) -> Result<V::Value, E>
    where
        V: Visitor<'de>,
        E: de::Error,
    {
  1111  MapDeserializer::new — constructs a MapDeserializer from a vector of Content values
    }

called by ContentDeserializer::deserialize_any, ContentDeserializer::deserialize_map and ContentDeserializer::deserialize_struct

Shared inference daemons

Indexing, search, hints and bash-smart send model work to user-wide local daemons over a Unix-domain socket. Client processes never load a heavy model themselves as a fallback. Requests are bundled into micro-batches and served round-robin per client. The queue has no capacity limit at which requests get rejected.

A model stays loaded for 300 seconds without a request; a daemon process exits after 1,800 seconds idle. greppy doctor --json shows each daemon's backend, state, socket and queue policy.

$ greppy doctor --json | jq '.inference.daemons | map_values({backend, state, queue_policy})'
{
  "embedding": {
    "backend": "metal",
    "state": "ready",
    "queue_policy": "fair-round-robin-unbounded"
  },
  "summary": {
    "backend": "metal-q4k-forward",
    "state": "ready",
    "queue_policy": "fair-round-robin-unbounded"
  }
}

Since 0.4.1, embeddings and hints also go through a user-wide, content-addressed cache, bound to the exact model, prompt, task profile and input. An unchanged definition is computed once across repositories and worktrees; the daemons see genuine cache misses and nothing else. greppy index reports the split. Here greppy indexes a fresh copy of serde, which had already been embedded on this machine.

$ greppy index .
…
embedded 2443 code spans (0 local-store reused, 2443 global-cache hits, 0 inference misses, 4573 considered, 2150 non-definition skipped, 0 missing-file, 0 invalid-span, 0 oversize, 0 failed, 0 stale pruned)

The index lifecycle

The graph is built once per repository and reused across sessions. A first graph query builds it when needed, and graph and symbol commands answer as soon as it is published. The embeddings for semantic search are built by an explicit greppy index or by the first search that finds them missing.

In 0.4.1 the lifecycle is observable:

  • A first graph query that has to wait for the build relays its phases on stderr, for example writing_graph: 154/219 files; phase ETA about 40s. If indexing cannot start, the query exits with the temporary code 75 and names the condition for the next attempt.
  • An explicit greppy index reports its first phase, its PID and the status command right away.
  • greppy index status --json never blocks during a build. It exits 75 and reports "status": "indexing" and a background_job record with the current phase (state), its unit (progress_unit), the completed and total amount and, once known, a phase ETA.
  • A job whose progress record does not change for two minutes is marked as possibly stalled.

This is what it looks like when a script polls the status every 0.25 s during a greppy index .:

greppy index . > /dev/null 2>&1 &
while sleep 0.25; do
  s=$(greppy index status --json); rc=$?
  echo "$rc $(echo "$s" | jq -c '[.status, .background_job.state, .background_job.completed_spans, .background_job.total_spans]')"
  [ $rc -eq 75 ] || break
done
75 ["indexing","extracting_files",0,219]
75 ["indexing","extracting_files",0,219]
75 ["indexing","writing_graph",0,219]
75 ["indexing","writing_graph",0,219]
75 ["indexing","writing_graph",219,219]
75 ["indexing","building_files",0,339]
75 ["indexing","resolving_file_imports",1,1]
75 ["indexing","counting_embeddings",0,0]
…
75 ["indexing","embedding",0,2443]
75 ["indexing","checking_integrity",0,0]
0 ["ok",null,null,null]

Semantic search never returns partial hits. Automation waits until greppy index status --json exits 0 and reports "healthy": true and "embedding_complete": true.

Freshness without a file watcher

Every query checks the index against the working tree. When indexed spans no longer match the files, greppy fails closed instead of answering from stale source: a query that cannot refresh the index exits with the temporary code 75 and returns no results.

Since 0.4.1, greppy heals small changes directly in the query. In the following serde example, an ordinary editor (not a greppy command) inserts a new function visit_content_map_logged after visit_content_map in serde/src/private/de.rs, and the new function calls visit_content_map. Without greppy index, the next query knows the new caller, and the three known call sites appear on their new lines.

$ greppy who-calls visit_content_map
serde/src/private/de.rs:1152  ContentDeserializer::deserialize_any
serde/src/private/de.rs:1399  ContentDeserializer::deserialize_map
serde/src/private/de.rs:1415  ContentDeserializer::deserialize_struct
$ greppy who-calls visit_content_map
serde/src/private/de.rs:1125  visit_content_map_logged
serde/src/private/de.rs:1163  ContentDeserializer::deserialize_any
serde/src/private/de.rs:1410  ContentDeserializer::deserialize_map
serde/src/private/de.rs:1426  ContentDeserializer::deserialize_struct

A large refresh starts a single observable background job, and the query returns bounded guidance for the next attempt instead of hiding a long rebuild. The embeddings of changed definitions are built by greppy index or by a search that finds them missing. Until then, greppy index status --json reports exit 73 with "embedding_complete": false. In the example above, the next search embedded the new function and returned it as the first hit, and the status then reported 0 again. read refuses a graph that is refreshing with a temporary error instead of combining current bytes with a stale span.

What stays on your machine

The embedded models compute the index, symbol graph, embeddings and hints locally. Code navigation makes no network calls, sends no telemetry and needs no account. Local data, privacy and uninstall describes what is stored and how you remove it.