Skip to main content

sync_auth/backend/
git.rs

1//! Default Git backend implementation using the `git` CLI.
2
3use std::path::Path;
4use tokio::process::Command;
5use tracing::{debug, info};
6
7/// Default Git backend that shells out to the `git` command.
8#[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        // Stage all changes
81        Self::run_git(&["add", "-A"], local_path).await?;
82
83        // Check if there's anything to commit
84        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        // This will fail because URL is invalid, but parent dirs should be created
119        let result: Result<(), crate::SyncError> =
120            repo.clone_repo("invalid://url", &nested, true).await;
121        assert!(result.is_err());
122
123        // Parent directory should have been created
124        assert!(nested.parent().unwrap().exists());
125    }
126}