1use std::path::Path;
4use tokio::process::Command;
5use tracing::{debug, info};
6
7#[derive(Debug, Clone, Default)]
9pub struct GitRepo;
10
11impl GitRepo {
12 async fn run_git(args: &[&str], cwd: &Path) -> Result<String, crate::SyncError> {
13 debug!(args = ?args, cwd = %cwd.display(), "running git command");
14 let output = Command::new("git")
15 .args(args)
16 .current_dir(cwd)
17 .output()
18 .await
19 .map_err(|e| crate::SyncError::Git(format!("failed to run git: {e}")))?;
20
21 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
22 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
23
24 if output.status.success() {
25 Ok(stdout)
26 } else {
27 Err(crate::SyncError::Git(format!(
28 "git {} failed (exit {}): {}",
29 args.join(" "),
30 output.status.code().unwrap_or(-1),
31 stderr
32 )))
33 }
34 }
35}
36
37#[async_trait::async_trait]
38impl super::GitBackend for GitRepo {
39 async fn clone_repo(
40 &self,
41 url: &str,
42 local_path: &Path,
43 shallow: bool,
44 ) -> Result<(), crate::SyncError> {
45 let parent = local_path
46 .parent()
47 .ok_or_else(|| crate::SyncError::Git("invalid local path".to_string()))?;
48 tokio::fs::create_dir_all(parent).await?;
49
50 let local_str = local_path.to_string_lossy().to_string();
51 let mut args = vec!["clone"];
52 if shallow {
53 args.extend_from_slice(&["--depth", "1"]);
54 }
55 args.extend_from_slice(&[url, &local_str]);
56
57 info!(url = url, path = %local_path.display(), shallow = shallow, "cloning repository");
58
59 let output = Command::new("git")
60 .args(&args)
61 .output()
62 .await
63 .map_err(|e| crate::SyncError::Git(format!("failed to run git clone: {e}")))?;
64
65 if output.status.success() {
66 Ok(())
67 } else {
68 let stderr = String::from_utf8_lossy(&output.stderr);
69 Err(crate::SyncError::Git(format!("git clone failed: {stderr}")))
70 }
71 }
72
73 async fn pull(&self, local_path: &Path) -> Result<(), crate::SyncError> {
74 info!(path = %local_path.display(), "pulling latest changes");
75 Self::run_git(&["pull", "--rebase", "--autostash"], local_path).await?;
76 Ok(())
77 }
78
79 async fn push(&self, local_path: &Path, message: &str) -> Result<(), crate::SyncError> {
80 Self::run_git(&["add", "-A"], local_path).await?;
82
83 let status = Self::run_git(&["status", "--porcelain"], local_path).await?;
85 if status.trim().is_empty() {
86 info!("no changes to push");
87 return Ok(());
88 }
89
90 info!(message = message, "committing and pushing changes");
91 Self::run_git(&["commit", "-m", message], local_path).await?;
92 Self::run_git(&["push"], local_path).await?;
93 Ok(())
94 }
95
96 fn is_cloned(&self, local_path: &Path) -> bool {
97 local_path.join(".git").is_dir()
98 }
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::backend::GitBackend;
105
106 #[test]
107 fn test_is_cloned_false_for_nonexistent() {
108 let repo = GitRepo;
109 assert!(!repo.is_cloned(Path::new("/nonexistent/path")));
110 }
111
112 #[tokio::test]
113 async fn test_clone_creates_parent_dirs() {
114 let tmp = tempfile::tempdir().unwrap();
115 let nested = tmp.path().join("a").join("b").join("repo");
116 let repo = GitRepo;
117
118 let result: Result<(), crate::SyncError> =
120 repo.clone_repo("invalid://url", &nested, true).await;
121 assert!(result.is_err());
122
123 assert!(nested.parent().unwrap().exists());
125 }
126}