1use std::path::{Path, PathBuf};
2use std::process::Command;
3use thiserror::Error;
4
5pub struct GitRepo {
7 path: PathBuf,
8}
9
10impl GitRepo {
11 pub fn from_path(path: &Path) -> Self {
13 GitRepo { path: path.to_path_buf() }
14 }
15
16 #[tracing::instrument(skip(dest))]
25 pub fn clone(url: &str, dest: &Path) -> Result<Self, GitRepoError> {
26 tracing::debug!("Cloning repository from {} (blobless clone)", url);
27 let output = Command::new("git")
28 .arg("clone")
29 .arg("--no-checkout")
30 .arg("--filter=blob:none")
31 .arg(url)
32 .arg(dest)
33 .output()
34 .map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git clone: {e}")))?;
35
36 if !output.status.success() {
37 let stderr = String::from_utf8_lossy(&output.stderr);
38 return Err(GitRepoError::CloneFailed(stderr.to_string()));
39 }
40
41 Ok(GitRepo { path: dest.to_path_buf() })
42 }
43
44 #[tracing::instrument(skip(self))]
46 pub fn checkout(&self, reference: &str) -> Result<(), GitRepoError> {
47 tracing::debug!("Checking out ref: {}", reference);
48 let output = Command::new("git")
49 .arg("-C")
50 .arg(&self.path)
51 .arg("checkout")
52 .arg(reference)
53 .output()
54 .map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git checkout: {e}")))?;
55
56 if !output.status.success() {
57 let stderr = String::from_utf8_lossy(&output.stderr);
58 return Err(GitRepoError::CheckoutFailed { reference: reference.to_string(), reason: stderr.to_string() });
59 }
60
61 Ok(())
62 }
63
64 #[tracing::instrument(skip(self))]
75 pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result<String, GitRepoError> {
76 let mut cmd = Command::new("git");
77 cmd.arg("-C").arg(&self.path).arg("diff");
78
79 if let Some(to) = to_commit {
80 tracing::debug!("Getting diff from {} to {}", from_commit, to);
81 cmd.arg(format!("{from_commit}..{to}"));
82 } else {
83 tracing::debug!("Getting diff from {} to working directory", from_commit);
84 cmd.arg(from_commit);
85 }
86
87 let output =
88 cmd.output().map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git diff: {e}")))?;
89
90 if !output.status.success() {
91 let stderr = String::from_utf8_lossy(&output.stderr);
92 return Err(GitRepoError::DiffFailed {
93 from: from_commit.to_string(),
94 to: to_commit.unwrap_or("working directory").to_string(),
95 reason: stderr.to_string(),
96 });
97 }
98
99 let diff = String::from_utf8_lossy(&output.stdout).to_string();
100 Ok(diff)
101 }
102
103 pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result<String, GitRepoError> {
107 self.diff_range(from_commit, Some(to_commit))
108 }
109
110 pub fn diff_unstaged(&self) -> Result<String, GitRepoError> {
114 self.diff_range("HEAD", None)
115 }
116}
117
118#[derive(Debug, Error)]
119pub enum GitRepoError {
120 #[error("Git command failed: {0}")]
121 CommandFailed(String),
122
123 #[error("Failed to clone repository: {0}")]
124 CloneFailed(String),
125
126 #[error("Failed to checkout '{reference}': {reason}")]
127 CheckoutFailed { reference: String, reason: String },
128
129 #[error("Failed to diff '{from}..{to}': {reason}")]
130 DiffFailed { from: String, to: String, reason: String },
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::fs;
137
138 #[test]
139 fn test_git_diff_between_commits() {
140 let temp_dir = tempfile::tempdir().unwrap();
141 let repo_path = temp_dir.path();
142
143 Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
144 Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
145 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
146
147 fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
148 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
149 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
150
151 let first_commit = String::from_utf8(
152 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
153 )
154 .unwrap()
155 .trim()
156 .to_string();
157
158 fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
159 fs::write(repo_path.join("new.txt"), "new file\n").unwrap();
160 Command::new("git").args(["add", "."]).current_dir(repo_path).output().unwrap();
161 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
162
163 let second_commit = String::from_utf8(
164 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
165 )
166 .unwrap()
167 .trim()
168 .to_string();
169
170 let git_repo = GitRepo::from_path(repo_path);
171 let diff = git_repo.diff(&first_commit, &second_commit).unwrap();
172
173 assert!(diff.contains("test.txt"), "Diff should mention test.txt");
174 assert!(diff.contains("new.txt"), "Diff should mention new.txt");
175 assert!(
176 diff.contains("modified content") || diff.contains("+modified content"),
177 "Diff should show modified content"
178 );
179 }
180
181 #[test]
182 fn test_unified_diff_function() {
183 let temp_dir = tempfile::tempdir().unwrap();
184 let repo_path = temp_dir.path();
185
186 Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
187 Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
188 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
189
190 fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
191 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
192 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
193
194 let first_commit = String::from_utf8(
195 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
196 )
197 .unwrap()
198 .trim()
199 .to_string();
200
201 fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
202 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
203 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
204
205 let second_commit = String::from_utf8(
206 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
207 )
208 .unwrap()
209 .trim()
210 .to_string();
211
212 fs::write(repo_path.join("test.txt"), "unstaged content\n").unwrap();
213
214 let git_repo = GitRepo::from_path(repo_path);
215
216 let diff = git_repo.diff_range(&first_commit, Some(&second_commit)).unwrap();
217 assert!(diff.contains("modified content") || diff.contains("+modified content"));
218
219 let unstaged_diff = git_repo.diff_range("HEAD", None).unwrap();
220 assert!(unstaged_diff.contains("unstaged content") || unstaged_diff.contains("+unstaged content"));
221
222 let from_commit_diff = git_repo.diff_range(&first_commit, None).unwrap();
223 assert!(from_commit_diff.contains("unstaged content") || from_commit_diff.contains("+unstaged content"));
224 }
225
226 #[test]
227 fn test_blobless_clone_and_checkout() {
228 let source_dir = tempfile::tempdir().unwrap();
229 let source_path = source_dir.path();
230
231 Command::new("git").args(["init"]).current_dir(source_path).output().unwrap();
232 Command::new("git")
233 .args(["config", "user.email", "test@example.com"])
234 .current_dir(source_path)
235 .output()
236 .unwrap();
237 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(source_path).output().unwrap();
238
239 fs::write(source_path.join("test.txt"), "initial content\n").unwrap();
240 Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
241 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(source_path).output().unwrap();
242
243 let first_commit = String::from_utf8(
244 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
245 )
246 .unwrap()
247 .trim()
248 .to_string();
249
250 fs::write(source_path.join("test.txt"), "modified content\n").unwrap();
251 Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
252 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(source_path).output().unwrap();
253
254 let second_commit = String::from_utf8(
255 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
256 )
257 .unwrap()
258 .trim()
259 .to_string();
260
261 let clone_dir = tempfile::tempdir().unwrap();
262 let repo = GitRepo::clone(source_path.to_str().unwrap(), clone_dir.path()).unwrap();
263
264 let entries: Vec<_> = fs::read_dir(clone_dir.path())
265 .unwrap()
266 .filter_map(std::result::Result::ok)
267 .filter(|e| e.file_name() != ".git")
268 .collect();
269 assert_eq!(entries.len(), 0, "Working directory should be empty after blobless clone");
270
271 repo.checkout(&first_commit).unwrap();
272
273 let content = fs::read_to_string(clone_dir.path().join("test.txt")).unwrap();
274 assert_eq!(content, "initial content\n");
275
276 let diff = repo.diff(&first_commit, &second_commit).unwrap();
277 assert!(diff.contains("modified content") || diff.contains("+modified content"));
278 }
279}