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_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}