1use std::path::{Path, PathBuf};
7use std::process::{Command, Output};
8
9use crate::Error;
10
11#[derive(Debug, Clone)]
13pub struct Git {
14 work_dir: PathBuf,
16 git_path: PathBuf,
18}
19
20impl Default for Git {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl Git {
27 #[must_use]
29 pub fn new() -> Self {
30 Self {
31 work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
32 git_path: PathBuf::from("git"),
33 }
34 }
35
36 #[must_use]
38 pub fn with_work_dir<P: AsRef<Path>>(path: P) -> Self {
39 Self {
40 work_dir: path.as_ref().to_path_buf(),
41 git_path: PathBuf::from("git"),
42 }
43 }
44
45 #[must_use]
47 pub fn work_dir(&self) -> &Path {
48 &self.work_dir
49 }
50
51 pub fn check_repository(&self) -> Result<(), Error> {
57 let output = self.run(&["rev-parse", "--git-dir"])?;
58 if !output.status.success() {
59 return Err(Error::NotARepository {
60 path: Some(self.work_dir.display().to_string()),
61 });
62 }
63 Ok(())
64 }
65
66 pub fn repo_root(&self) -> Result<PathBuf, Error> {
72 self.check_repository()?;
73 let output = self.run_output(&["rev-parse", "--show-toplevel"])?;
74 Ok(PathBuf::from(output.trim()))
75 }
76
77 pub fn run(&self, args: &[&str]) -> Result<Output, Error> {
83 Command::new(&self.git_path)
84 .current_dir(&self.work_dir)
85 .args(args)
86 .output()
87 .map_err(|e| {
88 if e.kind() == std::io::ErrorKind::NotFound {
89 Error::GitNotFound
90 } else {
91 Error::Git {
92 message: e.to_string(),
93 command: args.iter().map(|s| (*s).to_string()).collect(),
94 exit_code: -1,
95 stderr: String::new(),
96 }
97 }
98 })
99 }
100
101 pub fn run_output(&self, args: &[&str]) -> Result<String, Error> {
107 let output = self.run(args)?;
108
109 if !output.status.success() {
110 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
111 return Err(Error::Git {
112 message: format!("git command failed: git {}", args.join(" ")),
113 command: args.iter().map(|s| (*s).to_string()).collect(),
114 exit_code: output.status.code().unwrap_or(-1),
115 stderr,
116 });
117 }
118
119 Ok(String::from_utf8_lossy(&output.stdout).to_string())
120 }
121
122 pub fn run_silent(&self, args: &[&str]) -> Result<(), Error> {
128 let output = self.run(args)?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
132 return Err(Error::Git {
133 message: format!("git command failed: git {}", args.join(" ")),
134 command: args.iter().map(|s| (*s).to_string()).collect(),
135 exit_code: output.status.code().unwrap_or(-1),
136 stderr,
137 });
138 }
139
140 Ok(())
141 }
142
143 pub fn head(&self) -> Result<String, Error> {
149 let output = self.run_output(&["rev-parse", "HEAD"])?;
150 Ok(output.trim().to_string())
151 }
152
153 pub fn short_hash(&self, commit: &str) -> Result<String, Error> {
159 let output = self.run_output(&["rev-parse", "--short", commit])?;
160 Ok(output.trim().to_string())
161 }
162
163 pub fn config_get(&self, key: &str) -> Result<Option<String>, Error> {
169 let output = self.run(&["config", "--get", key])?;
170
171 if output.status.success() {
172 Ok(Some(
173 String::from_utf8_lossy(&output.stdout).trim().to_string(),
174 ))
175 } else {
176 Ok(None)
177 }
178 }
179
180 pub fn config_set(&self, key: &str, value: &str) -> Result<(), Error> {
186 self.run_silent(&["config", key, value])
187 }
188
189 pub fn config_unset(&self, key: &str, all: bool) -> Result<(), Error> {
197 let args: Vec<&str> = if all {
198 vec!["config", "--unset-all", key]
199 } else {
200 vec!["config", "--unset", key]
201 };
202
203 let output = self.run(&args)?;
205 if output.status.success() || output.status.code() == Some(5) {
206 Ok(())
207 } else {
208 Err(Error::Git {
209 message: format!(
210 "Failed to unset config {key}: {}",
211 String::from_utf8_lossy(&output.stderr)
212 ),
213 command: args.iter().map(|s| (*s).to_string()).collect(),
214 exit_code: output.status.code().unwrap_or(-1),
215 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
216 })
217 }
218 }
219
220 pub fn notes_show(&self, notes_ref: &str, commit: &str) -> Result<Option<String>, Error> {
226 let output = self.run(&["notes", "--ref", notes_ref, "show", commit])?;
227
228 if output.status.success() {
229 Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
230 } else {
231 Ok(None)
232 }
233 }
234
235 pub fn notes_add(&self, notes_ref: &str, commit: &str, content: &str) -> Result<(), Error> {
241 self.run_silent(&[
242 "notes", "--ref", notes_ref, "add", "-f", "-m", content, commit,
243 ])
244 }
245
246 pub fn notes_remove(&self, notes_ref: &str, commit: &str) -> Result<(), Error> {
252 self.run_silent(&["notes", "--ref", notes_ref, "remove", commit])
253 }
254
255 pub fn notes_list(&self, notes_ref: &str) -> Result<Vec<(String, String)>, Error> {
261 let output = self.run(&["notes", "--ref", notes_ref, "list"])?;
262
263 if !output.status.success() {
264 return Ok(Vec::new());
266 }
267
268 let stdout = String::from_utf8_lossy(&output.stdout);
269 let mut results = Vec::new();
270
271 for line in stdout.lines() {
272 let parts: Vec<&str> = line.split_whitespace().collect();
273 if parts.len() >= 2 {
274 results.push((parts[0].to_string(), parts[1].to_string()));
275 }
276 }
277
278 Ok(results)
279 }
280
281 pub fn notes_push(&self, remote: &str, notes_ref: &str) -> Result<(), Error> {
287 self.run_silent(&[
288 "push",
289 remote,
290 &format!("refs/notes/{notes_ref}:refs/notes/{notes_ref}"),
291 ])
292 }
293
294 pub fn notes_fetch(&self, remote: &str, notes_ref: &str) -> Result<(), Error> {
300 self.run_silent(&[
301 "fetch",
302 remote,
303 &format!("refs/notes/{notes_ref}:refs/notes/{notes_ref}"),
304 ])
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use tempfile::TempDir;
312
313 #[test]
314 fn test_git_new() {
315 let git = Git::new();
316 assert!(git.work_dir().exists() || git.work_dir() == Path::new("."));
317 }
318
319 #[test]
320 fn test_git_default() {
321 let git = Git::default();
322 assert!(git.work_dir().exists() || git.work_dir() == Path::new("."));
323 }
324
325 #[test]
326 fn test_git_with_work_dir() {
327 let temp_dir = TempDir::new().unwrap();
328 let git = Git::with_work_dir(temp_dir.path());
329 assert_eq!(git.work_dir(), temp_dir.path());
330 }
331
332 #[test]
333 fn test_git_work_dir() {
334 let git = Git::new();
335 let _ = git.work_dir();
336 }
337
338 #[test]
339 fn test_check_repository_not_a_repo() {
340 let temp_dir = TempDir::new().unwrap();
341 let git = Git::with_work_dir(temp_dir.path());
342 let result = git.check_repository();
343 assert!(result.is_err());
344 }
345
346 #[test]
347 fn test_run_success() {
348 let git = Git::new();
349 let output = git.run(&["--version"]).expect("git --version should work");
350 assert!(output.status.success());
351 }
352
353 #[test]
354 fn test_run_output_success() {
355 let git = Git::new();
356 let output = git
357 .run_output(&["--version"])
358 .expect("git --version should work");
359 assert!(output.contains("git version"));
360 }
361
362 #[test]
363 fn test_run_output_failure() {
364 let temp_dir = TempDir::new().unwrap();
365 let git = Git::with_work_dir(temp_dir.path());
366 let result = git.run_output(&["rev-parse", "HEAD"]);
368 assert!(result.is_err());
369 }
370
371 #[test]
372 fn test_run_silent_failure() {
373 let temp_dir = TempDir::new().unwrap();
374 let git = Git::with_work_dir(temp_dir.path());
375 let result = git.run_silent(&["status"]);
377 assert!(result.is_err());
378 }
379
380 #[test]
381 fn test_notes_list_empty() {
382 let temp_dir = TempDir::new().unwrap();
383 let git = Git::with_work_dir(temp_dir.path());
384 Command::new("git")
386 .current_dir(temp_dir.path())
387 .args(["init"])
388 .output()
389 .unwrap();
390 let result = git.notes_list("adr");
391 assert!(result.is_ok());
392 assert!(result.unwrap().is_empty());
393 }
394
395 #[test]
396 fn test_config_get_nonexistent() {
397 let temp_dir = TempDir::new().unwrap();
398 Command::new("git")
399 .current_dir(temp_dir.path())
400 .args(["init"])
401 .output()
402 .unwrap();
403 let git = Git::with_work_dir(temp_dir.path());
404 let result = git.config_get("nonexistent.key");
405 assert!(result.is_ok());
406 assert!(result.unwrap().is_none());
407 }
408
409 #[test]
410 fn test_config_set_and_get() {
411 let temp_dir = TempDir::new().unwrap();
412 Command::new("git")
413 .current_dir(temp_dir.path())
414 .args(["init"])
415 .output()
416 .unwrap();
417 let git = Git::with_work_dir(temp_dir.path());
418 git.config_set("test.key", "test_value").unwrap();
419 let result = git.config_get("test.key").unwrap();
420 assert_eq!(result, Some("test_value".to_string()));
421 }
422
423 #[test]
424 fn test_config_unset() {
425 let temp_dir = TempDir::new().unwrap();
426 Command::new("git")
427 .current_dir(temp_dir.path())
428 .args(["init"])
429 .output()
430 .unwrap();
431 let git = Git::with_work_dir(temp_dir.path());
432 git.config_set("test.key", "value").unwrap();
433 git.config_unset("test.key", false).unwrap();
434 let result = git.config_get("test.key").unwrap();
435 assert!(result.is_none());
436 }
437
438 #[test]
439 fn test_config_unset_nonexistent() {
440 let temp_dir = TempDir::new().unwrap();
441 Command::new("git")
442 .current_dir(temp_dir.path())
443 .args(["init"])
444 .output()
445 .unwrap();
446 let git = Git::with_work_dir(temp_dir.path());
447 let result = git.config_unset("nonexistent.key", false);
449 assert!(result.is_ok());
450 }
451
452 #[test]
453 fn test_notes_show_nonexistent() {
454 let temp_dir = TempDir::new().unwrap();
455 Command::new("git")
456 .current_dir(temp_dir.path())
457 .args(["init"])
458 .output()
459 .unwrap();
460 std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
462 Command::new("git")
463 .current_dir(temp_dir.path())
464 .args(["add", "."])
465 .output()
466 .unwrap();
467 Command::new("git")
468 .current_dir(temp_dir.path())
469 .args(["config", "user.email", "test@example.com"])
470 .output()
471 .unwrap();
472 Command::new("git")
473 .current_dir(temp_dir.path())
474 .args(["config", "user.name", "Test"])
475 .output()
476 .unwrap();
477 Command::new("git")
478 .current_dir(temp_dir.path())
479 .args(["commit", "-m", "Initial"])
480 .output()
481 .unwrap();
482
483 let git = Git::with_work_dir(temp_dir.path());
484 let result = git.notes_show("adr", "HEAD");
485 assert!(result.is_ok());
486 assert!(result.unwrap().is_none());
487 }
488
489 #[test]
490 fn test_repo_root() {
491 let temp_dir = TempDir::new().unwrap();
492 Command::new("git")
493 .current_dir(temp_dir.path())
494 .args(["init"])
495 .output()
496 .unwrap();
497 let git = Git::with_work_dir(temp_dir.path());
498 let root = git.repo_root();
499 assert!(root.is_ok());
500 }
501
502 #[test]
503 fn test_head_and_short_hash() {
504 let temp_dir = TempDir::new().unwrap();
505 Command::new("git")
506 .current_dir(temp_dir.path())
507 .args(["init"])
508 .output()
509 .unwrap();
510 std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
511 Command::new("git")
512 .current_dir(temp_dir.path())
513 .args(["add", "."])
514 .output()
515 .unwrap();
516 Command::new("git")
517 .current_dir(temp_dir.path())
518 .args(["config", "user.email", "test@example.com"])
519 .output()
520 .unwrap();
521 Command::new("git")
522 .current_dir(temp_dir.path())
523 .args(["config", "user.name", "Test"])
524 .output()
525 .unwrap();
526 Command::new("git")
527 .current_dir(temp_dir.path())
528 .args(["commit", "-m", "Initial"])
529 .output()
530 .unwrap();
531
532 let git = Git::with_work_dir(temp_dir.path());
533 let head = git.head().unwrap();
534 assert_eq!(head.len(), 40); let short = git.short_hash(&head).unwrap();
537 assert!(short.len() < head.len());
538 }
539}