lechange_core/git/
recovery.rs1use crate::error::{Error, Result};
4use crate::interner::StringInterner;
5use crate::types::InternedString;
6use rayon::prelude::*;
7use std::path::{Path, PathBuf};
8
9pub struct FileRecovery {
11 repo_path: PathBuf,
12}
13
14impl FileRecovery {
15 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 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 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 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 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 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 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}