Skip to main content

ontocore_diff/
git.rs

1use crate::compare::{diff_catalogs, DiffError, Result};
2use crate::model::DiffResult;
3use git2::{ObjectType, Oid, Repository, TreeWalkMode};
4use ontocore_catalog::{IndexBuilder, OntologyCatalog};
5use ontocore_core::{
6    discover_git_repo_root, ensure_extract_path_within, limits::MAX_FILE_BYTES,
7    limits::MAX_SCAN_FILES, path_jail::path_has_parent_escape,
8};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12const ONTOLOGY_EXTENSIONS: &[&str] =
13    &["ttl", "rdf", "owl", "jsonld", "json-ld", "nt", "nq", "trig", "obo"];
14
15const CHECKOUT_CACHE_MAX: usize = 4;
16
17#[derive(Debug, Clone)]
18pub struct GitDiffSpec {
19    pub repo_path: PathBuf,
20    pub left_ref: String,
21    pub right_ref: String,
22}
23
24struct CachedCheckout {
25    key: String,
26    root: PathBuf,
27    /// Keeps the temp directory alive while any caller indexes from `root`.
28    _temp: Arc<tempfile::TempDir>,
29}
30
31static CHECKOUT_CACHE: Mutex<Vec<CachedCheckout>> = Mutex::new(Vec::new());
32
33/// Parse `main..feature`, `main...feature` (merge-base), or single ref vs WORKTREE.
34pub fn parse_git_range(spec: &str) -> Result<(String, String)> {
35    let spec = spec.trim();
36    if spec.is_empty() {
37        return Err(DiffError::Message("empty git range".to_string()));
38    }
39    if let Some(idx) = spec.find("...") {
40        let left = spec[..idx].trim();
41        let right = spec[idx + 3..].trim();
42        if left.is_empty() || right.is_empty() {
43            return Err(DiffError::Message("invalid triple-dot git range".to_string()));
44        }
45        return Ok((format!("{left}...{right}"), "TRIPLE_DOT".to_string()));
46    }
47    if let Some((left, right)) = spec.split_once("..") {
48        let left = left.trim();
49        let right = right.trim();
50        if left.is_empty() || right.is_empty() {
51            return Err(DiffError::Message("invalid two-dot git range".to_string()));
52        }
53        return Ok((left.to_string(), right.to_string()));
54    }
55    Ok((spec.to_string(), "WORKTREE".to_string()))
56}
57
58pub fn discover_repo_root(paths: &[PathBuf]) -> Option<PathBuf> {
59    discover_git_repo_root(paths)
60}
61
62pub fn diff_git_refs(repo_path: &Path, left_ref: &str, right_ref: &str) -> Result<DiffResult> {
63    Ok(diff_git_refs_with_catalogs(repo_path, left_ref, right_ref)?.0)
64}
65
66/// Like [`diff_git_refs`], but also returns the left and right catalogs used for the diff.
67pub fn diff_git_refs_with_catalogs(
68    repo_path: &Path,
69    left_ref: &str,
70    right_ref: &str,
71) -> Result<(DiffResult, OntologyCatalog, OntologyCatalog)> {
72    if right_ref == "TRIPLE_DOT" || left_ref.contains("...") {
73        let (left, right) = parse_merge_base_refs(left_ref)?;
74        let repo = Repository::open(repo_path)
75            .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
76        let left_obj = repo
77            .revparse_single(&left)
78            .map_err(|e| DiffError::Message(format!("git revparse {left}: {e}")))?;
79        let right_obj = repo
80            .revparse_single(&right)
81            .map_err(|e| DiffError::Message(format!("git revparse {right}: {e}")))?;
82        let merge_base = repo
83            .merge_base(left_obj.id(), right_obj.id())
84            .map_err(|e| DiffError::Message(format!("git merge-base: {e}")))?;
85        let base = checkout_catalog_at_oid(&repo, merge_base)?;
86        let head = catalog_at_git_ref(repo_path, &right)?;
87        let diff = diff_catalogs(&base, &head);
88        return Ok((diff, base, head));
89    }
90    let left = catalog_at_git_ref(repo_path, left_ref)?;
91    let right = if right_ref == "WORKTREE" || right_ref.is_empty() {
92        catalog_at_worktree(repo_path)?
93    } else {
94        catalog_at_git_ref(repo_path, right_ref)?
95    };
96    Ok((diff_catalogs(&left, &right), left, right))
97}
98
99fn parse_merge_base_refs(spec: &str) -> Result<(String, String)> {
100    let idx = spec
101        .find("...")
102        .ok_or_else(|| DiffError::Message("expected triple-dot range".to_string()))?;
103    let left = spec[..idx].trim().to_string();
104    let right = spec[idx + 3..].trim().to_string();
105    if left.is_empty() || right.is_empty() {
106        return Err(DiffError::Message("invalid triple-dot git range".to_string()));
107    }
108    Ok((left, right))
109}
110
111pub fn catalog_at_git_ref(repo_path: &Path, git_ref: &str) -> Result<OntologyCatalog> {
112    let repo = Repository::open(repo_path)
113        .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
114    checkout_catalog(&repo, git_ref)
115}
116
117/// Build catalog from git-tracked ontology files in the working tree (aligned with git-side diffs).
118pub fn catalog_at_worktree(repo_path: &Path) -> Result<OntologyCatalog> {
119    let tracked = list_git_tracked_ontology_paths(repo_path)?;
120    let mut builder = IndexBuilder::new().workspace(repo_path);
121    if !tracked.is_empty() {
122        builder = builder.only_paths(tracked);
123    }
124    builder.build().map_err(DiffError::from)
125}
126
127fn list_git_tracked_ontology_paths(repo_path: &Path) -> Result<Vec<PathBuf>> {
128    let repo = Repository::open(repo_path)
129        .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
130    let mut paths = Vec::new();
131    let mut index = repo.index().map_err(|e| DiffError::Message(format!("git index: {e}")))?;
132    index.read(true).map_err(|e| DiffError::Message(format!("git index read: {e}")))?;
133    for entry in index.iter() {
134        let path_str =
135            std::str::from_utf8(&entry.path).map_err(|e| DiffError::Message(e.to_string()))?;
136        if !is_ontology_file(path_str) {
137            continue;
138        }
139        let full = repo_path.join(path_str);
140        if path_has_parent_escape(full.as_path()) {
141            continue;
142        }
143        paths.push(full);
144        if paths.len() > MAX_SCAN_FILES {
145            return Err(DiffError::Message(format!(
146                "git tracked files exceed maximum of {MAX_SCAN_FILES}"
147            )));
148        }
149    }
150    Ok(paths)
151}
152
153fn checkout_catalog(repo: &Repository, git_ref: &str) -> Result<OntologyCatalog> {
154    let obj = repo
155        .revparse_single(git_ref)
156        .map_err(|e| DiffError::Message(format!("git revparse {git_ref}: {e}")))?;
157    let commit =
158        obj.peel_to_commit().map_err(|e| DiffError::Message(format!("git peel commit: {e}")))?;
159    checkout_catalog_at_oid(repo, commit.id())
160}
161
162fn checkout_catalog_at_oid(repo: &Repository, oid: Oid) -> Result<OntologyCatalog> {
163    let repo_path =
164        repo.path().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
165    let key = format!("{}:{oid}", repo_path.display());
166    if let Some((root, _keepalive)) = cache_get(&key) {
167        return IndexBuilder::new().workspace(&root).build().map_err(DiffError::from);
168    }
169
170    let commit =
171        repo.find_commit(oid).map_err(|e| DiffError::Message(format!("git find commit: {e}")))?;
172    let tree = commit.tree().map_err(|e| DiffError::Message(format!("git tree: {e}")))?;
173
174    let tmp = tempfile::Builder::new()
175        .prefix("ontocore-diff-")
176        .tempdir()
177        .map_err(|e| DiffError::Message(e.to_string()))?;
178    let temp = Arc::new(tmp);
179    let root = temp.path().to_path_buf();
180    let mut walk_error: Option<String> = None;
181    let mut file_count = 0usize;
182
183    tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
184        if walk_error.is_some() {
185            return 1;
186        }
187        if entry.kind() != Some(ObjectType::Blob) {
188            return 0;
189        }
190        let name = entry.name().unwrap_or("");
191        if !is_ontology_file(name) {
192            return 0;
193        }
194        file_count += 1;
195        if file_count > MAX_SCAN_FILES {
196            walk_error =
197                Some(format!("git tree exceeds maximum of {MAX_SCAN_FILES} ontology files"));
198            return 1;
199        }
200        let rel = if dir.is_empty() { name.to_string() } else { format!("{dir}{name}") };
201        let out_path = match ensure_extract_path_within(&root, &rel) {
202            Ok(p) => p,
203            Err(e) => {
204                walk_error = Some(e);
205                return 1;
206            }
207        };
208        let blob = match repo.find_blob(entry.id()) {
209            Ok(b) => b,
210            Err(e) => {
211                walk_error = Some(format!("find blob: {e}"));
212                return 1;
213            }
214        };
215        let size = blob.content().len() as u64;
216        if size > MAX_FILE_BYTES {
217            walk_error =
218                Some(format!("blob exceeds size limit ({size} bytes > {MAX_FILE_BYTES}): {rel}",));
219            return 1;
220        }
221        if let Some(parent) = out_path.parent() {
222            if let Err(e) = std::fs::create_dir_all(parent) {
223                walk_error = Some(format!("create dir {}: {e}", parent.display()));
224                return 1;
225            }
226        }
227        if let Err(e) = std::fs::write(&out_path, blob.content()) {
228            walk_error = Some(format!("write {}: {e}", out_path.display()));
229            return 1;
230        }
231        0
232    })
233    .map_err(|e| DiffError::Message(format!("git tree walk: {e}")))?;
234
235    if let Some(msg) = walk_error {
236        return Err(DiffError::Message(msg));
237    }
238
239    cache_insert(key, root.clone(), temp);
240    IndexBuilder::new().workspace(&root).build().map_err(DiffError::from)
241}
242
243fn cache_get(key: &str) -> Option<(PathBuf, Arc<tempfile::TempDir>)> {
244    let guard = CHECKOUT_CACHE.lock().ok()?;
245    guard.iter().find(|e| e.key == key).map(|e| (e.root.clone(), Arc::clone(&e._temp)))
246}
247
248fn cache_insert(key: String, root: PathBuf, temp: Arc<tempfile::TempDir>) {
249    if let Ok(mut guard) = CHECKOUT_CACHE.lock() {
250        if guard.iter().any(|e| e.key == key) {
251            return;
252        }
253        while guard.len() >= CHECKOUT_CACHE_MAX {
254            guard.remove(0);
255        }
256        guard.push(CachedCheckout { key, root, _temp: temp });
257    }
258}
259
260fn is_ontology_file(name: &str) -> bool {
261    Path::new(name)
262        .extension()
263        .and_then(|e| e.to_str())
264        .is_some_and(|ext| ONTOLOGY_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn parse_triple_dot_range() {
273        let (left, right) = parse_git_range("main...feature").unwrap();
274        assert_eq!(left, "main...feature");
275        assert_eq!(right, "TRIPLE_DOT");
276    }
277
278    #[test]
279    fn parse_two_dot_range() {
280        let (left, right) = parse_git_range("main..feature").unwrap();
281        assert_eq!(left, "main");
282        assert_eq!(right, "feature");
283    }
284
285    #[test]
286    fn reject_escape_path() {
287        let dir = tempfile::tempdir().unwrap();
288        assert!(ensure_extract_path_within(dir.path(), "../outside.ttl").is_err());
289    }
290}