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 _temp: Arc<tempfile::TempDir>,
30}
31
32static CHECKOUT_CACHE: Mutex<Vec<CachedCheckout>> = Mutex::new(Vec::new());
33
34pub 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
67pub 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
118pub 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_two_dot_range_splits_refs() {
310 let (left, right) = parse_git_range("main..feature").unwrap();
311 assert_eq!(left, "main");
312 assert_eq!(right, "feature");
313 }
314
315 #[test]
316 fn parse_single_ref_defaults_right_to_worktree() {
317 let (left, right) = parse_git_range("HEAD").unwrap();
318 assert_eq!(left, "HEAD");
319 assert_eq!(right, "WORKTREE");
320 }
321
322 #[test]
323 fn triple_dot_diff_compares_merge_base_to_feature() {
324 let dir = tempfile::tempdir().unwrap();
330 let root = dir.path();
331 let ttl = |name: &str| {
332 format!(
333 "@prefix ex: <http://example.org#> .\n\
334 @prefix owl: <http://www.w3.org/2002/07/owl#> .\n\
335 ex:{name} a owl:Class .\n"
336 )
337 };
338
339 let repo = Repository::init(root).expect("git init");
340 let sig = Signature::now("OntoCode Test", "test@example.com").expect("signature");
341
342 std::fs::write(root.join("onto.ttl"), ttl("Base")).unwrap();
343 let mut index = repo.index().expect("index");
344 index.add_path(Path::new("onto.ttl")).unwrap();
345 index.write().unwrap();
346 let tree_id = index.write_tree().unwrap();
347 let tree = repo.find_tree(tree_id).unwrap();
348 let base_oid =
349 repo.commit(Some("HEAD"), &sig, &sig, "base", &tree, &[]).expect("base commit");
350 let base_commit = repo.find_commit(base_oid).unwrap();
351
352 std::fs::write(root.join("onto.ttl"), ttl("MainOnly")).unwrap();
354 let mut index = repo.index().unwrap();
355 index.add_path(Path::new("onto.ttl")).unwrap();
356 index.write().unwrap();
357 let tree_id = index.write_tree().unwrap();
358 let tree = repo.find_tree(tree_id).unwrap();
359 repo.commit(Some("HEAD"), &sig, &sig, "main tip", &tree, &[&base_commit])
360 .expect("main commit");
361 repo.branch("main", &repo.head().unwrap().peel_to_commit().unwrap(), true).ok();
362
363 repo.set_head_detached(base_oid).unwrap();
365 repo.checkout_head(Some(git2::build::CheckoutBuilder::new().force())).unwrap();
366 std::fs::write(root.join("onto.ttl"), ttl("FeatureOnly")).unwrap();
367 let mut index = repo.index().unwrap();
368 index.add_path(Path::new("onto.ttl")).unwrap();
369 index.write().unwrap();
370 let tree_id = index.write_tree().unwrap();
371 let tree = repo.find_tree(tree_id).unwrap();
372 let feature_oid = repo
373 .commit(Some("refs/heads/feature"), &sig, &sig, "feature tip", &tree, &[&base_commit])
374 .expect("feature commit");
375 let _ = feature_oid;
376
377 let (left, right) = parse_git_range("main...feature").unwrap();
378 let (diff, left_cat, right_cat) =
379 diff_git_refs_with_catalogs(root, &left, &right).expect("triple-dot diff");
380
381 let left_iris: Vec<_> = left_cat.data().entities.iter().map(|e| e.iri.as_str()).collect();
382 let right_iris: Vec<_> = right_cat.data().entities.iter().map(|e| e.iri.as_str()).collect();
383 assert!(
384 left_iris.iter().any(|i| i.contains("Base")),
385 "merge-base catalog should contain Base, got {left_iris:?}"
386 );
387 assert!(
388 !left_iris.iter().any(|i| i.contains("MainOnly")),
389 "merge-base must not be main tip (MainOnly): {left_iris:?}"
390 );
391 assert!(
392 right_iris.iter().any(|i| i.contains("FeatureOnly")),
393 "right catalog should be feature tip: {right_iris:?}"
394 );
395 assert!(
396 diff.entity_changes.iter().any(|c| c.iri.contains("FeatureOnly")),
397 "diff should mention FeatureOnly: {:?}",
398 diff.entity_changes
399 );
400 }
401
402 #[test]
403 fn reject_escape_path() {
404 let dir = tempfile::tempdir().unwrap();
405 let err = ensure_extract_path_within(dir.path(), "../outside.ttl").expect_err("escape");
406 assert!(
407 err.contains("..") || err.contains("escapes") || err.contains("outside"),
408 "unexpected error: {err}"
409 );
410 }
411
412 #[test]
413 fn worktree_catalog_includes_untracked_ontology_files() {
414 let dir = tempfile::tempdir().unwrap();
415 let root = dir.path();
416 let tracked_ttl = concat!(
417 "@prefix ex: <http://example.org#> .\n",
418 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
419 "ex:Tracked a owl:Class .\n"
420 );
421 let untracked_ttl = concat!(
422 "@prefix ex: <http://example.org#> .\n",
423 "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n",
424 "ex:Untracked a owl:Class .\n"
425 );
426 std::fs::write(root.join("tracked.ttl"), tracked_ttl).unwrap();
427
428 let repo = Repository::init(root).expect("git init");
429 let mut index = repo.index().expect("index");
430 index.add_path(Path::new("tracked.ttl")).expect("index add tracked");
431 index.write().expect("index write");
432 let tree_id = index.write_tree().expect("write tree");
433 let tree = repo.find_tree(tree_id).expect("find tree");
434 let sig = Signature::now("OntoCode Test", "test@example.com").expect("signature");
435 repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[]).expect("commit");
436
437 std::fs::write(root.join("new.ttl"), untracked_ttl).unwrap();
438
439 let catalog = catalog_at_worktree(root).expect("worktree catalog");
440 let iris: Vec<_> = catalog.data().entities.iter().map(|e| e.iri.as_str()).collect();
441 assert!(
442 iris.iter().any(|iri| iri.contains("Tracked")),
443 "expected tracked entity, got {iris:?}"
444 );
445 assert!(
446 iris.iter().any(|iri| iri.contains("Untracked")),
447 "expected untracked entity in WORKTREE catalog, got {iris:?}"
448 );
449 }
450}