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, WorkspaceScanner,
8};
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13const ONTOLOGY_EXTENSIONS: &[&str] =
14    &["ttl", "rdf", "owl", "jsonld", "json-ld", "nt", "nq", "trig", "obo"];
15
16const CHECKOUT_CACHE_MAX: usize = 4;
17
18#[derive(Debug, Clone)]
19pub struct GitDiffSpec {
20    pub repo_path: PathBuf,
21    pub left_ref: String,
22    pub right_ref: String,
23}
24
25struct CachedCheckout {
26    key: String,
27    root: PathBuf,
28    /// Keeps the temp directory alive while any caller indexes from `root`.
29    _temp: Arc<tempfile::TempDir>,
30}
31
32static CHECKOUT_CACHE: Mutex<Vec<CachedCheckout>> = Mutex::new(Vec::new());
33
34/// Parse `main..feature`, `main...feature` (merge-base), or single ref vs WORKTREE.
35pub fn parse_git_range(spec: &str) -> Result<(String, String)> {
36    let spec = spec.trim();
37    if spec.is_empty() {
38        return Err(DiffError::Message("empty git range".to_string()));
39    }
40    if let Some(idx) = spec.find("...") {
41        let left = spec[..idx].trim();
42        let right = spec[idx + 3..].trim();
43        if left.is_empty() || right.is_empty() {
44            return Err(DiffError::Message("invalid triple-dot git range".to_string()));
45        }
46        return Ok((format!("{left}...{right}"), "TRIPLE_DOT".to_string()));
47    }
48    if let Some((left, right)) = spec.split_once("..") {
49        let left = left.trim();
50        let right = right.trim();
51        if left.is_empty() || right.is_empty() {
52            return Err(DiffError::Message("invalid two-dot git range".to_string()));
53        }
54        return Ok((left.to_string(), right.to_string()));
55    }
56    Ok((spec.to_string(), "WORKTREE".to_string()))
57}
58
59pub fn discover_repo_root(paths: &[PathBuf]) -> Option<PathBuf> {
60    discover_git_repo_root(paths)
61}
62
63pub fn diff_git_refs(repo_path: &Path, left_ref: &str, right_ref: &str) -> Result<DiffResult> {
64    Ok(diff_git_refs_with_catalogs(repo_path, left_ref, right_ref)?.0)
65}
66
67/// Like [`diff_git_refs`], but also returns the left and right catalogs used for the diff.
68pub fn diff_git_refs_with_catalogs(
69    repo_path: &Path,
70    left_ref: &str,
71    right_ref: &str,
72) -> Result<(DiffResult, OntologyCatalog, OntologyCatalog)> {
73    if right_ref == "TRIPLE_DOT" || left_ref.contains("...") {
74        let (left, right) = parse_merge_base_refs(left_ref)?;
75        let repo = Repository::open(repo_path)
76            .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
77        let left_obj = repo
78            .revparse_single(&left)
79            .map_err(|e| DiffError::Message(format!("git revparse {left}: {e}")))?;
80        let right_obj = repo
81            .revparse_single(&right)
82            .map_err(|e| DiffError::Message(format!("git revparse {right}: {e}")))?;
83        let merge_base = repo
84            .merge_base(left_obj.id(), right_obj.id())
85            .map_err(|e| DiffError::Message(format!("git merge-base: {e}")))?;
86        let base = checkout_catalog_at_oid(&repo, merge_base)?;
87        let head = catalog_at_git_ref(repo_path, &right)?;
88        let diff = diff_catalogs(&base, &head);
89        return Ok((diff, base, head));
90    }
91    let left = catalog_at_git_ref(repo_path, left_ref)?;
92    let right = if right_ref == "WORKTREE" || right_ref.is_empty() {
93        catalog_at_worktree(repo_path)?
94    } else {
95        catalog_at_git_ref(repo_path, right_ref)?
96    };
97    Ok((diff_catalogs(&left, &right), left, right))
98}
99
100fn parse_merge_base_refs(spec: &str) -> Result<(String, String)> {
101    let idx = spec
102        .find("...")
103        .ok_or_else(|| DiffError::Message("expected triple-dot range".to_string()))?;
104    let left = spec[..idx].trim().to_string();
105    let right = spec[idx + 3..].trim().to_string();
106    if left.is_empty() || right.is_empty() {
107        return Err(DiffError::Message("invalid triple-dot git range".to_string()));
108    }
109    Ok((left, right))
110}
111
112pub fn catalog_at_git_ref(repo_path: &Path, git_ref: &str) -> Result<OntologyCatalog> {
113    let repo = Repository::open(repo_path)
114        .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
115    checkout_catalog(&repo, git_ref)
116}
117
118/// Build catalog from ontology files in the working tree: git-indexed paths plus
119/// untracked workspace files (respecting path jail and scan limits).
120pub fn catalog_at_worktree(repo_path: &Path) -> Result<OntologyCatalog> {
121    let paths = worktree_ontology_paths(repo_path)?;
122    let mut builder = IndexBuilder::new().workspace(repo_path);
123    if !paths.is_empty() {
124        builder = builder.only_paths(paths);
125    }
126    builder.build().map_err(DiffError::from)
127}
128
129fn worktree_ontology_paths(repo_path: &Path) -> Result<Vec<PathBuf>> {
130    let mut seen = HashSet::new();
131    let mut paths = Vec::new();
132
133    for path in list_git_tracked_ontology_paths(repo_path)? {
134        push_worktree_path(&mut seen, &mut paths, path)?;
135    }
136
137    let scanned =
138        WorkspaceScanner::new(repo_path).scan().map_err(|e| DiffError::Message(e.to_string()))?;
139    for file in scanned {
140        push_worktree_path(&mut seen, &mut paths, file.path)?;
141    }
142
143    Ok(paths)
144}
145
146fn push_worktree_path(
147    seen: &mut HashSet<PathBuf>,
148    paths: &mut Vec<PathBuf>,
149    path: PathBuf,
150) -> Result<()> {
151    let key = path.canonicalize().unwrap_or_else(|_| path.clone());
152    if seen.insert(key) {
153        paths.push(path);
154        if paths.len() > MAX_SCAN_FILES {
155            return Err(DiffError::Message(format!(
156                "worktree ontology files exceed maximum of {MAX_SCAN_FILES}"
157            )));
158        }
159    }
160    Ok(())
161}
162
163fn list_git_tracked_ontology_paths(repo_path: &Path) -> Result<Vec<PathBuf>> {
164    let repo = Repository::open(repo_path)
165        .map_err(|e| DiffError::Message(format!("git open failed: {e}")))?;
166    let mut paths = Vec::new();
167    let mut index = repo.index().map_err(|e| DiffError::Message(format!("git index: {e}")))?;
168    index.read(true).map_err(|e| DiffError::Message(format!("git index read: {e}")))?;
169    for entry in index.iter() {
170        let path_str =
171            std::str::from_utf8(&entry.path).map_err(|e| DiffError::Message(e.to_string()))?;
172        if !is_ontology_file(path_str) {
173            continue;
174        }
175        let full = repo_path.join(path_str);
176        if path_has_parent_escape(full.as_path()) {
177            continue;
178        }
179        paths.push(full);
180        if paths.len() > MAX_SCAN_FILES {
181            return Err(DiffError::Message(format!(
182                "git tracked files exceed maximum of {MAX_SCAN_FILES}"
183            )));
184        }
185    }
186    Ok(paths)
187}
188
189fn checkout_catalog(repo: &Repository, git_ref: &str) -> Result<OntologyCatalog> {
190    let obj = repo
191        .revparse_single(git_ref)
192        .map_err(|e| DiffError::Message(format!("git revparse {git_ref}: {e}")))?;
193    let commit =
194        obj.peel_to_commit().map_err(|e| DiffError::Message(format!("git peel commit: {e}")))?;
195    checkout_catalog_at_oid(repo, commit.id())
196}
197
198fn checkout_catalog_at_oid(repo: &Repository, oid: Oid) -> Result<OntologyCatalog> {
199    let repo_path =
200        repo.path().parent().map(|p| p.to_path_buf()).unwrap_or_else(|| PathBuf::from("."));
201    let key = format!("{}:{oid}", repo_path.display());
202    if let Some((root, _keepalive)) = cache_get(&key) {
203        return IndexBuilder::new().workspace(&root).build().map_err(DiffError::from);
204    }
205
206    let commit =
207        repo.find_commit(oid).map_err(|e| DiffError::Message(format!("git find commit: {e}")))?;
208    let tree = commit.tree().map_err(|e| DiffError::Message(format!("git tree: {e}")))?;
209
210    let tmp = tempfile::Builder::new()
211        .prefix("ontocore-diff-")
212        .tempdir()
213        .map_err(|e| DiffError::Message(e.to_string()))?;
214    let temp = Arc::new(tmp);
215    let root = temp.path().to_path_buf();
216    let mut walk_error: Option<String> = None;
217    let mut file_count = 0usize;
218
219    tree.walk(TreeWalkMode::PreOrder, |dir, entry| {
220        if walk_error.is_some() {
221            return 1;
222        }
223        if entry.kind() != Some(ObjectType::Blob) {
224            return 0;
225        }
226        let name = entry.name().unwrap_or("");
227        if !is_ontology_file(name) {
228            return 0;
229        }
230        file_count += 1;
231        if file_count > MAX_SCAN_FILES {
232            walk_error =
233                Some(format!("git tree exceeds maximum of {MAX_SCAN_FILES} ontology files"));
234            return 1;
235        }
236        let rel = if dir.is_empty() { name.to_string() } else { format!("{dir}{name}") };
237        let out_path = match ensure_extract_path_within(&root, &rel) {
238            Ok(p) => p,
239            Err(e) => {
240                walk_error = Some(e);
241                return 1;
242            }
243        };
244        let blob = match repo.find_blob(entry.id()) {
245            Ok(b) => b,
246            Err(e) => {
247                walk_error = Some(format!("find blob: {e}"));
248                return 1;
249            }
250        };
251        let size = blob.content().len() as u64;
252        if size > MAX_FILE_BYTES {
253            walk_error =
254                Some(format!("blob exceeds size limit ({size} bytes > {MAX_FILE_BYTES}): {rel}",));
255            return 1;
256        }
257        if let Some(parent) = out_path.parent() {
258            if let Err(e) = std::fs::create_dir_all(parent) {
259                walk_error = Some(format!("create dir {}: {e}", parent.display()));
260                return 1;
261            }
262        }
263        if let Err(e) = std::fs::write(&out_path, blob.content()) {
264            walk_error = Some(format!("write {}: {e}", out_path.display()));
265            return 1;
266        }
267        0
268    })
269    .map_err(|e| DiffError::Message(format!("git tree walk: {e}")))?;
270
271    if let Some(msg) = walk_error {
272        return Err(DiffError::Message(msg));
273    }
274
275    cache_insert(key, root.clone(), temp);
276    IndexBuilder::new().workspace(&root).build().map_err(DiffError::from)
277}
278
279fn cache_get(key: &str) -> Option<(PathBuf, Arc<tempfile::TempDir>)> {
280    let guard = CHECKOUT_CACHE.lock().ok()?;
281    guard.iter().find(|e| e.key == key).map(|e| (e.root.clone(), Arc::clone(&e._temp)))
282}
283
284fn cache_insert(key: String, root: PathBuf, temp: Arc<tempfile::TempDir>) {
285    if let Ok(mut guard) = CHECKOUT_CACHE.lock() {
286        if guard.iter().any(|e| e.key == key) {
287            return;
288        }
289        while guard.len() >= CHECKOUT_CACHE_MAX {
290            guard.remove(0);
291        }
292        guard.push(CachedCheckout { key, root, _temp: temp });
293    }
294}
295
296fn is_ontology_file(name: &str) -> bool {
297    Path::new(name)
298        .extension()
299        .and_then(|e| e.to_str())
300        .is_some_and(|ext| ONTOLOGY_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str()))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use git2::{Repository, Signature};
307
308    #[test]
309    fn parse_triple_dot_range() {
310        let (left, right) = parse_git_range("main...feature").unwrap();
311        assert_eq!(left, "main...feature");
312        assert_eq!(right, "TRIPLE_DOT");
313    }
314
315    #[test]
316    fn parse_two_dot_range() {
317        let (left, right) = parse_git_range("main..feature").unwrap();
318        assert_eq!(left, "main");
319        assert_eq!(right, "feature");
320    }
321
322    #[test]
323    fn reject_escape_path() {
324        let dir = tempfile::tempdir().unwrap();
325        assert!(ensure_extract_path_within(dir.path(), "../outside.ttl").is_err());
326    }
327
328    #[test]
329    fn worktree_catalog_includes_untracked_ontology_files() {
330        let dir = tempfile::tempdir().unwrap();
331        let root = dir.path();
332        let tracked_ttl = concat!(
333            "@prefix ex: <http://example.org#> .\n",
334            "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
335            "ex:Tracked a owl:Class .\n"
336        );
337        let untracked_ttl = concat!(
338            "@prefix ex: <http://example.org#> .\n",
339            "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
340            "ex:Untracked a owl:Class .\n"
341        );
342        std::fs::write(root.join("tracked.ttl"), tracked_ttl).unwrap();
343
344        let repo = Repository::init(root).expect("git init");
345        let mut index = repo.index().expect("index");
346        index.add_path(Path::new("tracked.ttl")).expect("index add tracked");
347        index.write().expect("index write");
348        let tree_id = index.write_tree().expect("write tree");
349        let tree = repo.find_tree(tree_id).expect("find tree");
350        let sig = Signature::now("OntoCode Test", "test@example.com").expect("signature");
351        repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).expect("commit");
352
353        std::fs::write(root.join("new.ttl"), untracked_ttl).unwrap();
354
355        let catalog = catalog_at_worktree(root).expect("worktree catalog");
356        let iris: Vec<_> = catalog.data().entities.iter().map(|e| e.iri.as_str()).collect();
357        assert!(
358            iris.iter().any(|iri| iri.contains("Tracked")),
359            "expected tracked entity, got {iris:?}"
360        );
361        assert!(
362            iris.iter().any(|iri| iri.contains("Untracked")),
363            "expected untracked entity in WORKTREE catalog, got {iris:?}"
364        );
365    }
366}