1use std::path::{Path, PathBuf};
4
5use crate::error::{Error, Result};
6use crate::git::sha::ShaResolver;
7use crate::interner::StringInterner;
8use crate::types::{ChangeType, ChangedFile, DiffResult};
9
10pub struct GitRepository {
16 path: PathBuf,
17}
18
19impl GitRepository {
20 pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
22 let path = path.as_ref().to_path_buf();
23 let _repo = git2::Repository::open(&path)?;
25
26 Ok(Self { path })
27 }
28
29 pub fn discover<P: AsRef<Path>>(path: P) -> Result<Self> {
31 let git_path = git2::Repository::discover_path(path.as_ref(), &[] as &[&std::ffi::OsStr])?;
33 let _repo = git2::Repository::open(&git_path)?;
35
36 Ok(Self { path: git_path })
37 }
38
39 pub fn path(&self) -> &Path {
41 &self.path
42 }
43
44 pub fn workdir(&self) -> &Path {
49 if self.path.ends_with(".git") {
50 self.path.parent().unwrap_or(&self.path)
51 } else {
52 &self.path
53 }
54 }
55
56 fn get_repo(&self) -> Result<git2::Repository> {
58 Ok(git2::Repository::open(&self.path)?)
61 }
62
63 fn sha_to_tree<'r>(
69 repo: &'r git2::Repository,
70 oid: git2::Oid,
71 sha: &str,
72 ) -> Result<git2::Tree<'r>> {
73 if sha == ShaResolver::empty_tree_sha() {
74 Ok(repo.find_tree(oid).or_else(|_| {
76 let empty_oid = repo
78 .treebuilder(None)
79 .map_err(|e| Error::Git(format!("Failed to create empty tree: {}", e)))?
80 .write()
81 .map_err(|e| Error::Git(format!("Failed to write empty tree: {}", e)))?;
82 repo.find_tree(empty_oid)
83 .map_err(|e| Error::Git(format!("Failed to find empty tree: {}", e)))
84 })?)
85 } else {
86 Ok(repo.find_commit(oid)?.tree()?)
87 }
88 }
89
90 pub async fn ensure_depth(&self, depth: u32) -> Result<()> {
92 if depth == 0 {
93 return Ok(());
94 }
95
96 let path = self.path.clone();
97
98 tokio::task::spawn_blocking(move || {
99 let _repo = git2::Repository::open(&path)?;
101
102 let output = std::process::Command::new("git")
104 .args(["rev-parse", "--is-shallow-repository"])
105 .current_dir(&path)
106 .output()
107 .map_err(|e| Error::Git(format!("Failed to check shallow status: {}", e)))?;
108
109 let is_shallow = String::from_utf8_lossy(&output.stdout).trim() == "true";
110
111 if is_shallow {
112 let _output = std::process::Command::new("git")
114 .args(["fetch", &format!("--depth={}", depth)])
115 .current_dir(&path)
116 .output()
117 .map_err(|e| Error::Git(format!("Failed to deepen repository: {}", e)))?;
118 }
119
120 Ok::<_, Error>(())
121 })
122 .await
123 .map_err(|e| Error::Runtime(format!("Task join error: {}", e)))?
124 }
125
126 pub async fn ensure_depth_with_retry(
131 &self,
132 base_sha: &str,
133 head_sha: &str,
134 initial_depth: u32,
135 max_retries: u32,
136 ) -> Result<()> {
137 let path = self.path.clone();
138 let base = base_sha.to_string();
139 let head = head_sha.to_string();
140
141 tokio::task::spawn_blocking(move || {
142 let repo = git2::Repository::open(&path)?;
143
144 let output = std::process::Command::new("git")
146 .args(["rev-parse", "--is-shallow-repository"])
147 .current_dir(&path)
148 .output()
149 .map_err(|e| Error::Git(format!("Failed to check shallow status: {}", e)))?;
150
151 let is_shallow = String::from_utf8_lossy(&output.stdout).trim() == "true";
152
153 if !is_shallow {
154 return Ok(()); }
156
157 let base_oid = git2::Oid::from_str(&base)
159 .map_err(|e| Error::Git(format!("Invalid base SHA: {}", e)))?;
160 let head_oid = git2::Oid::from_str(&head)
161 .map_err(|e| Error::Git(format!("Invalid head SHA: {}", e)))?;
162
163 let mut depth = initial_depth.max(1);
164
165 for attempt in 0..=max_retries {
166 if repo.merge_base(base_oid, head_oid).is_ok() {
167 return Ok(()); }
169
170 if attempt == max_retries {
171 return Err(Error::ShallowExhausted(format!(
172 "Could not find merge base between {} and {} after {} retries (depth={}). \
173 Consider using a deeper clone.",
174 base, head, max_retries, depth
175 )));
176 }
177
178 let deepen_output = std::process::Command::new("git")
180 .args(["fetch", &format!("--deepen={}", depth)])
181 .current_dir(&path)
182 .output()
183 .map_err(|e| Error::Git(format!("Failed to deepen repository: {}", e)))?;
184
185 if !deepen_output.status.success() {
186 let stderr = String::from_utf8_lossy(&deepen_output.stderr);
187 return Err(Error::Git(format!(
188 "git fetch --deepen={} failed: {}",
189 depth, stderr
190 )));
191 }
192
193 depth *= 2; }
195
196 Ok(())
197 })
198 .await
199 .map_err(|e| Error::Runtime(format!("Task join error: {}", e)))?
200 }
201
202 pub fn is_symlink_in_tree(&self, sha: &str, file_path: &str) -> Result<bool> {
207 let repo = self.get_repo()?;
208 let oid = git2::Oid::from_str(sha)
209 .map_err(|e| Error::Git(format!("Invalid SHA '{}': {}", sha, e)))?;
210 let commit = repo.find_commit(oid)?;
211 let tree = commit.tree()?;
212
213 match tree.get_path(std::path::Path::new(file_path)) {
214 Ok(entry) => {
215 Ok(entry.filemode() == 0o120000)
217 }
218 Err(_) => Ok(false), }
220 }
221
222 pub fn diff_sync(
224 &self,
225 base_sha: &str,
226 head_sha: &str,
227 interner: &StringInterner,
228 diff_filter: &str,
229 ) -> Result<DiffResult> {
230 let repo = self.get_repo()?;
231
232 let base_oid = git2::Oid::from_str(base_sha)
234 .map_err(|e| Error::Git(format!("Invalid base SHA '{}': {}", base_sha, e)))?;
235 let head_oid = git2::Oid::from_str(head_sha)
236 .map_err(|e| Error::Git(format!("Invalid head SHA '{}': {}", head_sha, e)))?;
237
238 let base_tree = Self::sha_to_tree(&repo, base_oid, base_sha)?;
240 let head_tree = Self::sha_to_tree(&repo, head_oid, head_sha)?;
241
242 let mut opts = git2::DiffOptions::new();
244 opts.ignore_submodules(true);
245
246 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
248
249 let mut result = DiffResult::default();
250
251 diff.foreach(
253 &mut |delta, _progress| {
254 let status = delta.status();
255
256 use crate::types::ChangeType;
258 let change_type = match status {
259 git2::Delta::Added => ChangeType::Added,
260 git2::Delta::Deleted => ChangeType::Deleted,
261 git2::Delta::Modified => ChangeType::Modified,
262 git2::Delta::Renamed => ChangeType::Renamed,
263 git2::Delta::Copied => ChangeType::Copied,
264 git2::Delta::Typechange => ChangeType::TypeChanged,
265 git2::Delta::Conflicted => ChangeType::Unmerged,
266 _ => ChangeType::Unknown,
267 };
268
269 let type_char = change_type
271 .as_str()
272 .chars()
273 .next()
274 .unwrap_or('X')
275 .to_ascii_uppercase();
276 if !diff_filter.contains(type_char) {
277 return true; }
279
280 let new_file = delta.new_file();
282 let old_file = delta.old_file();
283
284 if let Some(new_path) = new_file.path().and_then(|p| p.to_str()) {
285 let previous_path = if change_type == ChangeType::Renamed
286 || change_type == ChangeType::Copied
287 {
288 old_file
289 .path()
290 .and_then(|p| p.to_str())
291 .map(|s| interner.intern(s))
292 } else {
293 None
294 };
295
296 result.files.push(crate::types::ChangedFile {
297 path: interner.intern(new_path),
298 change_type,
299 previous_path,
300 is_symlink: false,
301 submodule_depth: 0,
302 origin: crate::types::FileOrigin {
303 in_current_changes: true,
304 in_previous_failure: false,
305 in_previous_success: false,
306 },
307 });
308 }
309
310 true },
312 None,
313 None,
314 None,
315 )?;
316
317 Ok(result)
318 }
319
320 pub fn resolve_sha_sync(&self, reference: &str) -> Result<String> {
322 let repo = self.get_repo()?;
323
324 if let Ok(oid) = git2::Oid::from_str(reference) {
326 if repo.find_object(oid, None).is_ok() {
328 return Ok(oid.to_string());
329 }
330 }
331
332 let resolved = repo.revparse_single(reference).map_err(|e| {
334 Error::Git(format!(
335 "Failed to resolve reference '{}': {}",
336 reference, e
337 ))
338 })?;
339
340 Ok(resolved.id().to_string())
341 }
342
343 pub fn submodules_sync(&self) -> Result<Vec<String>> {
345 let repo = self.get_repo()?;
346 let mut result = Vec::new();
347
348 for submodule in repo.submodules()? {
349 if let Some(path) = submodule.path().to_str() {
350 result.push(path.to_string());
351 }
352 }
353
354 Ok(result)
355 }
356}
357
358impl GitRepository {
359 pub async fn diff(
365 &self,
366 base_sha: &str,
367 head_sha: &str,
368 interner: &StringInterner,
369 diff_filter: &str,
370 ) -> Result<DiffResult> {
371 {
372 let repo = self.get_repo()?;
373 let base_oid = git2::Oid::from_str(base_sha)?;
374 let head_oid = git2::Oid::from_str(head_sha)?;
375
376 let base_tree = Self::sha_to_tree(&repo, base_oid, base_sha)?;
378 let head_tree = Self::sha_to_tree(&repo, head_oid, head_sha)?;
379
380 let mut opts = git2::DiffOptions::new();
381 opts.ignore_submodules(true);
382
383 let diff =
384 repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Some(&mut opts))?;
385 let mut result = DiffResult::default();
386
387 diff.foreach(
388 &mut |delta, _progress| {
389 let status = delta.status();
390 let change_type = match status {
391 git2::Delta::Added => ChangeType::Added,
392 git2::Delta::Deleted => ChangeType::Deleted,
393 git2::Delta::Modified => ChangeType::Modified,
394 git2::Delta::Renamed => ChangeType::Renamed,
395 git2::Delta::Copied => ChangeType::Copied,
396 git2::Delta::Typechange => ChangeType::TypeChanged,
397 git2::Delta::Conflicted => ChangeType::Unmerged,
398 _ => ChangeType::Unknown,
399 };
400
401 let type_char = change_type
402 .as_str()
403 .chars()
404 .next()
405 .unwrap_or('X')
406 .to_ascii_uppercase();
407 if !diff_filter.contains(type_char) {
408 return true;
409 }
410
411 let new_file = delta.new_file();
412 let old_file = delta.old_file();
413
414 if let Some(new_path) = new_file.path().and_then(|p| p.to_str()) {
415 let previous_path = if change_type == ChangeType::Renamed
416 || change_type == ChangeType::Copied
417 {
418 old_file
419 .path()
420 .and_then(|p| p.to_str())
421 .map(|s| interner.intern(s))
422 } else {
423 None
424 };
425
426 result.files.push(ChangedFile {
427 path: interner.intern(new_path),
428 change_type,
429 previous_path,
430 is_symlink: false,
431 submodule_depth: 0,
432 origin: crate::types::FileOrigin {
433 in_current_changes: true,
434 in_previous_failure: false,
435 in_previous_success: false,
436 },
437 });
438 }
439
440 true
441 },
442 None,
443 None,
444 None,
445 )?;
446
447 Ok(result)
448 }
449 }
450}
451
452unsafe impl Send for GitRepository {}
454unsafe impl Sync for GitRepository {}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use std::fs;
460 use tempfile::TempDir;
461
462 fn create_test_repo() -> (TempDir, GitRepository) {
463 let dir = TempDir::new().unwrap();
464 let repo_path = dir.path();
465
466 std::process::Command::new("git")
468 .args(["init"])
469 .current_dir(repo_path)
470 .output()
471 .unwrap();
472
473 std::process::Command::new("git")
474 .args(["config", "user.name", "Test User"])
475 .current_dir(repo_path)
476 .output()
477 .unwrap();
478
479 std::process::Command::new("git")
480 .args(["config", "user.email", "test@example.com"])
481 .current_dir(repo_path)
482 .output()
483 .unwrap();
484
485 fs::write(repo_path.join("file1.txt"), "content1").unwrap();
487 std::process::Command::new("git")
488 .args(["add", "."])
489 .current_dir(repo_path)
490 .output()
491 .unwrap();
492 std::process::Command::new("git")
493 .args(["commit", "-m", "Initial commit"])
494 .current_dir(repo_path)
495 .output()
496 .unwrap();
497
498 let git_repo = GitRepository::discover(repo_path).unwrap();
499 (dir, git_repo)
500 }
501
502 #[test]
503 fn test_open_repository() {
504 let (_dir, repo) = create_test_repo();
505 assert!(!repo.path.as_os_str().is_empty());
506 }
507
508 #[test]
509 fn test_resolve_sha() {
510 let (_dir, repo) = create_test_repo();
511
512 let sha = repo.resolve_sha_sync("HEAD").unwrap();
514 assert_eq!(sha.len(), 40); }
516
517 #[test]
518 fn test_diff_sync() {
519 let (dir, repo) = create_test_repo();
520 let repo_path = dir.path();
521
522 let base_sha = repo.resolve_sha_sync("HEAD").unwrap();
524
525 fs::write(repo_path.join("file2.txt"), "content2").unwrap();
527 std::process::Command::new("git")
528 .args(["add", "."])
529 .current_dir(repo_path)
530 .output()
531 .unwrap();
532 std::process::Command::new("git")
533 .args(["commit", "-m", "Add file2"])
534 .current_dir(repo_path)
535 .output()
536 .unwrap();
537
538 let head_sha = repo.resolve_sha_sync("HEAD").unwrap();
539
540 let interner = StringInterner::new();
542 let result = repo
543 .diff_sync(&base_sha, &head_sha, &interner, "ACDMRTUX")
544 .unwrap();
545
546 assert_eq!(result.files.len(), 1);
547 assert_eq!(result.files[0].change_type, crate::types::ChangeType::Added);
548 assert_eq!(interner.resolve(result.files[0].path), Some("file2.txt"));
549 }
550
551 #[test]
552 fn test_is_symlink_in_tree() {
553 let (dir, repo) = create_test_repo();
554 #[cfg_attr(not(unix), allow(unused_variables))]
556 let repo_path = dir.path();
557
558 let sha = repo.resolve_sha_sync("HEAD").unwrap();
560
561 let result = repo.is_symlink_in_tree(&sha, "file1.txt").unwrap();
563 assert!(!result);
564
565 #[cfg(unix)]
567 {
568 std::os::unix::fs::symlink("file1.txt", repo_path.join("link.txt")).unwrap();
569 std::process::Command::new("git")
570 .args(["add", "link.txt"])
571 .current_dir(repo_path)
572 .output()
573 .unwrap();
574 std::process::Command::new("git")
575 .args(["commit", "-m", "Add symlink"])
576 .current_dir(repo_path)
577 .output()
578 .unwrap();
579
580 let sha_with_link = repo.resolve_sha_sync("HEAD").unwrap();
581 let is_link = repo.is_symlink_in_tree(&sha_with_link, "link.txt").unwrap();
582 assert!(is_link);
583
584 let is_regular = repo
585 .is_symlink_in_tree(&sha_with_link, "file1.txt")
586 .unwrap();
587 assert!(!is_regular);
588 }
589 }
590
591 #[test]
592 fn test_is_symlink_in_tree_invalid_sha() {
593 let (_dir, repo) = create_test_repo();
594
595 let result =
596 repo.is_symlink_in_tree("0000000000000000000000000000000000000000", "file1.txt");
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_is_symlink_in_tree_file_not_found() {
602 let (_dir, repo) = create_test_repo();
603 let sha = repo.resolve_sha_sync("HEAD").unwrap();
604
605 let result = repo.is_symlink_in_tree(&sha, "nonexistent.txt").unwrap();
607 assert!(!result);
608 }
609
610 #[test]
611 fn test_git_repository_struct_size_no_arc_overhead() {
612 let repo_size = std::mem::size_of::<GitRepository>();
615 let pathbuf_size = std::mem::size_of::<PathBuf>();
616 assert_eq!(
617 repo_size, pathbuf_size,
618 "GitRepository size ({}) should equal PathBuf size ({}); no Arc/Mutex overhead expected",
619 repo_size, pathbuf_size
620 );
621 }
622}