use std::collections::{BTreeMap, BTreeSet, HashSet};
use crate::cache::{CacheError, ObjectCache};
use crate::extract::Extractor;
use crate::git::{GitError, Repo};
use crate::store::StoreError;
use crate::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance, Store};
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Cache(#[from] CacheError),
#[error(transparent)]
Git(#[from] GitError),
#[error("worktree io error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct SyncReport {
pub tree: String,
pub no_op: bool,
pub blobs_total: usize,
pub blobs_extracted: usize,
pub blobs_cached: usize,
pub blobs_dirty: usize,
pub nodes: u64,
pub edges: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub rebuilt_from_foreign_worktree: Option<String>,
}
#[must_use]
pub fn worktree_id(repo: &Repo) -> String {
repo.workdir()
.unwrap_or_else(|| repo.git_dir())
.to_string_lossy()
.into_owned()
}
fn foreign_worktree(store: &Store, repo: &Repo) -> Result<Option<String>, SyncError> {
let here = worktree_id(repo);
Ok(store.synced_worktree()?.filter(|prior| *prior != here))
}
pub fn sync(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<SyncReport, SyncError> {
let tree = repo.head_tree_id()?;
let env = format!(
"v{}-e{:016x}",
crate::extract::EXTRACT_VERSION,
extractor.env_tag()
);
let foreign = foreign_worktree(store, repo)?;
if foreign.is_none()
&& store.sync_state()?.as_deref() == Some(tree.as_str())
&& store.sync_env()?.as_deref() == Some(env.as_str())
{
return Ok(SyncReport {
no_op: true,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
..SyncReport::default()
});
}
if foreign.is_none()
&& let Some(report) = try_incremental(store, repo, cache, extractor, &tree, &env)?
{
return Ok(report);
}
let committed = extract_committed(repo, cache, extractor)?;
let mut assembled = flatten(committed.by_path);
resolve_calls(&mut assembled);
append_submodule_nodes(repo.submodules()?, &mut assembled);
let total = file_count(&assembled);
store.reconcile(&assembled, Some(&tree))?;
store.set_sync_env(&env)?;
store.set_synced_worktree(&worktree_id(repo))?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: committed.extracted,
blobs_cached: committed.cached,
blobs_dirty: 0,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
rebuilt_from_foreign_worktree: foreign,
})
}
fn try_incremental(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
head_tree: &str,
env: &str,
) -> Result<Option<SyncReport>, SyncError> {
let Some(prior_tree) = store.sync_state()? else {
return Ok(None);
};
if prior_tree.contains(':') || store.sync_env()?.as_deref() != Some(env) {
return Ok(None);
}
let Ok(diff) = repo.diff_trees(&prior_tree, head_tree) else {
return Ok(None);
};
let mut nodes: Vec<Node> = store.nodes_by_provenance(Provenance::Derived)?;
let mut edges: Vec<Edge> = store
.edges_by_provenance(Provenance::Derived)?
.into_iter()
.filter(|e| e.kind != EdgeKind::Calls)
.collect();
let touched: BTreeSet<&str> = diff
.changed
.iter()
.map(|b| b.path.as_str())
.chain(diff.deleted.iter().map(String::as_str))
.collect();
let dropped: HashSet<String> = nodes
.iter()
.filter(|n| n.path.as_deref().is_some_and(|p| touched.contains(p)))
.map(|n| n.key.clone())
.collect();
nodes.retain(|n| !dropped.contains(&n.key));
edges.retain(|e| !dropped.contains(&e.src) && !dropped.contains(&e.dst));
let env_tag = extractor.env_tag();
let mut extracted = 0usize;
let mut cached = 0usize;
for blob in &diff.changed {
let key = cache_key(&blob.path, &blob.oid, env_tag);
let facts = if let Some(facts) = cache.get(&key)? {
cached += 1;
facts
} else {
let bytes = repo.read_blob(&blob.oid)?;
let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
cache.put(&key, &facts)?;
extracted += 1;
facts
};
nodes.extend(facts.nodes);
edges.extend(facts.edges);
}
let referenced: HashSet<&str> = edges
.iter()
.flat_map(|e| [e.src.as_str(), e.dst.as_str()])
.collect();
nodes.retain(|n| n.path.is_some() || referenced.contains(n.key.as_str()));
let mut assembled = FactSet { nodes, edges };
resolve_calls(&mut assembled);
append_submodule_nodes(repo.submodules()?, &mut assembled);
let total = file_count(&assembled);
store.reconcile(&assembled, Some(head_tree))?;
store.set_sync_env(env)?;
store.set_synced_worktree(&worktree_id(repo))?;
Ok(Some(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: extracted,
blobs_cached: cached,
blobs_dirty: 0,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree: head_tree.to_owned(),
rebuilt_from_foreign_worktree: None,
}))
}
pub fn sync_worktree(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<SyncReport, SyncError> {
let tree = repo.head_tree_id()?;
let committed = extract_committed(repo, cache, extractor)?;
let mut by_path = committed.by_path;
let mut dirty: BTreeSet<(String, String)> = BTreeSet::new();
if let Some(workdir) = repo.workdir() {
for blob in &committed.blobs {
match std::fs::read(workdir.join(&blob.path)) {
Ok(bytes) => {
let woid = repo.blob_oid(&bytes)?;
if woid != blob.oid {
by_path.insert(
blob.path.clone(),
extractor.extract(&blob.path, &woid, &bytes),
);
dirty.insert((blob.path.clone(), woid));
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
by_path.remove(&blob.path);
dirty.insert((blob.path.clone(), "\0deleted".to_owned()));
}
Err(e) => return Err(e.into()),
}
}
for path in repo.untracked_files()? {
match std::fs::read(workdir.join(&path)) {
Ok(bytes) => {
let woid = repo.blob_oid(&bytes)?;
by_path.insert(path.clone(), extractor.extract(&path, &woid, &bytes));
dirty.insert((path, woid));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
}
}
let total = by_path.len();
let state = if dirty.is_empty() {
tree.clone()
} else {
let mut buf = String::new();
for (path, marker) in &dirty {
buf.push_str(path);
buf.push('\0');
buf.push_str(marker);
buf.push('\n');
}
format!("{tree}:dirty:{:016x}", fnv1a64(buf.as_bytes()))
};
let dirty_count = dirty.len();
let foreign = foreign_worktree(store, repo)?;
if foreign.is_none() && store.sync_state()?.as_deref() == Some(state.as_str()) {
return Ok(SyncReport {
no_op: true,
blobs_total: total,
blobs_dirty: dirty_count,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
..SyncReport::default()
});
}
let mut assembled = flatten(by_path);
resolve_calls(&mut assembled);
append_submodule_nodes(repo.submodules()?, &mut assembled);
store.reconcile(&assembled, Some(&state))?;
store.set_synced_worktree(&worktree_id(repo))?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: committed.extracted,
blobs_cached: committed.cached,
blobs_dirty: dirty_count,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree,
rebuilt_from_foreign_worktree: foreign,
})
}
pub fn sync_index(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<SyncReport, SyncError> {
let staged = repo.index_files()?;
let mut buf = String::new();
for blob in &staged {
buf.push_str(&blob.path);
buf.push('\0');
buf.push_str(&blob.oid);
buf.push('\n');
}
let state = format!("index:{:016x}", fnv1a64(buf.as_bytes()));
let foreign = foreign_worktree(store, repo)?;
if foreign.is_none() && store.sync_state()?.as_deref() == Some(state.as_str()) {
return Ok(SyncReport {
no_op: true,
blobs_total: staged.len(),
nodes: store.node_count()?,
edges: store.edge_count()?,
tree: state,
..SyncReport::default()
});
}
let extracted = extract_blobs(repo, cache, extractor, staged)?;
let total = extracted.by_path.len();
let mut assembled = flatten(extracted.by_path);
resolve_calls(&mut assembled);
append_submodule_nodes(repo.index_submodules()?, &mut assembled);
store.reconcile(&assembled, Some(&state))?;
store.set_synced_worktree(&worktree_id(repo))?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: extracted.extracted,
blobs_cached: extracted.cached,
blobs_dirty: 0,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree: state,
rebuilt_from_foreign_worktree: foreign,
})
}
pub fn sync_tree(
store: &mut Store,
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
rev: &str,
) -> Result<SyncReport, SyncError> {
let extracted = extract_blobs(repo, cache, extractor, repo.blobs_at(rev)?)?;
let mut assembled = flatten(extracted.by_path);
resolve_calls(&mut assembled);
append_submodule_nodes(repo.submodules_at(rev)?, &mut assembled);
let total = file_count(&assembled);
store.rebuild(&assembled, None)?;
Ok(SyncReport {
no_op: false,
blobs_total: total,
blobs_extracted: extracted.extracted,
blobs_cached: extracted.cached,
blobs_dirty: 0,
nodes: store.node_count()?,
edges: store.edge_count()?,
tree: rev.to_owned(),
rebuilt_from_foreign_worktree: None,
})
}
struct Committed {
blobs: Vec<crate::BlobRef>,
by_path: BTreeMap<String, FactSet>,
extracted: usize,
cached: usize,
}
fn extract_committed(
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
) -> Result<Committed, SyncError> {
extract_blobs(repo, cache, extractor, repo.walk_blobs()?)
}
fn extract_blobs(
repo: &Repo,
cache: &ObjectCache,
extractor: &dyn Extractor,
blobs: Vec<crate::BlobRef>,
) -> Result<Committed, SyncError> {
let mut by_path = BTreeMap::new();
let mut extracted = 0usize;
let mut cached = 0usize;
let env = extractor.env_tag();
for blob in &blobs {
let key = cache_key(&blob.path, &blob.oid, env);
let facts = if let Some(facts) = cache.get(&key)? {
cached += 1;
facts
} else {
let bytes = repo.read_blob(&blob.oid)?;
let facts = extractor.extract(&blob.path, &blob.oid, &bytes);
cache.put(&key, &facts)?;
extracted += 1;
facts
};
by_path.insert(blob.path.clone(), facts);
}
Ok(Committed {
blobs,
by_path,
extracted,
cached,
})
}
fn flatten(by_path: BTreeMap<String, FactSet>) -> FactSet {
let mut assembled = FactSet::new();
for facts in by_path.into_values() {
assembled.nodes.extend(facts.nodes);
assembled.edges.extend(facts.edges);
}
assembled
}
pub(crate) const SUBMODULE_KIND: &str = "submodule";
fn append_submodule_nodes(subs: Vec<crate::Submodule>, assembled: &mut FactSet) {
let kind = NodeKind::Other(SUBMODULE_KIND.to_owned());
assembled.nodes.retain(|n| n.kind != kind);
for sm in subs {
let key = format!("submodule:{}", sm.path);
let mut node = Node::new(key, kind.clone(), sm.path.clone());
node.path = Some(".gitmodules".to_owned());
node.provenance = Provenance::Derived;
node.meta = serde_json::json!({ "path": sm.path, "url": sm.url, "sha": sm.sha });
assembled.nodes.push(node);
}
}
fn file_count(facts: &FactSet) -> usize {
facts
.nodes
.iter()
.filter(|n| n.kind == NodeKind::File)
.count()
}
fn resolve_calls(facts: &mut FactSet) {
let mut by_name: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for n in &facts.nodes {
if n.kind == NodeKind::Fn {
by_name
.entry(n.name.as_str())
.or_default()
.push(n.key.as_str());
}
}
let mut resolved: BTreeSet<(String, String)> = BTreeSet::new();
for n in &facts.nodes {
if n.kind != NodeKind::Fn {
continue;
}
let Some(calls) = n.meta.get("calls").and_then(|v| v.as_array()) else {
continue;
};
let caller_self = self_type_of(&n.key);
for descriptor in calls.iter().filter_map(|v| v.as_str()) {
let (qualifier, name) = split_callee(descriptor);
let Some(candidates) = by_name.get(name) else {
continue;
};
let target = if candidates.len() == 1 {
Some(candidates[0])
} else if let Some(q) = qualifier {
let want = if q == "Self" { caller_self } else { Some(q) };
want.and_then(|want| unique_in_scope(candidates, want, name))
} else {
None
};
if let Some(dst) = target {
resolved.insert((n.key.clone(), dst.to_owned()));
}
}
}
for (src, dst) in resolved {
facts.edges.push(Edge::derived(src, dst, EdgeKind::Calls));
}
}
fn qualified_suffix(key: &str) -> &str {
key.rsplit_once('#').map_or(key, |(_, q)| q)
}
fn self_type_of(key: &str) -> Option<&str> {
let mut segs = qualified_suffix(key).rsplit("::");
segs.next()?; segs.next() }
fn unique_in_scope<'a>(candidates: &[&'a str], want: &str, name: &str) -> Option<&'a str> {
let mut hit = None;
for &key in candidates {
let mut segs = qualified_suffix(key).rsplit("::");
if segs.next() == Some(name) && segs.next() == Some(want) {
if hit.is_some() {
return None; }
hit = Some(key);
}
}
hit
}
fn split_callee(descriptor: &str) -> (Option<&str>, &str) {
match descriptor.rsplit_once("::") {
Some((qualifier, name)) => (Some(qualifier), name),
None => (None, descriptor),
}
}
fn cache_key(path: &str, oid: &str, env: u64) -> String {
format!(
"{oid}-{:016x}-v{}-e{env:016x}",
fnv1a64(path.as_bytes()),
crate::extract::EXTRACT_VERSION,
)
}
fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::{cache_key, resolve_calls};
use crate::{EdgeKind, FactSet, Node, NodeKind};
fn fn_node(key: &str, name: &str, calls: &[&str]) -> Node {
let mut n = Node::new(key, NodeKind::Fn, name);
if !calls.is_empty() {
n.meta = serde_json::json!({ "calls": calls });
}
n
}
#[test]
fn resolve_calls_links_unique_names_only() {
let mut fs = FactSet::new()
.with_node(fn_node(
"sym:rust:a.rs#caller",
"caller",
&["target", "dup", "missing"],
))
.with_node(fn_node("sym:rust:a.rs#target", "target", &[]))
.with_node(fn_node("sym:rust:a.rs#dup", "dup", &[]))
.with_node(fn_node("sym:rust:b.rs#dup", "dup", &[]));
resolve_calls(&mut fs);
let calls: Vec<_> = fs
.edges
.iter()
.filter(|e| e.kind == EdgeKind::Calls)
.collect();
assert_eq!(
calls.len(),
1,
"only the unambiguous, known callee is linked"
);
assert_eq!(calls[0].src, "sym:rust:a.rs#caller");
assert_eq!(calls[0].dst, "sym:rust:a.rs#target");
}
#[test]
fn cache_key_separates_paths_but_is_stable() {
let oid = "abc123";
assert_eq!(cache_key("src/a.rs", oid, 0), cache_key("src/a.rs", oid, 0));
assert_ne!(cache_key("src/a.rs", oid, 0), cache_key("src/b.rs", oid, 0));
assert_ne!(
cache_key("src/a.rs", "aaa", 0),
cache_key("src/a.rs", "bbb", 0)
);
assert_ne!(
cache_key("src/a.rs", oid, 0),
cache_key("src/a.rs", oid, 42)
);
assert!(cache_key("src/a.rs", oid, 0).starts_with("abc123-"));
assert!(
cache_key("src/a.rs", oid, 0)
.contains(&format!("-v{}", crate::extract::EXTRACT_VERSION))
);
}
}