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 _temp: Arc<tempfile::TempDir>,
29}
30
31static CHECKOUT_CACHE: Mutex<Vec<CachedCheckout>> = Mutex::new(Vec::new());
32
33pub 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
66pub 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
117pub 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}