//! tree usecase: impl TreeOps for GitRepository. use crate::GitRepository; use crate::object::types::ObjectType; use crate::tree::impls::TreeOps; use crate::tree::types::{ EntryFollowResult, EntryMode, PathLastUpdated, PathResult, Tree, TreeEntry, }; fn entry_to_type(mode: gix::objs::tree::EntryMode) -> ObjectType { match mode.kind() { gix::objs::tree::EntryKind::Tree => ObjectType::Tree, gix::objs::tree::EntryKind::Blob | gix::objs::tree::EntryKind::BlobExecutable | gix::objs::tree::EntryKind::Link => ObjectType::Blob, gix::objs::tree::EntryKind::Commit => ObjectType::Commit, } } fn entry_to_tree_entry(repo: &gix::Repository, e: gix::objs::tree::EntryRef) -> TreeEntry { let mode = EntryMode::from(e.mode); let size = if mode.is_blob() { repo.find_object(e.oid).ok().map(|o| o.data.len() as i64) } else { None }; TreeEntry { id: e.oid.to_owned(), name: e.filename.to_string(), mode, size, object_type: entry_to_type(e.mode), } } impl TreeOps for GitRepository { fn tree_read(&self, id: &gix::hash::ObjectId) -> Result { if let Some(hit) = self.cache_get_oid::("tree", id) { return Ok(hit); } let repo = self.repo()?; let tree = repo .find_tree(*id) .map_err(|e| crate::Error::Find(e.to_string()))?; let refs = tree .decode() .map_err(|e| crate::Error::Find(e.to_string()))?; let entries = refs .entries .iter() .map(|e| entry_to_tree_entry(&repo, *e)) .collect(); let v = Tree { id: *id, entries }; self.cache_put_oid("tree", id, &v); Ok(v) } fn tree_resolve_path( &self, tree_id: &gix::hash::ObjectId, path: &str, ) -> Result { let tip = self.tip_id()?; let key = format!("{tree_id}.{path}"); if let Some(hit) = self.cache_get_rev::("tree", "resolve", &key, tip) { return Ok(hit); } let repo = self.repo()?; let mut current = repo .find_tree(*tree_id) .map_err(|e| crate::Error::Find(e.to_string()))?; let mut parts = path.split('/').filter(|s| !s.is_empty()); let mut last = None; while let Some(part) = parts.next() { let entry = current .find_entry(part.as_bytes()) .ok_or_else(|| crate::Error::NotFound { name: part.to_string(), })?; let te = entry_to_tree_entry(&repo, entry.inner); if parts.clone().next().is_some() { if !te.mode.is_dir() { return Err(crate::Error::NotFound { name: path.to_string(), }); } current = repo .find_tree(te.id) .map_err(|e| crate::Error::Find(e.to_string()))?; } last = Some(te); } let v = last.ok_or_else(|| crate::Error::NotFound { name: path.to_string(), })?; self.cache_put_rev("tree", "resolve", &key, tip, &v); Ok(v) } fn tree_walk(&self, tree_id: &gix::hash::ObjectId) -> Result, crate::Error> { if let Some(hit) = self.cache_get_oid::>("tree_walk", tree_id) { return Ok(hit); } let mut out = Vec::new(); let mut stack = vec![(*tree_id, String::new())]; let repo = self.repo()?; while let Some((id, prefix)) = stack.pop() { let tree = repo .find_tree(id) .map_err(|e| crate::Error::Find(e.to_string()))?; let refs = tree .decode() .map_err(|e| crate::Error::Find(e.to_string()))?; for e in refs.entries.iter() { let name = e.filename.to_string(); let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; let te = entry_to_tree_entry(&repo, *e); out.push(PathResult { entry: te.clone(), path: path.clone(), }); if te.mode.is_dir() { stack.push((te.id, path)); } } } self.cache_put_oid("tree_walk", tree_id, &out); Ok(out) } fn tree_follow(&self, entry: &TreeEntry) -> Result { if let Some(hit) = self.cache_get_oid::("tree_follow", &entry.id) { return Ok(hit); } let repo = self.repo()?; let v = if !entry.mode.is_link() { EntryFollowResult { symlink_content: None, target_full_path: None, target_entry: Some(entry.clone()), } } else { let blob = repo .find_blob(entry.id) .map_err(|e| crate::Error::Find(e.to_string()))?; let content = String::from_utf8_lossy(&blob.data).to_string(); EntryFollowResult { symlink_content: Some(content), target_full_path: None, target_entry: None, } }; self.cache_put_oid("tree_follow", &entry.id, &v); Ok(v) } fn tree_last_updated(&self, rev: &str) -> Result, crate::Error> { crate::validate::validate_short_name("revision", rev)?; let tip = self.tip_id()?; if let Some(hit) = self.cache_get_rev::>("tree", "last_updated", rev, tip) { return Ok(hit); } let repo = self.repo()?; let commit_id = repo .rev_parse_single(rev.as_bytes()) .map_err(|e| crate::Error::RevParse(e.to_string()))? .detach(); let commit = repo .find_commit(commit_id) .map_err(|e| crate::Error::Find(e.to_string()))?; let decoded = commit .decode() .map_err(|e| crate::Error::Find(e.to_string()))? .into_owned() .map_err(|e| crate::Error::Find(e.to_string()))?; let commit_time = decoded.committer.time.seconds; let tree_id = decoded.tree; // Single pass over history: path -> (last commit time, last commit id) // (newest commit wins, since git log is newest-first). let repo_path = repo.path(); let log = crate::gitcli::run( repo_path, &[ "log", "--format=%H%n%ct", "--name-only", "--no-renames", rev, ], )?; let mut last: std::collections::HashMap = std::collections::HashMap::new(); let mut cur_hash: Option = None; let mut cur_time: Option = None; let mut stage = 0u8; // 0=hash, 1=time, 2=files for line in log.lines() { let line = line.trim(); if line.is_empty() { if stage == 2 { stage = 0; cur_hash = None; cur_time = None; } continue; } match stage { 0 => { if line.len() == 40 && let Ok(id) = gix::hash::ObjectId::from_hex(line.as_bytes()) { cur_hash = Some(id); stage = 1; } } 1 => { if let Ok(ts) = line.parse::() { cur_time = Some(ts); stage = 2; } } _ => { if let (Some(ts), Some(id)) = (cur_time, cur_hash) { last.entry(line.to_string()).or_insert((ts, id)); } } } } let mut out = Vec::new(); walk_last_updated(&repo, tree_id, "", &last, commit_time, commit_id, &mut out)?; let tip = self.tip_id()?; self.cache_put_rev("tree", "last_updated", rev, tip, &out); Ok(out) } } // Recursively walk a tree, emitting each path's last-update info. // For directories, the last-update time/id is the max over its descendants. fn walk_last_updated( repo: &gix::Repository, tree_id: gix::ObjectId, prefix: &str, last: &std::collections::HashMap, commit_time: i64, commit_id: gix::hash::ObjectId, out: &mut Vec, ) -> Result<(i64, gix::hash::ObjectId), crate::Error> { let tree = repo .find_tree(tree_id) .map_err(|e| crate::Error::Find(e.to_string()))?; let refs = tree .decode() .map_err(|e| crate::Error::Find(e.to_string()))?; let mut dir_max_time = commit_time; let mut dir_max_id = commit_id; for e in refs.entries.iter() { let name = e.filename.to_string(); let path = if prefix.is_empty() { name.clone() } else { format!("{prefix}/{name}") }; let mode = EntryMode::from(e.mode); let (this_time, this_id) = last.get(&path).copied().unwrap_or((commit_time, commit_id)); if mode.is_dir() { let (child_max_time, child_max_id) = walk_last_updated( repo, e.oid.to_owned(), &path, last, commit_time, commit_id, out, )?; let (t, id) = if this_time >= child_max_time { (this_time, this_id) } else { (child_max_time, child_max_id) }; out.push(PathLastUpdated { path, object_type: ObjectType::Tree, last_updated: t, commit_id: id, }); if t > dir_max_time { dir_max_time = t; dir_max_id = id; } } else { out.push(PathLastUpdated { path, object_type: entry_to_type(e.mode), last_updated: this_time, commit_id: this_id, }); if this_time > dir_max_time { dir_max_time = this_time; dir_max_id = this_id; } } } Ok((dir_max_time, dir_max_id)) } #[cfg(test)] mod tests { use super::*; use crate::GitRepository; use crate::object::cache::ObjectCache; use cache::CacheBuilder; use storage::config::StorageSettings; use storage::local::LocalStorage; fn block_on(f: F) -> F::Output { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(f) } fn make_repo(dir: &std::path::Path, name: &str) -> GitRepository { let cache_cfg = cache::config::cache::AppCacheSettings::default(); let cache = block_on(CacheBuilder::from_config(cache_cfg).build()) .expect("cache build") .expect("cache enabled"); let cache = std::sync::Arc::new(cache); let oc = ObjectCache { prefix: "test".to_string(), cache, expire_ttl: 300, }; let mut settings = StorageSettings::default(); settings.set_local( storage::config::LocalStorageSettings::builder() .root_dir(dir.join("storage").to_path_buf()) .build(), ); let storage: std::sync::Arc> = std::sync::Arc::new( Box::new(LocalStorage::from_config(&settings).expect("storage")), ); GitRepository::new(dir.join(name), oc, storage) } fn run_stdin(repo_path: &std::path::Path, args: &[&str], input: &[u8]) -> String { let out = duct::cmd("git", args) .env("GIT_DIR", repo_path) .env("GIT_TERMINAL_PROMPT", "0") .stdin_bytes(input) .stdout_capture() .stderr_capture() .run() .unwrap(); String::from_utf8_lossy(&out.stdout).trim().to_string() } #[test] fn tree_last_updated_reports_blob_and_dir_times() { let tmp = std::env::temp_dir().join(format!("git-lastupd-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let repo = make_repo(&tmp, "main"); crate::gitcli::run(&repo.path, &["init", "-q", "--bare", "-b", "main"]).unwrap(); crate::gitcli::run(&repo.path, &["config", "user.email", "t@t"]).unwrap(); crate::gitcli::run(&repo.path, &["config", "user.name", "t"]).unwrap(); let blob_a = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"a\n"); let blob_b = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"b\n"); let tree_d = run_stdin( &repo.path, &["mktree", "-z"], &format!("100644 blob {blob_b}\td.txt\0").into_bytes(), ); let tree_root = run_stdin( &repo.path, &["mktree", "-z"], &format!("100644 blob {blob_a}\ta.txt\040000 tree {tree_d}\td\0").into_bytes(), ); let c1 = run_stdin(&repo.path, &["commit-tree", &tree_root, "-m", "init"], b""); crate::gitcli::run(&repo.path, &["update-ref", "refs/heads/main", &c1]).unwrap(); let res = TreeOps::tree_last_updated(&repo, "main").unwrap(); let by_path: std::collections::HashMap<_, _> = res.iter().map(|p| (p.path.clone(), p)).collect(); assert!(by_path.contains_key("a.txt")); assert!(by_path.contains_key("d/d.txt")); assert!(by_path.contains_key("d")); assert!(by_path["a.txt"].last_updated > 0); assert_eq!(by_path["a.txt"].commit_id.to_string(), c1); assert_eq!(by_path["d/d.txt"].commit_id.to_string(), c1); assert_eq!(by_path["d"].commit_id.to_string(), c1); assert!(by_path["d"].last_updated >= by_path["d/d.txt"].last_updated); let _ = std::fs::remove_dir_all(&tmp); } }