Skip to content

Commit 35a999f

Browse files
feat(ingest): git-history ingestion behind --features git-integration (#28)
Implements the deferred git2 ingest seam. `ingest::from_git(repo)`: - builds the structural lattice from the committed HEAD tree (tracked files only -- never untracked/.gitignore'd), with Module/File/Definition nodes (definitions extracted from blob contents, reusing extract_definitions); - enriches it with temporal coupling: `co_change` relationships between files repeatedly modified in the same commit (scans up to 500 commits, skips merges and bulk commits >25 files, requires >=2 co-changes per edge). `reticulate build` now uses a git-aware ingest helper when built with the feature, falling back to the filesystem walk for a non-git path or the default build. The REST API and default build are unchanged. A feature-gated test runs from_git against this repo's own history and asserts real structure plus the P2a DAG invariant after coupling edges are added. Verified: clippy --all-targets and cargo test green for BOTH default and --features git-integration; end-to-end `reticulate build --repo .` exercised. https://claude.ai/code/session_01JNCDaWMB8NV6nAPrvmTg4w
1 parent 0e91507 commit 35a999f

2 files changed

Lines changed: 186 additions & 1 deletion

File tree

src/cli/main.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,22 @@ enum Commands {
3838
},
3939
}
4040

41+
/// Ingest a repository into a lattice. With `--features git-integration` this is
42+
/// git-aware (HEAD tree + commit-history coupling), falling back to a filesystem
43+
/// walk when `repo` is not a git repository; otherwise it is always a walk.
44+
#[cfg(feature = "git-integration")]
45+
fn reticulate_ingest(repo: &str) -> git_reticulator::lattice::Lattice {
46+
match git_reticulator::ingest::from_git(repo) {
47+
Ok(lattice) => lattice,
48+
Err(_) => git_reticulator::ingest::from_path(repo),
49+
}
50+
}
51+
52+
#[cfg(not(feature = "git-integration"))]
53+
fn reticulate_ingest(repo: &str) -> git_reticulator::lattice::Lattice {
54+
git_reticulator::ingest::from_path(repo)
55+
}
56+
4157
#[tokio::main]
4258
async fn main() {
4359
let cli = Cli::parse();
@@ -46,7 +62,7 @@ async fn main() {
4662
match &cli.command {
4763
Commands::Build { repo, db } => {
4864
println!("🚀 Reticulating {repo} ...");
49-
let lattice = git_reticulator::ingest::from_path(repo);
65+
let lattice = reticulate_ingest(repo);
5066
let cond = lattice.condense();
5167
println!(
5268
" {} nodes · {} edges · {} components · acyclic={}",

src/ingest.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,172 @@ mod tests {
131131
assert!(defs.contains(&"helper".to_string()));
132132
}
133133
}
134+
135+
// ---------------------------------------------------------------------------
136+
// Git-history ingestion (feature `git-integration`).
137+
//
138+
// Unlike the std-only `from_path` walk, this reads the *committed* HEAD tree
139+
// (tracked files only — never untracked or .gitignore'd), then enriches the
140+
// lattice with temporal coupling: `co_change` relationships between files that
141+
// are repeatedly modified in the same commit. That coupling is a real
142+
// history-derived signal the filesystem walk cannot see.
143+
// ---------------------------------------------------------------------------
144+
#[cfg(feature = "git-integration")]
145+
pub use git_history::from_git;
146+
147+
#[cfg(feature = "git-integration")]
148+
mod git_history {
149+
use super::{extract_definitions, MAX_DEFS_PER_FILE};
150+
use crate::lattice::{Lattice, LatticeBuilder, NodeId, SemanticLevel};
151+
use std::collections::HashMap;
152+
153+
/// Most recent commits scanned for co-change coupling.
154+
const MAX_COMMITS: usize = 500;
155+
/// Commits touching more files than this are treated as bulk/vendoring
156+
/// changes and excluded from coupling (they create dense spurious edges).
157+
const MAX_FILES_PER_COMMIT: usize = 25;
158+
/// Minimum times two files must co-change before an edge is recorded.
159+
const MIN_COCHANGE: usize = 2;
160+
161+
/// Build a lattice from a git repository's HEAD tree and commit history.
162+
///
163+
/// Returns an error only if `repo_path` is not a readable git repository or
164+
/// HEAD is unborn. Every per-commit and per-blob failure is fail-soft
165+
/// (skipped), so a partially-corrupt history still yields a usable lattice.
166+
pub fn from_git(repo_path: &str) -> Result<Lattice, git2::Error> {
167+
let repo = git2::Repository::open(repo_path)?;
168+
let mut builder = LatticeBuilder::new();
169+
170+
let root_name = repo
171+
.workdir()
172+
.and_then(|w| w.file_name())
173+
.and_then(|s| s.to_str())
174+
.map(String::from)
175+
.unwrap_or_else(|| repo_path.to_string());
176+
let root_id =
177+
builder.add_keyword(root_name, repo_path.to_string(), SemanticLevel::Module, None);
178+
179+
// 1. Structure from the HEAD tree. `dir_ids` is keyed by the
180+
// trailing-slash directory path git2 hands the walk callback ("" is
181+
// the repo root); `file_ids` by the repo-relative file path, which
182+
// matches the paths git diffs report (so step 2 maps cleanly).
183+
let mut dir_ids: HashMap<String, NodeId> = HashMap::new();
184+
dir_ids.insert(String::new(), root_id);
185+
let mut file_ids: HashMap<String, NodeId> = HashMap::new();
186+
let mut blobs: Vec<(NodeId, git2::Oid, String)> = Vec::new();
187+
188+
let head = repo.head()?.peel_to_tree()?;
189+
head.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
190+
let name = match entry.name() {
191+
Some(n) => n.to_string(),
192+
None => return git2::TreeWalkResult::Ok, // non-UTF-8 path: skip
193+
};
194+
let parent = dir_ids.get(dir).copied().unwrap_or(root_id);
195+
match entry.kind() {
196+
Some(git2::ObjectType::Tree) => {
197+
let full_dir = format!("{dir}{name}/");
198+
let id = builder.add_keyword(
199+
name,
200+
full_dir.trim_end_matches('/').to_string(),
201+
SemanticLevel::Module,
202+
Some(parent),
203+
);
204+
dir_ids.insert(full_dir, id);
205+
}
206+
Some(git2::ObjectType::Blob) => {
207+
let full = format!("{dir}{name}");
208+
let fid =
209+
builder.add_keyword(name, full.clone(), SemanticLevel::File, Some(parent));
210+
file_ids.insert(full.clone(), fid);
211+
blobs.push((fid, entry.id(), full)); // read content after the walk
212+
}
213+
_ => {}
214+
}
215+
git2::TreeWalkResult::Ok
216+
})?;
217+
218+
// Definitions, read from blob contents after the walk (keeps the walk
219+
// closure free of the `repo` borrow).
220+
for (fid, oid, path) in &blobs {
221+
if let Ok(blob) = repo.find_blob(*oid) {
222+
if let Ok(text) = std::str::from_utf8(blob.content()) {
223+
for def in extract_definitions(text).into_iter().take(MAX_DEFS_PER_FILE) {
224+
builder.add_keyword(def, path.clone(), SemanticLevel::Definition, Some(*fid));
225+
}
226+
}
227+
}
228+
}
229+
230+
// 2. Temporal coupling from history: count file pairs that co-change.
231+
let mut counts: HashMap<(NodeId, NodeId), usize> = HashMap::new();
232+
if let Ok(mut revwalk) = repo.revwalk() {
233+
if revwalk.push_head().is_ok() {
234+
for oid in revwalk.flatten().take(MAX_COMMITS) {
235+
let commit = match repo.find_commit(oid) {
236+
Ok(c) => c,
237+
Err(_) => continue,
238+
};
239+
if commit.parent_count() > 1 {
240+
continue; // skip merges: their diffs are not real co-edits
241+
}
242+
let tree = match commit.tree() {
243+
Ok(t) => t,
244+
Err(_) => continue,
245+
};
246+
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
247+
let diff =
248+
match repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) {
249+
Ok(d) => d,
250+
Err(_) => continue,
251+
};
252+
let mut changed: Vec<NodeId> = Vec::new();
253+
for delta in diff.deltas() {
254+
let path = delta.new_file().path().or_else(|| delta.old_file().path());
255+
if let Some(p) = path {
256+
if let Some(&fid) = file_ids.get(p.to_string_lossy().as_ref()) {
257+
changed.push(fid);
258+
}
259+
}
260+
}
261+
if changed.len() < 2 || changed.len() > MAX_FILES_PER_COMMIT {
262+
continue;
263+
}
264+
changed.sort_unstable();
265+
changed.dedup();
266+
for i in 0..changed.len() {
267+
for j in (i + 1)..changed.len() {
268+
*counts.entry((changed[i], changed[j])).or_insert(0) += 1;
269+
}
270+
}
271+
}
272+
}
273+
}
274+
for ((a, b), n) in counts {
275+
if n >= MIN_COCHANGE {
276+
builder.add_relationship(a, b, n as f64, "co_change".to_string());
277+
}
278+
}
279+
280+
Ok(builder.build())
281+
}
282+
}
283+
284+
#[cfg(all(test, feature = "git-integration"))]
285+
mod git_history_tests {
286+
use super::from_git;
287+
288+
#[test]
289+
fn ingests_self_repo_with_structure_and_remains_a_dag() {
290+
// The package working directory is itself a git repository.
291+
let Ok(lat) = from_git(".") else {
292+
panic!("the package working directory should be a git repository");
293+
};
294+
assert!(lat.len() > 1, "the HEAD tree should yield more than the root module");
295+
assert!(
296+
lat.nodes().iter().any(|k| k.name == "Cargo.toml"),
297+
"the tracked Cargo.toml should appear as a File node"
298+
);
299+
// Adding co_change edges must not break the core invariant (P2a).
300+
assert!(lat.condense().is_acyclic());
301+
}
302+
}

0 commit comments

Comments
 (0)