Skip to main content

lechange_core/git/
repository.rs

1//! Git repository operations with async support
2
3use 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
10/// Git repository wrapper that handles Send/Sync constraints
11///
12/// git2::Repository is not Send/Sync due to internal raw pointers.
13/// We work around this by storing the path and using spawn_blocking
14/// for all git operations.
15pub struct GitRepository {
16    path: PathBuf,
17}
18
19impl GitRepository {
20    /// Open a repository at the given path
21    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
22        let path = path.as_ref().to_path_buf();
23        // Verify the repository exists
24        let _repo = git2::Repository::open(&path)?;
25
26        Ok(Self { path })
27    }
28
29    /// Discover a repository starting from the given path
30    pub fn discover<P: AsRef<Path>>(path: P) -> Result<Self> {
31        // discover_path() returns the path to the .git directory (PathBuf)
32        let git_path = git2::Repository::discover_path(path.as_ref(), &[] as &[&std::ffi::OsStr])?;
33        // Verify it can be opened
34        let _repo = git2::Repository::open(&git_path)?;
35
36        Ok(Self { path: git_path })
37    }
38
39    /// Get the repository path (the .git directory or workdir path)
40    pub fn path(&self) -> &Path {
41        &self.path
42    }
43
44    /// Get the working directory root (parent of .git directory).
45    ///
46    /// If `path` ends with `.git`, returns its parent. Otherwise returns `path` as-is.
47    /// This is the directory that contains the actual source files.
48    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    /// Get or create a repository instance (for internal use)
57    fn get_repo(&self) -> Result<git2::Repository> {
58        // Always reopen the repository
59        // git2 has internal caching so this is cheap
60        Ok(git2::Repository::open(&self.path)?)
61    }
62
63    /// Resolve a SHA to its tree, handling the empty tree SHA (initial push).
64    ///
65    /// The empty tree SHA `4b825dc...` is a tree object, not a commit, so
66    /// `find_commit()` fails on it. This method tries `find_commit().tree()`
67    /// first, then falls back to `find_tree()` for the empty tree case.
68    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            // Empty tree SHA is a tree object, not a commit
75            Ok(repo.find_tree(oid).or_else(|_| {
76                // If the empty tree isn't in the ODB, create it
77                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    /// Ensure repository has sufficient depth for diff
91    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            // Check if repository is shallow
100            let _repo = git2::Repository::open(&path)?;
101
102            // git2 doesn't have a direct shallow check, so we use git command
103            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                // Fetch additional depth
113                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    /// Ensure repository has sufficient depth with retry and exponential deepening
127    ///
128    /// Tries to find the merge base between base and head. If the merge base
129    /// is not reachable (shallow clone), deepens the repository and retries.
130    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            // Check if repo is shallow
145            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(()); // Full clone, no deepening needed
155            }
156
157            // Try to find merge base; if it fails, deepen
158            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(()); // Merge base found
168                }
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                // Deepen the repository
179                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; // Exponential backoff
194            }
195
196            Ok(())
197        })
198        .await
199        .map_err(|e| Error::Runtime(format!("Task join error: {}", e)))?
200    }
201
202    /// Check if a path is a symlink in a specific tree (by SHA)
203    ///
204    /// Useful for detecting symlinks in deleted files where the working tree
205    /// no longer has the file. Falls back to checking the git tree object.
206    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                // Symlinks have filemode 0o120000 (0x8000 in git)
216                Ok(entry.filemode() == 0o120000)
217            }
218            Err(_) => Ok(false), // Path not found in tree
219        }
220    }
221
222    /// Compute diff between two commits (sync version)
223    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        // Parse OIDs
233        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        // Get trees (handles empty tree SHA for initial pushes)
239        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        // Create diff options
243        let mut opts = git2::DiffOptions::new();
244        opts.ignore_submodules(true);
245
246        // Compute diff
247        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        // Process each delta
252        diff.foreach(
253            &mut |delta, _progress| {
254                let status = delta.status();
255
256                // Map git2 status to our ChangeType
257                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                // Filter by diff_filter
270                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; // Continue
278                }
279
280                // Get file paths
281                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 // Continue iteration
311            },
312            None,
313            None,
314            None,
315        )?;
316
317        Ok(result)
318    }
319
320    /// Resolve a reference to a SHA (sync version)
321    pub fn resolve_sha_sync(&self, reference: &str) -> Result<String> {
322        let repo = self.get_repo()?;
323
324        // Try as direct OID first
325        if let Ok(oid) = git2::Oid::from_str(reference) {
326            // Verify it exists
327            if repo.find_object(oid, None).is_ok() {
328                return Ok(oid.to_string());
329            }
330        }
331
332        // Try as reference
333        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    /// Get list of submodule paths (sync version)
344    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    /// Compute the diff between two SHAs, interning paths as they stream out.
360    ///
361    /// The git2 work runs inline on the calling task: the endpoint tree diff is
362    /// CPU/page-cache bound and completes in single-digit milliseconds on real
363    /// repositories, so a spawn_blocking hop would cost more than it saves.
364    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            // Get trees (handles empty tree SHA for initial pushes)
377            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
452// Implement Send + Sync since we handle git2::Repository correctly
453unsafe 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        // Initialize git repo
467        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        // Create initial commit
486        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        // Resolve HEAD
513        let sha = repo.resolve_sha_sync("HEAD").unwrap();
514        assert_eq!(sha.len(), 40); // SHA is 40 hex characters
515    }
516
517    #[test]
518    fn test_diff_sync() {
519        let (dir, repo) = create_test_repo();
520        let repo_path = dir.path();
521
522        // Get current SHA
523        let base_sha = repo.resolve_sha_sync("HEAD").unwrap();
524
525        // Create a new file
526        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        // Compute diff
541        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        // Used only by the unix-gated symlink block below
555        #[cfg_attr(not(unix), allow(unused_variables))]
556        let repo_path = dir.path();
557
558        // Get the SHA with the regular file
559        let sha = repo.resolve_sha_sync("HEAD").unwrap();
560
561        // Regular file should not be a symlink
562        let result = repo.is_symlink_in_tree(&sha, "file1.txt").unwrap();
563        assert!(!result);
564
565        // Create a symlink and commit it
566        #[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        // File not in tree returns false (not an error based on the implementation)
606        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        // GitRepository should be exactly the size of a PathBuf (no Arc/Mutex wrapping).
613        // If someone accidentally wraps the path in Arc<Mutex<PathBuf>>, this will fail.
614        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}