1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use thiserror::Error;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum CloneMode {
9 Full,
13 Blobless,
16}
17
18pub struct GitRepo {
20 path: PathBuf,
21}
22
23impl GitRepo {
24 pub fn from_path(path: &Path) -> Self {
26 GitRepo { path: path.to_path_buf() }
27 }
28
29 #[tracing::instrument(skip(dest))]
31 pub fn init(dest: &Path) -> Result<Self, GitRepoError> {
32 run_git(None, [OsStr::new("init"), dest.as_os_str()], GitRepoError::InitFailed)?;
33 Ok(GitRepo { path: dest.to_path_buf() })
34 }
35
36 #[tracing::instrument(skip(source, dest))]
43 pub fn clone(source: impl AsRef<OsStr>, dest: &Path, mode: CloneMode) -> Result<Self, GitRepoError> {
44 let mut args = vec![OsStr::new("clone"), OsStr::new("--no-checkout")];
45 if mode == CloneMode::Blobless {
46 args.push(OsStr::new("--filter=blob:none"));
47 }
48 args.push(source.as_ref());
49 args.push(dest.as_os_str());
50 run_git(None, args, GitRepoError::CloneFailed)?;
51 Ok(GitRepo { path: dest.to_path_buf() })
52 }
53
54 #[tracing::instrument(skip(self))]
56 pub fn checkout(&self, reference: &str) -> Result<(), GitRepoError> {
57 run_git(Some(&self.path), ["checkout", reference], |reason| GitRepoError::CheckoutFailed {
58 reference: reference.to_string(),
59 reason,
60 })?;
61 Ok(())
62 }
63
64 #[tracing::instrument(skip(self))]
66 pub fn fetch(&self, remote: &str, revs: &[&str]) -> Result<(), GitRepoError> {
67 let mut args = vec!["fetch", remote];
68 args.extend_from_slice(revs);
69 run_git(Some(&self.path), args, GitRepoError::FetchFailed)?;
70 Ok(())
71 }
72
73 #[tracing::instrument(skip(self))]
75 pub fn update_ref(&self, name: &str, commit: &str) -> Result<(), GitRepoError> {
76 run_git(Some(&self.path), ["update-ref", name, commit], GitRepoError::UpdateRefFailed)?;
77 Ok(())
78 }
79
80 #[tracing::instrument(skip(self, out))]
83 pub fn bundle(&self, revs: &[&str], out: &Path) -> Result<(), GitRepoError> {
84 let mut args = vec![OsStr::new("bundle"), OsStr::new("create"), out.as_os_str()];
85 args.extend(revs.iter().map(OsStr::new));
86 run_git(Some(&self.path), args, GitRepoError::BundleFailed)?;
87 Ok(())
88 }
89
90 #[tracing::instrument(skip(self))]
101 pub fn diff_range(&self, from_commit: &str, to_commit: Option<&str>) -> Result<String, GitRepoError> {
102 let range = match to_commit {
103 Some(to) => format!("{from_commit}..{to}"),
104 None => from_commit.to_string(),
105 };
106 run_git(Some(&self.path), ["diff", range.as_str()], |reason| GitRepoError::DiffFailed {
107 from: from_commit.to_string(),
108 to: to_commit.unwrap_or("working directory").to_string(),
109 reason,
110 })
111 }
112
113 pub fn diff(&self, from_commit: &str, to_commit: &str) -> Result<String, GitRepoError> {
117 self.diff_range(from_commit, Some(to_commit))
118 }
119}
120
121fn run_git<T: IntoIterator<Item = U>, U: AsRef<OsStr>>(
126 cwd: Option<&Path>,
127 args: T,
128 on_failure: impl FnOnce(String) -> GitRepoError,
129) -> Result<String, GitRepoError> {
130 let mut cmd = Command::new("git");
131 if let Some(dir) = cwd {
132 cmd.arg("-C").arg(dir);
133 }
134
135 let output =
136 cmd.args(args).output().map_err(|e| GitRepoError::CommandFailed(format!("Failed to execute git: {e}")))?;
137
138 if !output.status.success() {
139 return Err(on_failure(String::from_utf8_lossy(&output.stderr).into_owned()));
140 }
141
142 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
143}
144
145#[derive(Debug, Error)]
146pub enum GitRepoError {
147 #[error("Git command failed: {0}")]
148 CommandFailed(String),
149
150 #[error("Failed to initialize repository: {0}")]
151 InitFailed(String),
152
153 #[error("Failed to clone repository: {0}")]
154 CloneFailed(String),
155
156 #[error("Failed to fetch revisions: {0}")]
157 FetchFailed(String),
158
159 #[error("Failed to update ref: {0}")]
160 UpdateRefFailed(String),
161
162 #[error("Failed to create git bundle: {0}")]
163 BundleFailed(String),
164
165 #[error("Failed to checkout '{reference}': {reason}")]
166 CheckoutFailed { reference: String, reason: String },
167
168 #[error("Failed to diff '{from}..{to}': {reason}")]
169 DiffFailed { from: String, to: String, reason: String },
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::fs;
176
177 #[test]
178 fn test_git_diff_between_commits() {
179 let temp_dir = tempfile::tempdir().unwrap();
180 let repo_path = temp_dir.path();
181
182 Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
183 Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
184 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
185
186 fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
187 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
188 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
189
190 let first_commit = String::from_utf8(
191 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
192 )
193 .unwrap()
194 .trim()
195 .to_string();
196
197 fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
198 fs::write(repo_path.join("new.txt"), "new file\n").unwrap();
199 Command::new("git").args(["add", "."]).current_dir(repo_path).output().unwrap();
200 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
201
202 let second_commit = String::from_utf8(
203 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
204 )
205 .unwrap()
206 .trim()
207 .to_string();
208
209 let git_repo = GitRepo::from_path(repo_path);
210 let diff = git_repo.diff(&first_commit, &second_commit).unwrap();
211
212 assert!(diff.contains("test.txt"), "Diff should mention test.txt");
213 assert!(diff.contains("new.txt"), "Diff should mention new.txt");
214 assert!(
215 diff.contains("modified content") || diff.contains("+modified content"),
216 "Diff should show modified content"
217 );
218 }
219
220 #[test]
221 fn test_unified_diff_function() {
222 let temp_dir = tempfile::tempdir().unwrap();
223 let repo_path = temp_dir.path();
224
225 Command::new("git").args(["init"]).current_dir(repo_path).output().unwrap();
226 Command::new("git").args(["config", "user.email", "test@example.com"]).current_dir(repo_path).output().unwrap();
227 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(repo_path).output().unwrap();
228
229 fs::write(repo_path.join("test.txt"), "initial content\n").unwrap();
230 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
231 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(repo_path).output().unwrap();
232
233 let first_commit = String::from_utf8(
234 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
235 )
236 .unwrap()
237 .trim()
238 .to_string();
239
240 fs::write(repo_path.join("test.txt"), "modified content\n").unwrap();
241 Command::new("git").args(["add", "test.txt"]).current_dir(repo_path).output().unwrap();
242 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(repo_path).output().unwrap();
243
244 let second_commit = String::from_utf8(
245 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(repo_path).output().unwrap().stdout,
246 )
247 .unwrap()
248 .trim()
249 .to_string();
250
251 fs::write(repo_path.join("test.txt"), "unstaged content\n").unwrap();
252
253 let git_repo = GitRepo::from_path(repo_path);
254
255 let diff = git_repo.diff_range(&first_commit, Some(&second_commit)).unwrap();
256 assert!(diff.contains("modified content") || diff.contains("+modified content"));
257
258 let unstaged_diff = git_repo.diff_range("HEAD", None).unwrap();
259 assert!(unstaged_diff.contains("unstaged content") || unstaged_diff.contains("+unstaged content"));
260
261 let from_commit_diff = git_repo.diff_range(&first_commit, None).unwrap();
262 assert!(from_commit_diff.contains("unstaged content") || from_commit_diff.contains("+unstaged content"));
263 }
264
265 #[test]
266 fn test_blobless_clone_and_checkout() {
267 let source_dir = tempfile::tempdir().unwrap();
268 let source_path = source_dir.path();
269
270 Command::new("git").args(["init"]).current_dir(source_path).output().unwrap();
271 Command::new("git")
272 .args(["config", "user.email", "test@example.com"])
273 .current_dir(source_path)
274 .output()
275 .unwrap();
276 Command::new("git").args(["config", "user.name", "Test User"]).current_dir(source_path).output().unwrap();
277
278 fs::write(source_path.join("test.txt"), "initial content\n").unwrap();
279 Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
280 Command::new("git").args(["commit", "-m", "Initial commit"]).current_dir(source_path).output().unwrap();
281
282 let first_commit = String::from_utf8(
283 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
284 )
285 .unwrap()
286 .trim()
287 .to_string();
288
289 fs::write(source_path.join("test.txt"), "modified content\n").unwrap();
290 Command::new("git").args(["add", "test.txt"]).current_dir(source_path).output().unwrap();
291 Command::new("git").args(["commit", "-m", "Second commit"]).current_dir(source_path).output().unwrap();
292
293 let second_commit = String::from_utf8(
294 Command::new("git").args(["rev-parse", "HEAD"]).current_dir(source_path).output().unwrap().stdout,
295 )
296 .unwrap()
297 .trim()
298 .to_string();
299
300 let clone_dir = tempfile::tempdir().unwrap();
301 let repo = GitRepo::clone(source_path.to_str().unwrap(), clone_dir.path(), CloneMode::Blobless).unwrap();
302
303 let entries: Vec<_> = fs::read_dir(clone_dir.path())
304 .unwrap()
305 .filter_map(std::result::Result::ok)
306 .filter(|e| e.file_name() != ".git")
307 .collect();
308 assert_eq!(entries.len(), 0, "Working directory should be empty after blobless clone");
309
310 repo.checkout(&first_commit).unwrap();
311
312 let content = fs::read_to_string(clone_dir.path().join("test.txt")).unwrap();
313 assert_eq!(content, "initial content\n");
314
315 let diff = repo.diff(&first_commit, &second_commit).unwrap();
316 assert!(diff.contains("modified content") || diff.contains("+modified content"));
317 }
318}