rto_graph/sync.rs
1//! The incremental, content-addressed sync engine.
2//!
3//! `sync` brings a [`Store`] into agreement with the repository's `HEAD` tree.
4//! Extraction is the expensive part and is content-addressed by blob id, so only
5//! blobs whose content changed are re-extracted; the rest load from the
6//! [`ObjectCache`]. If the tree id is unchanged since the last sync, it is a
7//! no-op. The graph itself is reassembled from the (cached) per-blob fact sets
8//! and rebuilt in a single transaction — a deliberately simple DB-write model
9//! for this stage; incremental DB updates can come later.
10
11use std::cell::Cell;
12use std::collections::{BTreeMap, BTreeSet, HashSet};
13
14use crate::cache::{CacheError, ObjectCache, ObjectSweep};
15use crate::extract::Extractor;
16use crate::git::{GitError, Repo};
17use crate::store::StoreError;
18use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Store};
19
20/// Errors raised while syncing.
21#[derive(Debug, thiserror::Error)]
22pub enum SyncError {
23 /// A store operation failed.
24 #[error(transparent)]
25 Store(#[from] StoreError),
26 /// A cache operation failed.
27 #[error(transparent)]
28 Cache(#[from] CacheError),
29 /// A git operation failed.
30 #[error(transparent)]
31 Git(#[from] GitError),
32 /// Reading a working-tree file failed (dirty overlay).
33 #[error("worktree io error: {0}")]
34 Io(#[from] std::io::Error),
35}
36
37/// A summary of the work a [`sync`] performed.
38#[derive(Debug, Clone, Default, serde::Serialize)]
39pub struct SyncReport {
40 /// Hex id of the synced `HEAD` tree.
41 pub tree: String,
42 /// Whether the tree was unchanged and nothing was done.
43 pub no_op: bool,
44 /// Source files reflected in the graph — one `File` node per extracted blob.
45 /// Derived from the assembled graph (not the raw tree walk) so full and
46 /// incremental syncs report the same total for the same tree.
47 pub blobs_total: usize,
48 /// Blobs that were extracted (cache misses).
49 pub blobs_extracted: usize,
50 /// Blobs served from the cache (cache hits).
51 pub blobs_cached: usize,
52 /// Working-tree files whose uncommitted content overrode the committed blob
53 /// (the dirty overlay); always zero for a committed-only [`sync`].
54 pub blobs_dirty: usize,
55 /// Nodes in the store after syncing.
56 pub nodes: u64,
57 /// Edges in the store after syncing.
58 pub edges: u64,
59 /// The working tree this graph previously described, when it was a
60 /// **different** one and the sync therefore rebuilt from scratch rather than
61 /// trusting the recorded state (issue #330).
62 ///
63 /// `None` on every ordinary sync. `Some(path)` is the loud half of the
64 /// guarantee: the answer was corrected rather than served, and the caller can
65 /// say *which* tree the store had been holding, so a stale store is never a
66 /// silent wrong answer nor an unexplained slow one.
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub rebuilt_from_foreign_worktree: Option<String>,
69}
70
71/// A stable identity for the working tree a graph is assembled from: the
72/// working-tree root, or the git dir for a bare repository.
73///
74/// The *path* is used rather than an opaque id because its whole job is to appear
75/// in a message naming the tree the store actually holds — an id the reader
76/// cannot act on would defeat the point. Linked worktrees have distinct roots, so
77/// this separates them; a plain branch switch within one tree does not change it,
78/// which is correct (the tree is the same, its content moved).
79#[must_use]
80pub fn worktree_id(repo: &Repo) -> String {
81 repo.workdir()
82 .unwrap_or_else(|| repo.git_dir())
83 .to_string_lossy()
84 .into_owned()
85}
86
87/// Decide whether `store`'s recorded sync state may be trusted for *this* tree.
88///
89/// Returns `Some(previous)` when the store was last assembled from a **different**
90/// working tree: its tree id, dirty-set hash and extraction env all describe
91/// someone else's tree, so no fast path may consult them and the caller must
92/// rebuild in full. `None` when the store belongs here or has never been stamped
93/// (unknown is adopted, not rebuilt — see [`Store::synced_worktree`]).
94///
95/// Rebuilding is cheap relative to being wrong: the object cache is shared across
96/// worktrees and already warm, so the re-extraction mostly hits it.
97fn foreign_worktree(store: &Store, repo: &Repo) -> Result<Option<String>, SyncError> {
98 let here = worktree_id(repo);
99 Ok(store.synced_worktree()?.filter(|prior| *prior != here))
100}
101
102/// Sync `store` to the repository's `HEAD` tree, extracting changed blobs with
103/// `extractor` and caching results in `cache`.
104///
105/// # Errors
106/// Returns a [`SyncError`] if git access, extraction caching, or the store
107/// rebuild fails.
108pub fn sync(
109 store: &mut Store,
110 repo: &Repo,
111 cache: &ObjectCache,
112 extractor: &dyn Extractor,
113) -> Result<SyncReport, SyncError> {
114 let tree = repo.head_tree_id()?;
115
116 // The extraction *identity*: the extractor code version (`EXTRACT_VERSION`,
117 // bumped when extraction output changes) plus its environment (installed image
118 // models + ingestion toggles). Both change what an unchanged file extracts to,
119 // and both are folded into the content-cache key — so this mirrors that key.
120 // Recorded with the tree so the next sync can tell whether reusing the stored
121 // facts (the incremental path) is sound; a binary upgrade that bumps the
122 // version, or a model change, invalidates it and forces a full re-extraction.
123 let env = extraction_identity(extractor);
124
125 // Nothing to do only when **both** the tree and the extraction identity are
126 // unchanged.
127 //
128 // The identity half is load-bearing, and its absence was a real hole: an
129 // `EXTRACT_VERSION` bump is supposed to guarantee that no user is served the
130 // previous version's facts, but a store already synced at the current `HEAD`
131 // returned `no_op` here before the identity was ever computed — so the new
132 // binary's facts appeared only once `HEAD` next moved. Enabling a feature that
133 // changes extraction output (`audio-metadata`, `pdf-text`, `image-ocr`) on a
134 // quiet repository therefore looked like it had done nothing at all. Every
135 // *other* consumer of the identity — the content-cache key, the incremental
136 // path below — already agreed on it; this one had simply never been asked.
137 //
138 // A store with no recorded identity (`None`) does not match, which is the safe
139 // direction: it re-extracts once and records one.
140 // …and only when the recorded state describes *this* working tree. A store
141 // assembled from another tree has a tree id, dirty hash and env that are all
142 // someone else's, so neither the no-op below nor the incremental diff may
143 // consult them: that is how a stale store reports "up to date" while holding
144 // a graph nobody is looking at (issue #330).
145 let foreign = foreign_worktree(store, repo)?;
146
147 if foreign.is_none()
148 && store.sync_state()?.as_deref() == Some(tree.as_str())
149 && store.sync_env()?.as_deref() == Some(env.as_str())
150 {
151 return Ok(SyncReport {
152 no_op: true,
153 nodes: store.node_count()?,
154 edges: store.edge_count()?,
155 tree,
156 ..SyncReport::default()
157 });
158 }
159
160 // Fast path: if the last sync was a committed one at a known tree with the
161 // same extraction identity, update only the paths that changed. Falls back to
162 // a full re-extraction on any doubt (no prior tree, identity changed, an
163 // unavailable diff, or a tree that is not ours).
164 if foreign.is_none()
165 && let Some(report) = try_incremental(store, repo, cache, extractor, &tree, &env)?
166 {
167 return Ok(report);
168 }
169
170 let committed = extract_committed(repo, cache, extractor)?;
171 let mut assembled = flatten(committed.by_path);
172 resolve_calls(&mut assembled);
173 append_submodule_nodes(
174 repo.submodules(extractor.paths())?,
175 extractor,
176 &mut assembled,
177 );
178 let total = file_count(&assembled);
179 store.reconcile(&assembled, Some(&tree))?;
180 store.set_sync_env(&env)?;
181 store.set_synced_worktree(&worktree_id(repo))?;
182
183 Ok(SyncReport {
184 no_op: false,
185 blobs_total: total,
186 blobs_extracted: committed.extracted,
187 blobs_cached: committed.cached,
188 blobs_dirty: 0,
189 nodes: store.node_count()?,
190 edges: store.edge_count()?,
191 tree,
192 rebuilt_from_foreign_worktree: foreign,
193 })
194}
195
196/// The extraction **identity**: the extractor code version plus its environment
197/// (installed image models, ingestion toggles, and the `[paths]` policy). Two
198/// syncs sharing a tree but not this string do not describe the same graph.
199///
200/// Public so a **read-only** surface can ask the same question the write paths
201/// ask. `rto_spec::tool_check` gates on "is this graph current?" and compared
202/// only the tree; with the `[paths]` policy now inside this string, a graph can
203/// be at the right tree and still have been built under a different declaration.
204///
205/// One function because all three write paths need it and only `sync` used to
206/// have it — which was the hole: `sync_worktree` and `sync_index` keyed their
207/// no-op solely on tree/dirty/index state, so changing `[paths]` with an
208/// unchanged worktree returned "up to date" over a graph still holding
209/// everything the declaration removed. Silent, and exactly the failure the
210/// policy's presence in the cache key exists to prevent one layer down.
211#[must_use]
212pub fn extraction_identity(extractor: &dyn Extractor) -> String {
213 format!(
214 "v{}-e{:016x}",
215 crate::extract::EXTRACT_VERSION,
216 extractor.env_tag()
217 )
218}
219
220/// Attempt an incremental committed sync from the last-synced tree to `head_tree`.
221/// Returns `Ok(Some(report))` when it ran, `Ok(None)` when the fast path is not
222/// eligible (the caller then does a full sync).
223///
224/// It is sound because it produces the exact same **derived-only** graph a full
225/// sync would: it reconstructs the derived subgraph from the store (identified by
226/// the `Derived` provenance tag — unchanged paths' facts are a deterministic
227/// function of their unchanged blob content, so they equal a fresh extraction),
228/// drops the changed/deleted paths, extracts only the changed blobs, re-resolves
229/// cross-file `calls` globally, and feeds the result to the same [`Store::reconcile`]
230/// the full path uses. `check`/`reapply_imports` re-layer the authored/import
231/// facts afterward exactly as before — this only accelerates the derived layer.
232fn try_incremental(
233 store: &mut Store,
234 repo: &Repo,
235 cache: &ObjectCache,
236 extractor: &dyn Extractor,
237 head_tree: &str,
238 env: &str,
239) -> Result<Option<SyncReport>, SyncError> {
240 // Eligibility: a prior committed tree (a plain oid — worktree/index states
241 // carry a `:`-delimited marker), extracted under the same environment.
242 let Some(prior_tree) = store.sync_state()? else {
243 return Ok(None);
244 };
245 if prior_tree.contains(':') || store.sync_env()?.as_deref() != Some(env) {
246 return Ok(None);
247 }
248 // The prior tree object may have been pruned (gc); on any diff failure, fall
249 // back to the full path rather than guessing.
250 let Ok(diff) = repo.diff_trees(&prior_tree, head_tree) else {
251 return Ok(None);
252 };
253
254 // Reconstruct the derived subgraph from the store: every derived node, and
255 // every derived edge except `calls` (globally re-derived below from the full
256 // function set, since a changed file can flip name-resolution elsewhere).
257 let mut nodes: Vec<Node> = store.nodes_by_provenance(Provenance::Derived)?;
258 let mut edges: Vec<Edge> = store
259 .edges_by_provenance(Provenance::Derived)?
260 .into_iter()
261 .filter(|e| e.kind != EdgeKind::Calls)
262 .collect();
263
264 // Drop the changed and deleted paths' derived facts (their nodes, and any edge
265 // incident to them — per-blob derived edges are intra-file, so this is exact).
266 let touched: BTreeSet<&str> = diff
267 .changed
268 .iter()
269 .map(|b| b.path.as_str())
270 .chain(diff.deleted.iter().map(String::as_str))
271 .collect();
272 let dropped: HashSet<String> = nodes
273 .iter()
274 .filter(|n| n.path.as_deref().is_some_and(|p| touched.contains(p)))
275 .map(|n| n.key.clone())
276 .collect();
277 nodes.retain(|n| !dropped.contains(&n.key));
278 edges.retain(|e| !dropped.contains(&e.src) && !dropped.contains(&e.dst));
279
280 // Extract the changed blobs (cache-aware) and add their derived facts.
281 let env_tag = extractor.env_tag();
282 let mut extracted = 0usize;
283 let mut cached = 0usize;
284 for blob in &diff.changed {
285 // The same exclusion the full path applies in `extract_blobs`. Asked
286 // here as well because this loop reaches `Extractor::extract` by its own
287 // route: a rule consulted on one of two paths into extraction is the
288 // defect this mechanism exists to prevent, one scope smaller. `touched`
289 // above is deliberately *not* filtered — leaving an excluded path in it
290 // drops any stale nodes it still has, and nothing re-adds them.
291 if !extractor.reads(&blob.path) {
292 continue;
293 }
294 let key = cache_key(&blob.path, &blob.oid, env_tag);
295 let facts = if let Some(facts) = cache.get(&key)? {
296 cached += 1;
297 facts
298 } else {
299 let bytes = repo.read_blob(&blob.oid)?;
300 let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
301 cache.put(&key, &facts)?;
302 extracted += 1;
303 facts
304 };
305 nodes.extend(facts.nodes);
306 edges.extend(facts.edges);
307 }
308
309 // Prune orphaned import-target nodes — a path-less derived node (e.g.
310 // `import:rust:foo`) that no surviving edge references. A full sync emits it
311 // only while some file imports it, so dropping the now-unreferenced ones keeps
312 // the two paths identical.
313 let referenced: HashSet<&str> = edges
314 .iter()
315 .flat_map(|e| [e.src.as_str(), e.dst.as_str()])
316 .collect();
317 nodes.retain(|n| n.path.is_some() || referenced.contains(n.key.as_str()));
318
319 // Global call resolution over the full (reconstructed + changed) function set,
320 // then reconcile to derived-only — identical to what the full path produces.
321 let mut assembled = FactSet { nodes, edges };
322 resolve_calls(&mut assembled);
323 append_submodule_nodes(
324 repo.submodules(extractor.paths())?,
325 extractor,
326 &mut assembled,
327 );
328 let total = file_count(&assembled);
329 store.reconcile(&assembled, Some(head_tree))?;
330 store.set_sync_env(env)?;
331 // The caller only reaches here for a store that is ours, but it may predate
332 // the stamp — record it, or this path would leave it unstamped forever.
333 store.set_synced_worktree(&worktree_id(repo))?;
334
335 Ok(Some(SyncReport {
336 no_op: false,
337 blobs_total: total,
338 blobs_extracted: extracted,
339 blobs_cached: cached,
340 blobs_dirty: 0,
341 nodes: store.node_count()?,
342 edges: store.edge_count()?,
343 tree: head_tree.to_owned(),
344 // Unreachable with a foreign store: the caller skips this path entirely.
345 rebuilt_from_foreign_worktree: None,
346 }))
347}
348
349/// Sync `store` to the working tree: the committed `HEAD` state with uncommitted
350/// working-tree changes overlaid on top (a pre-commit preview).
351///
352/// Committed blobs come from the content-addressed cache as in [`sync`]; then
353/// each tracked file whose working copy differs from its committed blob is
354/// re-extracted in memory (never cached, since dirty content is not a git
355/// object), deleted files are dropped, and brand-new **untracked** files (found
356/// via a gitignore-aware dirwalk, [`Repo::untracked_files`]) are overlaid in.
357/// The recorded sync state encodes the dirty set, so a later committed [`sync`]
358/// correctly supersedes the overlay.
359///
360/// # Errors
361/// Returns a [`SyncError`] if git access, extraction caching, working-tree I/O,
362/// or the store rebuild fails.
363pub fn sync_worktree(
364 store: &mut Store,
365 repo: &Repo,
366 cache: &ObjectCache,
367 extractor: &dyn Extractor,
368) -> Result<SyncReport, SyncError> {
369 let tree = repo.head_tree_id()?;
370 let committed = extract_committed(repo, cache, extractor)?;
371 let mut by_path = committed.by_path;
372
373 // Overlay uncommitted edits to tracked files. A file is dirty when its
374 // working-copy content hashes to a different git blob id than the committed
375 // one; identical content hashes identically, so clean files are skipped.
376 let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
377 if let Some(workdir) = repo.workdir() {
378 for blob in &committed.blobs {
379 match std::fs::read(workdir.join(&blob.path)) {
380 Ok(bytes) => {
381 let woid = repo.blob_oid(&bytes)?;
382 if woid != blob.oid {
383 by_path.insert(
384 blob.path.clone(),
385 extractor.extract(&blob.path, &woid, &bytes),
386 );
387 dirty.insert((blob.path.clone(), woid));
388 }
389 }
390 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
391 by_path.remove(&blob.path);
392 dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
393 }
394 Err(e) => return Err(e.into()),
395 }
396 }
397
398 // Overlay brand-new files: those `HEAD` does not have and git would
399 // nonetheless carry — untracked-but-not-ignored, plus anything staged.
400 //
401 // Two sources, and it needs both (#636). `untracked_files` classifies the
402 // working tree **against the index**, so it stops reporting a file the
403 // moment it is `git add`-ed; the committed blob list above comes from the
404 // `HEAD` tree, where a new file does not exist either. A **staged
405 // addition** is therefore in neither, and used to fall straight through
406 // this overlay — so `git add`, an action that moves a file *closer* to
407 // committed, deleted its node from the graph and dropped the
408 // `+N uncommitted` marker at the exact moment the tree differed most from
409 // `HEAD`. Adding the index entries that `HEAD` lacks closes the gap.
410 //
411 // Content still comes from **disk**, not from the staged blob: this is the
412 // worktree source, and a file edited after being staged must be read as it
413 // now stands.
414 //
415 // `.gitignore` is still honoured, and the union states *how*: an ignored
416 // file is absent from the dirwalk, so it enters only by being in the
417 // index — which takes a deliberate `git add -f`. That is the right
418 // outcome rather than a leak, because force-adding overrides the ignore
419 // and the file will be committed; the graph would see it a moment later
420 // anyway.
421 //
422 // They count as dirty (so the preview re-runs when they change) and add to
423 // the blob total (they are genuinely new blobs, not edits of existing ones).
424 let head_paths: BTreeSet<&str> = committed.blobs.iter().map(|b| b.path.as_str()).collect();
425 for path in repo.added_since_head(&head_paths)? {
426 // A new file under an excluded path is excluded too — otherwise
427 // dropping a corpus into `raw/` would put it in the graph until the
428 // moment it was committed, which is the inverse of what the
429 // declaration says.
430 if !extractor.reads(&path) {
431 continue;
432 }
433 match std::fs::read(workdir.join(&path)) {
434 Ok(bytes) => {
435 let woid = repo.blob_oid(&bytes)?;
436 by_path.insert(path.clone(), extractor.extract(&path, &woid, &bytes));
437 dirty.insert((path, woid));
438 }
439 // Raced away between the walk and the read — nothing to add.
440 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
441 Err(e) => return Err(e.into()),
442 }
443 }
444 }
445
446 // The blob total is the file count of the *overlaid* graph — committed files,
447 // minus working-tree deletions, plus untracked additions — not the committed
448 // baseline, so it stays consistent whether files were added or removed.
449 let total = by_path.len();
450
451 // Encode the dirty set into the sync state so repeated identical previews
452 // no-op, but any committed change (which alters the plain tree id) does not.
453 let state = if dirty.is_empty() {
454 tree.clone()
455 } else {
456 let mut buf = String::new();
457 for (path, marker) in &dirty {
458 buf.push_str(path);
459 buf.push('\0');
460 buf.push_str(marker);
461 buf.push('\n');
462 }
463 format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
464 };
465 let dirty_count = dirty.len();
466
467 // A dirty-set hash computed for another tree says nothing about this one, so
468 // a foreign store may never no-op here (issue #330).
469 let foreign = foreign_worktree(store, repo)?;
470 // Both halves, as `sync` has always compared them. The state alone is not the
471 // identity: `[paths]` can change while the tree and the dirty set do not.
472 let env = extraction_identity(extractor);
473 if foreign.is_none()
474 && store.sync_state()?.as_deref() == Some(state.as_str())
475 && store.sync_env()?.as_deref() == Some(env.as_str())
476 {
477 return Ok(SyncReport {
478 no_op: true,
479 blobs_total: total,
480 blobs_dirty: dirty_count,
481 nodes: store.node_count()?,
482 edges: store.edge_count()?,
483 tree,
484 ..SyncReport::default()
485 });
486 }
487
488 let mut assembled = flatten(by_path);
489 resolve_calls(&mut assembled);
490 append_submodule_nodes(
491 repo.submodules(extractor.paths())?,
492 extractor,
493 &mut assembled,
494 );
495 store.reconcile(&assembled, Some(&state))?;
496 store.set_sync_env(&env)?;
497 store.set_synced_worktree(&worktree_id(repo))?;
498
499 Ok(SyncReport {
500 no_op: false,
501 blobs_total: total,
502 blobs_extracted: committed.extracted,
503 blobs_cached: committed.cached,
504 blobs_dirty: dirty_count,
505 nodes: store.node_count()?,
506 edges: store.edge_count()?,
507 tree,
508 rebuilt_from_foreign_worktree: foreign,
509 })
510}
511
512/// Sync `store` to the **git index** — the staged tree that a commit would
513/// record. Unlike [`sync_worktree`] (files on disk) this reads each staged blob
514/// by its index object id, so it validates *exactly what is about to be
515/// committed* (partially-staged changes and all). New staged files are included;
516/// unstaged working-tree edits are not. Backs the index-aware pre-commit gate.
517///
518/// # Errors
519/// Returns a [`SyncError`] if git access, extraction caching, or the store
520/// reconcile fails.
521pub fn sync_index(
522 store: &mut Store,
523 repo: &Repo,
524 cache: &ObjectCache,
525 extractor: &dyn Extractor,
526) -> Result<SyncReport, SyncError> {
527 let staged = repo.index_files()?;
528 // A stable state id over the staged (path, oid) set, in its own `index:`
529 // namespace so it never collides with a committed tree id or a worktree dirty
530 // marker — repeated identical index syncs then no-op, while any staged change
531 // does not.
532 let mut buf = String::new();
533 for blob in &staged {
534 buf.push_str(&blob.path);
535 buf.push('\0');
536 buf.push_str(&blob.oid);
537 buf.push('\n');
538 }
539 let state = format!("index:{:016x}", fnv1a64(buf.as_bytes()));
540
541 // An index hash from another tree describes another index (issue #330).
542 let foreign = foreign_worktree(store, repo)?;
543 let env = extraction_identity(extractor);
544 if foreign.is_none()
545 && store.sync_state()?.as_deref() == Some(state.as_str())
546 && store.sync_env()?.as_deref() == Some(env.as_str())
547 {
548 return Ok(SyncReport {
549 no_op: true,
550 // The staged set is what a commit would record; the graph holds only
551 // the admitted part of it, and `blobs_total` elsewhere is the file
552 // count of the assembled graph. Counted the same way here so a no-op
553 // does not claim excluded files are in the graph.
554 blobs_total: store.file_node_count()?,
555 nodes: store.node_count()?,
556 edges: store.edge_count()?,
557 tree: state,
558 ..SyncReport::default()
559 });
560 }
561
562 let extracted = extract_blobs(repo, cache, extractor, staged)?;
563 let total = extracted.by_path.len();
564 let mut assembled = flatten(extracted.by_path);
565 resolve_calls(&mut assembled);
566 // Index mode is "exactly what a commit would record", so submodule pins come
567 // from the *staged* gitlinks, not `HEAD` — a staged bump is reflected.
568 append_submodule_nodes(
569 repo.index_submodules(extractor.paths())?,
570 extractor,
571 &mut assembled,
572 );
573 store.reconcile(&assembled, Some(&state))?;
574 store.set_sync_env(&env)?;
575 store.set_synced_worktree(&worktree_id(repo))?;
576
577 Ok(SyncReport {
578 no_op: false,
579 blobs_total: total,
580 blobs_extracted: extracted.extracted,
581 blobs_cached: extracted.cached,
582 blobs_dirty: 0,
583 nodes: store.node_count()?,
584 edges: store.edge_count()?,
585 tree: state,
586 rebuilt_from_foreign_worktree: foreign,
587 })
588}
589
590/// Extract a repo's **derived graph at an arbitrary commit/tree `rev`** into
591/// `store`, replacing its contents — the same content-addressed extraction as
592/// [`sync`], but for a historical point rather than `HEAD`. Because extraction is
593/// keyed by `(path, blob oid, env)`, every blob unchanged versus another synced
594/// point is a cache hit, so resolving an older version only re-does what differs.
595///
596/// This backs **version-pin resolution** (ADR-0009 step 8): to resolve a spoke's
597/// cross-repo reference against the hub *version it deploys* (a submodule sha,
598/// an image tag → commit), extract the hub at that `rev` into an ephemeral store
599/// and resolve there. It populates the derived layer only (config keys, symbols,
600/// calls); authored/import layers are not re-applied, since this is a read-only
601/// resolution snapshot. No sync-state is recorded (`tree` carries `rev` for the
602/// report only).
603///
604/// # Errors
605/// Returns [`SyncError`] on git access, extraction caching, or store failure.
606pub fn sync_tree(
607 store: &mut Store,
608 repo: &Repo,
609 cache: &ObjectCache,
610 extractor: &dyn Extractor,
611 rev: &str,
612) -> Result<SyncReport, SyncError> {
613 let extracted = extract_blobs(repo, cache, extractor, repo.blobs_at(rev)?)?;
614 let mut assembled = flatten(extracted.by_path);
615 resolve_calls(&mut assembled);
616 append_submodule_nodes(
617 repo.submodules_at(rev, extractor.paths())?,
618 extractor,
619 &mut assembled,
620 );
621 let total = file_count(&assembled);
622 store.rebuild(&assembled, None)?;
623 Ok(SyncReport {
624 no_op: false,
625 blobs_total: total,
626 blobs_extracted: extracted.extracted,
627 blobs_cached: extracted.cached,
628 blobs_dirty: 0,
629 nodes: store.node_count()?,
630 edges: store.edge_count()?,
631 tree: rev.to_owned(),
632 // A historical-rev store deliberately records no synced state at all
633 // (`rebuild(.., None)` clears the row), so it is stamped with no tree
634 // either — it is a scratch view of a commit, not of a working tree.
635 rebuilt_from_foreign_worktree: None,
636 })
637}
638
639/// The committed fact sets for the `HEAD` tree, one per path, plus the blob list
640/// (for overlay comparison) and cache-hit/miss counts.
641struct Committed {
642 blobs: Vec<crate::BlobRef>,
643 by_path: BTreeMap<String, FactSet>,
644 extracted: usize,
645 cached: usize,
646}
647
648/// Extract (or load from cache) the fact set for every blob in the `HEAD` tree.
649fn extract_committed(
650 repo: &Repo,
651 cache: &ObjectCache,
652 extractor: &dyn Extractor,
653) -> Result<Committed, SyncError> {
654 extract_blobs(repo, cache, extractor, repo.walk_blobs()?)
655}
656
657/// Extract (or load from cache) the fact set for each blob in `blobs` — the
658/// shared core of [`extract_committed`] and [`sync_index`], differing only in
659/// which tree the blob list comes from (`HEAD` vs the git index).
660fn extract_blobs(
661 repo: &Repo,
662 cache: &ObjectCache,
663 extractor: &dyn Extractor,
664 blobs: Vec<crate::BlobRef>,
665) -> Result<Committed, SyncError> {
666 // A path the repository has excluded (ADR-0007 `[paths] exclude`) is dropped
667 // here, before its bytes are read — so an excluded corpus costs nothing, and
668 // so `Committed::blobs` (which `sync_worktree` overlays onto and counts from)
669 // never carries one either. `Extractor::extract` would return an empty fact
670 // set for it regardless; this is the same answer given earlier and cheaper.
671 let blobs: Vec<crate::BlobRef> = blobs
672 .into_iter()
673 .filter(|b| extractor.reads(&b.path))
674 .collect();
675 let mut by_path = BTreeMap::new();
676 let mut extracted = 0usize;
677 let mut cached = 0usize;
678
679 // Extraction output depends on runtime state beyond (path, bytes): which
680 // image models are installed, and the extractor's ingestion toggles. The
681 // extractor folds both into a single tag for the cache key. Computed once
682 // per sync.
683 let env = extractor.env_tag();
684
685 for blob in &blobs {
686 // Extraction is a function of (path, blob bytes) and — with `image-ocr`
687 // — the OCR model environment (`env`), never blob id alone: node keys are
688 // path-scoped (e.g. `file:<path>`), so the same blob content at two
689 // different paths yields different facts. Key the cache by (path, oid,
690 // env) so duplicate-content files (e.g. empty files, which git dedupes to
691 // one oid) never collide, the same path+oid in another branch/worktree
692 // still hits, and installing/upgrading OCR models re-extracts images.
693 let key = cache_key(&blob.path, &blob.oid, env);
694 let facts = if let Some(facts) = cache.get(&key)? {
695 cached += 1;
696 facts
697 } else {
698 let bytes = repo.read_blob(&blob.oid)?;
699 let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
700 cache.put(&key, &facts)?;
701 extracted += 1;
702 facts
703 };
704 by_path.insert(blob.path.clone(), facts);
705 }
706
707 Ok(Committed {
708 blobs,
709 by_path,
710 extracted,
711 cached,
712 })
713}
714
715/// Concatenate per-path fact sets into one assembled fact set.
716fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
717 let mut assembled = FactSet::new();
718 for facts in by_path.into_values() {
719 assembled.nodes.extend(facts.nodes);
720 assembled.edges.extend(facts.edges);
721 }
722 assembled
723}
724
725/// The `NodeKind::Other` token for a submodule-pin node (`submodule:<path>`).
726pub(crate) const SUBMODULE_KIND: &str = "submodule";
727
728/// Append the given submodule-pin nodes to `assembled`, replacing any already
729/// present. `subs` is the caller's source-appropriate list — `repo.submodules()`
730/// (the `HEAD` tree) for committed/worktree syncs, `repo.index_submodules()` (the
731/// staged gitlinks) for the index-aware pre-commit gate. A submodule pin is a
732/// **tree-level** derived fact (a gitlink + its `.gitmodules` URL, ADR-0009), not
733/// a per-blob one, so it is recomputed on every sync rather than cached. Removing
734/// any existing submodule nodes first makes the
735/// incremental path — which reconstructs derived nodes from the store — produce
736/// exactly the full sync's result: an unchanged pin re-adds identically, a bumped
737/// pin's new sha wins, and a removed submodule leaves none behind. The nodes carry
738/// `path = .gitmodules` (so a `.gitmodules` deletion drops them) and stand alone
739/// (no edges — nothing in the graph is their guaranteed endpoint).
740fn append_submodule_nodes(
741 subs: Vec<crate::Submodule>,
742 extractor: &dyn Extractor,
743 assembled: &mut FactSet,
744) {
745 let kind = NodeKind::Other(SUBMODULE_KIND.to_owned());
746 assembled.nodes.retain(|n| n.kind != kind);
747 // **The source path, not the subject.** Every node below is attributed to
748 // `.gitmodules` (`node.path`), so a repository that declared *that* file out
749 // gets no submodule nodes at all — an excluded path contributes no node, and
750 // this is the only reader where the file supplying the facts is not the file
751 // they are about. The per-submodule check further down asks the other
752 // question, about `vendor/dep` itself; both declarations are real and they
753 // mean different things.
754 if !extractor.reads(crate::git::GITMODULES) {
755 return;
756 }
757 for sm in subs {
758 // The **sixteenth** reader, and the one that hides: these nodes are
759 // assembled from `.gitmodules` after `flatten`, so they never pass
760 // through `Extractor::extract` and the filter in `extract_blobs` cannot
761 // see them. A repository that excludes `vendor/**` would otherwise still
762 // get a `submodule:vendor/thing` node naming the path it declared out.
763 // Keyed on the submodule's own path, which is what a declaration names.
764 if !extractor.mines(&sm.path) {
765 continue;
766 }
767 let key = format!("submodule:{}", sm.path);
768 let mut node = Node::new(key, kind.clone(), sm.path.clone());
769 node.path = Some(".gitmodules".to_owned());
770 node.provenance = Provenance::Derived;
771 node.meta = serde_json::json!({ "path": sm.path, "url": sm.url, "sha": sm.sha });
772 assembled.nodes.push(node);
773 }
774}
775
776/// The number of source files reflected in an assembled fact set (one `File`
777/// node per extracted blob). Both the full and incremental sync paths derive
778/// `SyncReport::blobs_total` from the *assembled graph* this way — not from the
779/// raw blob list — so the two paths report the same total for the same tree (the
780/// graphs are identical; see the equivalence test).
781fn file_count(facts: &FactSet) -> usize {
782 facts
783 .nodes
784 .iter()
785 .filter(|n| n.kind == NodeKind::File)
786 .count()
787}
788
789/// Resolve the per-function call records (`meta.calls`) accumulated during
790/// extraction into `calls` edges, now that every file's symbols are present.
791///
792/// Resolution is deliberately conservative — it links a call only when the target
793/// is **unambiguous** — but scope-aware: a callee descriptor may carry the
794/// immediate qualifier the call site provided (`b::foo`, `Type::assoc`,
795/// `Self::method`; see `crate::extract`). A call resolves when either
796///
797/// 1. its simple name is unique across the whole tree (the base case), or
798/// 2. its name is ambiguous but a qualifier picks out **exactly one** matching
799/// function — the one whose immediate scope segment equals that qualifier
800/// (with `Self` bound to the caller's own impl type).
801///
802/// This never links a name it could not before (it is a strict superset), and it
803/// still refuses to guess when a qualifier leaves more than one candidate. Runs at
804/// assembly time — not per blob — since a single blob cannot see other files.
805fn resolve_calls(facts: &mut FactSet) {
806 // Simple function name → the keys of functions with that name.
807 let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
808 for n in &facts.nodes {
809 if n.kind == NodeKind::Fn {
810 by_name
811 .entry(n.name.as_str())
812 .or_default()
813 .push(n.key.as_str());
814 }
815 }
816
817 // Collect (caller, callee) pairs; BTreeSet dedupes and orders them.
818 let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
819 for n in &facts.nodes {
820 if n.kind != NodeKind::Fn {
821 continue;
822 }
823 let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
824 continue;
825 };
826 // The caller's own type (for binding `Self::` calls) is the scope segment
827 // immediately before its name in its key, if it is a method.
828 let caller_self = self_type_of(&n.key);
829 for descriptor in calls.iter().filter_map(|v| v.as_str()) {
830 let (qualifier, name) = split_callee(descriptor);
831 let Some(candidates) = by_name.get(name) else {
832 continue;
833 };
834 let target = if candidates.len() == 1 {
835 // Unambiguous by simple name — the base case (unchanged behaviour).
836 Some(candidates[0])
837 } else if let Some(q) = qualifier {
838 // Ambiguous name; try the qualifier. `Self` binds to the caller's
839 // impl type — a free function has none, so such a call stays open.
840 let want = if q == "Self" { caller_self } else { Some(q) };
841 want.and_then(|want| unique_in_scope(candidates, want, name))
842 } else {
843 None
844 };
845 if let Some(dst) = target {
846 resolved.insert((n.key.clone(), dst.to_owned()));
847 }
848 }
849 }
850
851 for (src, dst) in resolved {
852 facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
853 }
854}
855
856/// The qualified suffix of a symbol key (`sym:<lang>:<path>#<qualified>` →
857/// `<qualified>`), i.e. the scope-segment path within its file.
858fn qualified_suffix(key: &str) -> &str {
859 key.rsplit_once('#').map_or(key, |(_, q)| q)
860}
861
862/// The caller's own type for binding a `Self::` call: the scope segment
863/// immediately before the function's name in its key (`Type::method` → `Type`),
864/// or `None` for a free function (no enclosing type).
865fn self_type_of(key: &str) -> Option<&str> {
866 let mut segs = qualified_suffix(key).rsplit("::");
867 segs.next()?; // the function's own name
868 segs.next() // the enclosing scope segment, if any
869}
870
871/// The single candidate whose immediate scope segment is `want` (so its key ends
872/// with the `want::name` segment pair), or `None` when zero or several match —
873/// segment-aware so `T::m` matches `a::T::m` but never `XT::m`.
874fn unique_in_scope<'a>(candidates: &[&'a str], want: &str, name: &str) -> Option<&'a str> {
875 let mut hit = None;
876 for &key in candidates {
877 let mut segs = qualified_suffix(key).rsplit("::");
878 if segs.next() == Some(name) && segs.next() == Some(want) {
879 if hit.is_some() {
880 return None; // more than one match at this scope — refuse to guess
881 }
882 hit = Some(key);
883 }
884 }
885 hit
886}
887
888/// Split a `meta.calls` descriptor into its immediate qualifier and simple name:
889/// `b::foo` → `(Some("b"), "foo")`, `foo` → `(None, "foo")`.
890fn split_callee(descriptor: &str) -> (Option<&str>, &str) {
891 match descriptor.rsplit_once("::") {
892 Some((qualifier, name)) => (Some(qualifier), name),
893 None => (None, descriptor),
894 }
895}
896
897/// Content-addressed cache key for a blob at a given path: the blob oid (kept
898/// as the leading, well-distributed shard) suffixed with a stable 64-bit hash of
899/// the path, the `crate::extract::EXTRACT_VERSION`, and the extractor
900/// environment tag `env` (the installed media-model — OCR + vision + audio —
901/// identity; `0` when no media model is active — see
902/// `crate::extract::media_env_tag`). Sharing across branches/worktrees is
903/// preserved (same path+oid+version+env → same key) while duplicate content at
904/// distinct paths stays distinct; bumping the extractor version *or* changing the
905/// installed media models retires old entries so a re-extraction is forced.
906fn cache_key(path: &str, oid: &str, env: u64) -> String {
907 format!(
908 "{oid}-{:016x}-v{}-e{env:016x}",
909 fnv1a64(path.as_bytes()),
910 crate::extract::EXTRACT_VERSION,
911 )
912}
913
914/// How many superseded extractor generations [`sweep_superseded`] keeps behind
915/// the current one by default: **one**.
916///
917/// Not clutter, and not free — it is a trade against the one workflow this
918/// project actually has. Roteiro is developed *inside* the repository it indexes,
919/// so a branch that bumps `crate::extract::EXTRACT_VERSION` and the `main` it
920/// will merge into share one `.git/roteiro` (the cache is under the **common**
921/// git dir). With no retention, one maintenance pass on the branch deletes
922/// `main`'s whole live set, and every switch back pays a full cold extraction;
923/// keeping the previous generation makes that switch free. Rolling a release back
924/// one version gets the same protection as a side effect.
925///
926/// It is bounded, which is the part that matters: the complaint being answered
927/// (#387) is *unbounded* accumulation — four generations resident and counting —
928/// and the steady state here is two, whatever happens next.
929pub const DEFAULT_KEEP_GENERATIONS: u32 = 1;
930
931/// Delete the object-cache entries left behind by **superseded** extractor
932/// generations, keeping the current one and `keep_generations` behind it.
933///
934/// # Why a sweep and not a byte budget
935///
936/// Because a proof is available here and nowhere else. `cache_key` writes the
937/// extractor generation into every key, and that generation only ever moves
938/// forward, so an entry tagged with an older one *cannot be asked for* by any
939/// binary at or beyond the current generation — no bookkeeping, no recency, no
940/// guessing. A byte budget (the Stage 25 / `rto-llama` `ModelCache` precedent,
941/// ported to disk by [`crate::Store::sweep_agent_cache`]) would have had to
942/// invent an ordering over live entries and would then evict *reachable* ones by
943/// design: on a cache shared by every worktree that means one worktree silently
944/// paying for another's working set, and it would need a last-used column this
945/// store has no clock to fill (ADR-0013 §3). It buys a bound this does not give —
946/// the live set itself is unbounded, and a repository large enough for that to
947/// hurt still needs one. That is a second policy on top of this one, not an
948/// alternative to it, and nothing has yet measured a need for it.
949///
950/// # What "superseded" is allowed to mean
951///
952/// **Only the generation**, i.e. `crate::extract::EXTRACT_BASE_VERSION`. The
953/// other two things folded into a key are deliberately *not* eligible:
954///
955/// - The **feature namespace** (`crate::extract::FEATURE_NAMESPACE_STRIDE` and
956/// above). A default build and an `--all-features` build write different
957/// `EXTRACT_VERSION`s at the *same* generation, and both are live at once —
958/// `cargo test --workspace` and `cargo test --all-features` on one repository
959/// are exactly that. Sweeping on the whole version number would have each build
960/// delete the other's cache on sight, and the two would take turns
961/// re-extracting for ever. So the namespace is masked off, and every namespace
962/// at a kept generation is kept.
963/// - The **environment tag** (`-e…`: the installed media-model and ingestion
964/// identity). It is a hash — unordered, so no tag can be shown to supersede
965/// another, and several are legitimately live at once (a build without
966/// `image-ocr` tags `0`; a build with it and a model installed does not).
967/// Reclaiming those would need the ordering the paragraph above rejected. They
968/// are left alone, and the cost of that is stated rather than hidden: env churn
969/// *within* one generation is not reclaimed by this pass.
970///
971/// # Why this is safe while other worktrees are live
972///
973/// The rule reads only the key, never the repository — so it does not need to
974/// know what any other worktree has checked out, and cannot be wrong about it. A
975/// reachability rule phrased over *blob ids* would need exactly that knowledge,
976/// and would be the dangerous version of this function: an oid unreachable from
977/// one worktree's `HEAD` is routinely live in another's. This one never asks.
978///
979/// Its only cross-worktree effect is on a worktree running an **older** binary,
980/// which it can cost a re-extraction and nothing else — the cache is derived, so
981/// a miss is slow, never wrong. The asymmetry runs one way: an entry from a
982/// *newer* generation than the sweeper's is retained, because `generation >=
983/// oldest_kept` holds for anything ahead. Two binaries of different ages can
984/// therefore never take turns deleting each other's work.
985///
986/// # Errors
987/// Returns [`CacheError`] if the cache cannot be listed. See
988/// [`ObjectCache::sweep`] for what a failure to delete an individual entry does
989/// (it is counted, not raised).
990pub fn sweep_superseded(
991 cache: &ObjectCache,
992 keep_generations: u32,
993) -> Result<ReclaimReport, CacheError> {
994 let current = crate::extract::EXTRACT_BASE_VERSION;
995 let oldest_kept = current.saturating_sub(keep_generations);
996
997 // The predicate is the only thing that ever classifies an entry, and it runs
998 // exactly once per scanned entry — so tallying here is the one place the
999 // reason for a retention is known, and it costs nothing extra. Counting it
1000 // afterwards would mean a second walk, and reconstructing it in the caller
1001 // would mean a second copy of this rule.
1002 let current_kept = Cell::new(0);
1003 let recent_kept = Cell::new(0);
1004 let ahead_kept = Cell::new(0);
1005 let unrecognised_kept = Cell::new(0);
1006 let tally = |counter: &Cell<usize>| counter.set(counter.get() + 1);
1007
1008 let sweep = cache.sweep(&|key| match key_generation(key) {
1009 // Not a key this module writes — a foreign or future format. Unreadable
1010 // is not the same as unreachable, and only one of the two may be deleted.
1011 None => {
1012 tally(&unrecognised_kept);
1013 true
1014 }
1015 Some(generation) if generation > current => {
1016 tally(&ahead_kept);
1017 true
1018 }
1019 Some(generation) if generation == current => {
1020 tally(¤t_kept);
1021 true
1022 }
1023 Some(generation) if generation >= oldest_kept => {
1024 tally(&recent_kept);
1025 true
1026 }
1027 Some(_) => false,
1028 })?;
1029
1030 let report = ReclaimReport {
1031 kept_current: current_kept.get(),
1032 kept_recent: recent_kept.get(),
1033 kept_ahead: ahead_kept.get(),
1034 kept_unrecognised: unrecognised_kept.get(),
1035 sweep,
1036 };
1037 debug_assert_eq!(
1038 report.kept_total(),
1039 report.sweep.retained,
1040 "every retained entry is retained for exactly one of the four reasons",
1041 );
1042 Ok(report)
1043}
1044
1045/// What one [`sweep_superseded`] pass did — and, for everything it kept, **why**.
1046///
1047/// The four `kept_*` counts exist because the retention rule keeps more than the
1048/// obvious class, and a summary that named only the obvious one would describe an
1049/// irreversible operation inaccurately. They partition [`ObjectSweep::retained`]:
1050/// each retained entry falls into exactly one, and their sum is that total.
1051#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
1052pub struct ReclaimReport {
1053 /// The underlying pass: what was scanned, freed, and left on disk.
1054 pub sweep: ObjectSweep,
1055 /// Kept at **this build's own generation** — the live set, the thing a sweep
1056 /// exists to not touch.
1057 pub kept_current: usize,
1058 /// Kept at an **older** generation still inside the `keep_generations`
1059 /// window. Unreachable by this build; deliberate insurance for the binary a
1060 /// generation behind that shares this cache (see
1061 /// [`DEFAULT_KEEP_GENERATIONS`]).
1062 pub kept_recent: usize,
1063 /// Kept because it belongs to a generation **ahead** of this build — another
1064 /// worktree, or a colleague, running a newer binary against the same shared
1065 /// cache. Never swept, which is what stops two binaries of different ages
1066 /// taking turns deleting each other's work.
1067 pub kept_ahead: usize,
1068 /// Kept because `key_generation` could not read a generation out of the key
1069 /// at all. Doubt retains, always — but a non-zero count here is worth
1070 /// investigating rather than absorbing into a total, because it is either a
1071 /// format this build no longer writes or a bug in the parser, and both are
1072 /// things a reader would want to know their cache is holding.
1073 pub kept_unrecognised: usize,
1074}
1075
1076impl ReclaimReport {
1077 /// The four `kept_*` counts summed — equal to [`ObjectSweep::retained`].
1078 #[must_use]
1079 pub fn kept_total(&self) -> usize {
1080 self.kept_current + self.kept_recent + self.kept_ahead + self.kept_unrecognised
1081 }
1082}
1083
1084/// The extractor **generation** encoded in a `cache_key` key, or `None` if the
1085/// key does not carry one in the exact shape `cache_key` writes.
1086///
1087/// The parse is strict on purpose: this is the predicate a delete hangs off, so
1088/// every doubt has to resolve to `None`, which retains. It therefore requires the
1089/// whole `-v<digits>-e<16 hex digits>` tail, rejects a sign that `u32::from_str`
1090/// would otherwise accept (`+12`), and rejects an environment tag of the wrong
1091/// width — anything merely *shaped like* a key is left alone.
1092fn key_generation(key: &str) -> Option<u32> {
1093 let (head, env) = key.rsplit_once("-e")?;
1094 if env.len() != 16 || !env.bytes().all(|b| b.is_ascii_hexdigit()) {
1095 return None;
1096 }
1097 let (_, version) = head.rsplit_once("-v")?;
1098 if version.is_empty() || !version.bytes().all(|b| b.is_ascii_digit()) {
1099 return None;
1100 }
1101 // Mask off the feature namespace; what remains is the generation. Sound while
1102 // the base stays below the stride, which `extract.rs` asserts at compile time.
1103 Some(version.parse::<u32>().ok()? % crate::extract::FEATURE_NAMESPACE_STRIDE)
1104}
1105
1106/// FNV-1a (64-bit). Dependency-free and deterministic; used only to derive
1107/// cache filenames, so it needs no cryptographic properties.
1108fn fnv1a64(bytes: &[u8]) -> u64 {
1109 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1110 for &b in bytes {
1111 hash ^= u64::from(b);
1112 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1113 }
1114 hash
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::{ObjectCache, cache_key, key_generation, resolve_calls};
1120 use crate::{EdgeKind, FactSet, Node, NodeKind};
1121
1122 fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
1123 let mut n = Node::new(key, NodeKind::Fn, name);
1124 if !calls.is_empty() {
1125 n.meta = serde_json::json!({ "calls": calls });
1126 }
1127 n
1128 }
1129
1130 #[test]
1131 fn resolve_calls_links_unique_names_only() {
1132 let mut fs = FactSet::new()
1133 .with_node(fn_node(
1134 "sym:rust:a.rs#caller",
1135 "caller",
1136 &["target", "dup", "missing"],
1137 ))
1138 .with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
1139 // Two functions named `dup` → ambiguous, must not be linked.
1140 .with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
1141 .with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
1142
1143 resolve_calls(&mut fs);
1144
1145 let calls: Vec<_> = fs
1146 .edges
1147 .iter()
1148 .filter(|e| e.kind == EdgeKind::Calls)
1149 .collect();
1150 assert_eq!(
1151 calls.len(),
1152 1,
1153 "only the unambiguous, known callee is linked"
1154 );
1155 assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
1156 assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
1157 }
1158
1159 #[test]
1160 fn cache_key_separates_paths_but_is_stable() {
1161 let oid = "abc123";
1162 // Same path + oid + env is stable across calls.
1163 assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
1164 // Same blob content (oid) at two different paths must not collide.
1165 assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
1166 // Different content at the same path differs too.
1167 assert_ne!(
1168 cache_key("src/a.rs", "aaa", 0),
1169 cache_key("src/a.rs", "bbb", 0)
1170 );
1171 // A different extractor environment (e.g. OCR models installed) differs,
1172 // so image facts are re-extracted when the models change.
1173 assert_ne!(
1174 cache_key("src/a.rs", oid, 0),
1175 cache_key("src/a.rs", oid, 42)
1176 );
1177 // Key stays sharded on the oid so the cache's 2-char shard is well spread.
1178 assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
1179 // The extractor version is folded in, so a bump retires old entries.
1180 assert!(
1181 cache_key("src/a.rs", oid, 0)
1182 .contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
1183 );
1184 }
1185
1186 /// The sweep predicate's one input. The round trip is what makes the sweep
1187 /// safe: a key this module just wrote must decode to *this* generation, or a
1188 /// pass at the current version would delete its own live entries.
1189 #[test]
1190 fn key_generation_round_trips_the_key_this_module_writes() {
1191 let key = cache_key("src/a.rs", "abc123", 0);
1192 assert_eq!(
1193 key_generation(&key),
1194 Some(crate::extract::EXTRACT_BASE_VERSION),
1195 "a key written now decodes to the current generation: {key}",
1196 );
1197 // …and so does the same generation in another feature build's namespace,
1198 // which is the whole reason the namespace is masked off rather than
1199 // compared. Both are live at once on a machine that runs the default and
1200 // `--all-features` test suites over one repository.
1201 let base = crate::extract::EXTRACT_BASE_VERSION;
1202 for namespace in [100, 200, 300, 400, 500, 600, 700] {
1203 let other = format!(
1204 "abc123-0000000000000000-v{}-e0000000000000000",
1205 base + namespace
1206 );
1207 assert_eq!(
1208 key_generation(&other),
1209 Some(base),
1210 "namespace {namespace} is not a different generation",
1211 );
1212 }
1213 }
1214
1215 /// Every doubt resolves to `None`, and `None` retains. These are the strings
1216 /// that must *not* be read as a generation — each one would otherwise put a
1217 /// file nobody can identify in reach of a delete.
1218 #[test]
1219 fn key_generation_refuses_anything_it_did_not_write() {
1220 for not_a_key in [
1221 "",
1222 "abc123", // no tail at all
1223 "abc123-0000000000000000-v12", // no env tag
1224 "abc123-0000000000000000-e0000000000000000", // no version tag
1225 "abc123-0000000000000000-v12-e00000000000000", // env too short
1226 "abc123-0000000000000000-v12-e00000000000000000", // env too long
1227 "abc123-0000000000000000-v12-egggggggggggggggg", // env not hex
1228 "abc123-0000000000000000-v+12-e0000000000000000", // `+12` parses as 12
1229 "abc123-0000000000000000-v-e0000000000000000", // empty version
1230 "abc123-0000000000000000-v1 2-e0000000000000000", // not all digits
1231 "abc123-0000000000000000-v99999999999-e0000000000000000", // overflows u32
1232 ] {
1233 assert_eq!(
1234 key_generation(not_a_key),
1235 None,
1236 "`{not_a_key}` must not be read as a generation",
1237 );
1238 }
1239 }
1240
1241 /// The sweep's contract, on a cache holding one entry per generation and
1242 /// namespace: the current generation survives in **every** namespace, the
1243 /// retained generations survive, older ones go, and a *newer* one — written
1244 /// by a binary ahead of this one sharing the same common git dir — is never
1245 /// touched, whatever the retention.
1246 #[test]
1247 fn sweep_superseded_keeps_current_future_and_kept_generations() {
1248 let base = crate::extract::EXTRACT_BASE_VERSION;
1249 let dir = std::env::temp_dir().join(format!("roteiro-gc-{}", std::process::id()));
1250 std::fs::remove_dir_all(&dir).ok();
1251 let cache = ObjectCache::open(&dir).expect("open");
1252
1253 let key = |version: u32| format!("abc123-0000000000000000-v{version}-e0000000000000000");
1254 let ancient = key(base - 2);
1255 let previous = key(base - 1);
1256 let current = key(base);
1257 let current_all_features = key(base + 700);
1258 let future = key(base + 1);
1259 let foreign = "not-a-roteiro-cache-key".to_owned();
1260 for k in [
1261 &ancient,
1262 &previous,
1263 ¤t,
1264 ¤t_all_features,
1265 &future,
1266 &foreign,
1267 ] {
1268 cache.put(k, &FactSet::new()).expect("put");
1269 }
1270
1271 // Keeping one generation back: only `base - 2` is unreachable.
1272 let swept =
1273 super::sweep_superseded(&cache, super::DEFAULT_KEEP_GENERATIONS).expect("sweep");
1274 assert_eq!(swept.sweep.removed, 1, "{swept:?}");
1275 // Retention is not one class, and the report says which. Two entries sit
1276 // at this generation (the two namespaces), one behind it, one ahead of
1277 // it, and one key that does not parse — each counted under its own
1278 // reason, because a summary that folded them together would describe an
1279 // irreversible operation inaccurately.
1280 assert_eq!(
1281 (
1282 swept.kept_current,
1283 swept.kept_recent,
1284 swept.kept_ahead,
1285 swept.kept_unrecognised,
1286 ),
1287 (2, 1, 1, 1),
1288 "{swept:?}",
1289 );
1290 assert_eq!(
1291 swept.kept_total(),
1292 swept.sweep.retained,
1293 "the four reasons must partition the retained total: {swept:?}",
1294 );
1295 assert!(!cache.contains(&ancient));
1296 for k in [
1297 &previous,
1298 ¤t,
1299 ¤t_all_features,
1300 &future,
1301 &foreign,
1302 ] {
1303 assert!(cache.contains(k), "`{k}` must survive a keep-1 sweep");
1304 }
1305
1306 // Keeping none: the previous generation goes too, and nothing else does.
1307 let swept = super::sweep_superseded(&cache, 0).expect("sweep");
1308 assert_eq!(swept.sweep.removed, 1, "{swept:?}");
1309 assert!(!cache.contains(&previous));
1310 for k in [¤t, ¤t_all_features, &future, &foreign] {
1311 assert!(cache.contains(k), "`{k}` must survive a keep-0 sweep");
1312 }
1313
1314 // A repeat pass is a no-op: nothing reachable is ever swept "eventually".
1315 let swept = super::sweep_superseded(&cache, 0).expect("sweep");
1316 assert_eq!(swept.sweep.removed, 0, "{swept:?}");
1317 assert_eq!(swept.sweep.retained, 4, "{swept:?}");
1318
1319 std::fs::remove_dir_all(&dir).expect("cleanup");
1320 }
1321}