@@ -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