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