Skip to main content

lechange_core/git/
recovery.rs

1//! Deleted file recovery via git2 blob lookup
2
3use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::InternedString;
6use rayon::prelude::*;
7use std::path::{Path, PathBuf};
8
9/// Recovers deleted file contents from git history
10pub struct FileRecovery {
11    repo_path: PathBuf,
12}
13
14impl FileRecovery {
15    /// Create a new file recovery instance
16    pub fn new<P: AsRef<Path>>(repo_path: P) -> Self {
17        Self {
18            repo_path: repo_path.as_ref().to_path_buf(),
19        }
20    }
21
22    /// Recover a single deleted file from the given commit
23    pub fn recover_file(&self, sha: &str, file_path: &str, output_dir: &Path) -> Result<PathBuf> {
24        let repo = git2::Repository::open(&self.repo_path)?;
25        let oid = git2::Oid::from_str(sha)
26            .map_err(|e| Error::Recovery(format!("Invalid SHA '{}': {}", sha, e)))?;
27
28        let commit = repo
29            .find_commit(oid)
30            .map_err(|e| Error::Recovery(format!("Commit not found '{}': {}", sha, e)))?;
31
32        let tree = commit.tree()?;
33
34        let entry = tree
35            .get_path(Path::new(file_path))
36            .map_err(|e| Error::Recovery(format!("File '{}' not in tree: {}", file_path, e)))?;
37
38        let blob = repo
39            .find_blob(entry.id())
40            .map_err(|e| Error::Recovery(format!("Blob not found for '{}': {}", file_path, e)))?;
41
42        // Write to output directory
43        let output_path = output_dir.join(file_path);
44        if let Some(parent) = output_path.parent() {
45            std::fs::create_dir_all(parent)?;
46        }
47        std::fs::write(&output_path, blob.content())?;
48
49        Ok(output_path)
50    }
51
52    /// Recover multiple deleted files in parallel
53    pub fn recover_all_parallel(
54        &self,
55        sha: &str,
56        file_paths: &[InternedString],
57        interner: &StringInterner,
58        output_dir: &Path,
59    ) -> Vec<Result<PathBuf>> {
60        file_paths
61            .par_iter()
62            .map(|path| {
63                let path_str = interner.resolve(*path).ok_or_else(|| {
64                    Error::Recovery("Could not resolve interned path".to_string())
65                })?;
66                self.recover_file(sha, path_str, output_dir)
67            })
68            .collect()
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use tempfile::TempDir;
76
77    fn create_test_repo() -> (TempDir, String) {
78        let dir = TempDir::new().unwrap();
79        let repo_path = dir.path();
80
81        std::process::Command::new("git")
82            .args(["init"])
83            .current_dir(repo_path)
84            .output()
85            .unwrap();
86
87        std::process::Command::new("git")
88            .args(["config", "user.name", "Test User"])
89            .current_dir(repo_path)
90            .output()
91            .unwrap();
92
93        std::process::Command::new("git")
94            .args(["config", "user.email", "test@example.com"])
95            .current_dir(repo_path)
96            .output()
97            .unwrap();
98
99        // Create a file and commit
100        std::fs::write(repo_path.join("recoverable.txt"), "original content").unwrap();
101        std::process::Command::new("git")
102            .args(["add", "."])
103            .current_dir(repo_path)
104            .output()
105            .unwrap();
106        std::process::Command::new("git")
107            .args(["commit", "-m", "Add recoverable file"])
108            .current_dir(repo_path)
109            .output()
110            .unwrap();
111
112        // Get the commit SHA
113        let output = std::process::Command::new("git")
114            .args(["rev-parse", "HEAD"])
115            .current_dir(repo_path)
116            .output()
117            .unwrap();
118        let sha = String::from_utf8(output.stdout).unwrap().trim().to_string();
119
120        (dir, sha)
121    }
122
123    #[test]
124    fn test_recover_file() {
125        let (dir, sha) = create_test_repo();
126        let output_dir = TempDir::new().unwrap();
127
128        let recovery = FileRecovery::new(dir.path());
129        let result = recovery.recover_file(&sha, "recoverable.txt", output_dir.path());
130        assert!(result.is_ok());
131
132        let output_path = result.unwrap();
133        assert!(output_path.exists());
134        let content = std::fs::read_to_string(&output_path).unwrap();
135        assert_eq!(content, "original content");
136    }
137
138    #[test]
139    fn test_recover_file_not_in_tree() {
140        let (dir, sha) = create_test_repo();
141        let output_dir = TempDir::new().unwrap();
142
143        let recovery = FileRecovery::new(dir.path());
144        let result = recovery.recover_file(&sha, "nonexistent.txt", output_dir.path());
145        assert!(result.is_err());
146        let err_msg = format!("{}", result.unwrap_err());
147        assert!(err_msg.contains("not in tree"));
148    }
149
150    #[test]
151    fn test_recover_file_invalid_sha() {
152        let (dir, _) = create_test_repo();
153        let output_dir = TempDir::new().unwrap();
154
155        let recovery = FileRecovery::new(dir.path());
156        let result = recovery.recover_file("invalid_sha", "recoverable.txt", output_dir.path());
157        assert!(result.is_err());
158    }
159
160    #[test]
161    fn test_recover_all_parallel() {
162        let (dir, _sha) = create_test_repo();
163        let repo_path = dir.path();
164
165        // Add more files
166        std::fs::create_dir_all(repo_path.join("sub")).unwrap();
167        std::fs::write(repo_path.join("sub/file2.txt"), "content2").unwrap();
168        std::process::Command::new("git")
169            .args(["add", "."])
170            .current_dir(repo_path)
171            .output()
172            .unwrap();
173        std::process::Command::new("git")
174            .args(["commit", "-m", "Add sub/file2.txt"])
175            .current_dir(repo_path)
176            .output()
177            .unwrap();
178        let output = std::process::Command::new("git")
179            .args(["rev-parse", "HEAD"])
180            .current_dir(repo_path)
181            .output()
182            .unwrap();
183        let sha2 = String::from_utf8(output.stdout).unwrap().trim().to_string();
184
185        let output_dir = TempDir::new().unwrap();
186        let interner = StringInterner::new();
187        let paths = vec![
188            interner.intern("recoverable.txt"),
189            interner.intern("sub/file2.txt"),
190        ];
191
192        let recovery = FileRecovery::new(repo_path);
193        let results = recovery.recover_all_parallel(&sha2, &paths, &interner, output_dir.path());
194
195        assert_eq!(results.len(), 2);
196        for result in &results {
197            assert!(result.is_ok());
198            assert!(result.as_ref().unwrap().exists());
199        }
200    }
201}