Skip to main content

git_sync_rs/sync/
transport.rs

1use crate::error::{Result, SyncError};
2use std::path::Path;
3use std::process::Command;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum CommitOutcome {
7    Created,
8    NoChanges,
9}
10
11pub trait GitTransport: Send + Sync {
12    fn fetch_branch(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()>;
13    fn push_refspec(&self, repo_path: &Path, remote: &str, refspec: &str) -> Result<()>;
14    fn push_branch_upstream(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()>;
15    fn commit(&self, repo_path: &Path, message: &str, skip_hooks: bool) -> Result<CommitOutcome>;
16}
17
18#[derive(Debug, Default)]
19pub struct CommandGitTransport;
20
21impl CommandGitTransport {
22    fn classify_git_error(
23        &self,
24        command: &str,
25        stderr: &str,
26        remote: Option<&str>,
27        branch: Option<&str>,
28    ) -> SyncError {
29        let stderr_lower = stderr.to_lowercase();
30
31        if stderr.contains("couldn't find remote ref")
32            || stderr.contains("fatal: couldn't find remote ref")
33        {
34            return SyncError::RemoteBranchNotFound {
35                remote: remote.unwrap_or("origin").to_string(),
36                branch: branch.unwrap_or("<unknown>").to_string(),
37            };
38        }
39
40        if stderr_lower.contains("authentication failed")
41            || stderr_lower.contains("permission denied")
42            || stderr_lower.contains("could not read from remote repository")
43        {
44            return SyncError::AuthenticationFailed {
45                operation: command.to_string(),
46            };
47        }
48
49        if command.contains("commit")
50            && (stderr_lower.contains("hook declined")
51                || stderr_lower.contains("pre-commit")
52                || stderr_lower.contains("pre-commit hook failed")
53                || stderr_lower.contains("commit-msg")
54                || stderr_lower.contains("commit-msg hook failed"))
55        {
56            return SyncError::HookRejected {
57                details: stderr.trim().to_string(),
58            };
59        }
60
61        SyncError::GitCommandFailed {
62            command: command.to_string(),
63            stderr: stderr.trim().to_string(),
64        }
65    }
66}
67
68impl GitTransport for CommandGitTransport {
69    fn fetch_branch(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
70        let output = Command::new("git")
71            .arg("fetch")
72            .arg(remote)
73            .arg(branch)
74            .current_dir(repo_path)
75            .output()
76            .map_err(|e| SyncError::Other(format!("Failed to run git fetch: {}", e)))?;
77
78        if output.status.success() {
79            return Ok(());
80        }
81
82        let stderr = String::from_utf8_lossy(&output.stderr);
83        Err(self.classify_git_error(
84            &format!("git fetch {} {}", remote, branch),
85            &stderr,
86            Some(remote),
87            Some(branch),
88        ))
89    }
90
91    fn push_refspec(&self, repo_path: &Path, remote: &str, refspec: &str) -> Result<()> {
92        let output = Command::new("git")
93            .arg("push")
94            .arg(remote)
95            .arg(refspec)
96            .current_dir(repo_path)
97            .output()
98            .map_err(|e| SyncError::Other(format!("Failed to run git push: {}", e)))?;
99
100        if output.status.success() {
101            return Ok(());
102        }
103
104        let stderr = String::from_utf8_lossy(&output.stderr);
105        Err(self.classify_git_error(
106            &format!("git push {} {}", remote, refspec),
107            &stderr,
108            Some(remote),
109            None,
110        ))
111    }
112
113    fn push_branch_upstream(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
114        let output = Command::new("git")
115            .arg("push")
116            .arg("-u")
117            .arg(remote)
118            .arg(branch)
119            .current_dir(repo_path)
120            .output()
121            .map_err(|e| SyncError::Other(format!("Failed to run git push: {}", e)))?;
122
123        if output.status.success() {
124            return Ok(());
125        }
126
127        let stderr = String::from_utf8_lossy(&output.stderr);
128        Err(self.classify_git_error(
129            &format!("git push -u {} {}", remote, branch),
130            &stderr,
131            Some(remote),
132            Some(branch),
133        ))
134    }
135
136    fn commit(&self, repo_path: &Path, message: &str, skip_hooks: bool) -> Result<CommitOutcome> {
137        let mut command = Command::new("git");
138        command.arg("commit");
139        if skip_hooks {
140            command.arg("--no-verify");
141        }
142        command.arg("-m").arg(message).current_dir(repo_path);
143
144        let output = command
145            .output()
146            .map_err(|e| SyncError::Other(format!("Failed to run git commit: {}", e)))?;
147
148        if output.status.success() {
149            return Ok(CommitOutcome::Created);
150        }
151
152        let stderr = String::from_utf8_lossy(&output.stderr);
153        let stdout = String::from_utf8_lossy(&output.stdout);
154        let combined = format!("{stderr}\n{stdout}");
155        let lower = combined.to_lowercase();
156
157        if lower.contains("nothing to commit")
158            || lower.contains("nothing added to commit")
159            || lower.contains("no changes added to commit")
160        {
161            return Ok(CommitOutcome::NoChanges);
162        }
163
164        Err(self.classify_git_error("git commit", &combined, None, None))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::CommandGitTransport;
171    use crate::error::SyncError;
172
173    #[test]
174    fn classifies_missing_remote_ref_errors() {
175        let transport = CommandGitTransport;
176        let err = transport.classify_git_error(
177            "git fetch origin feature",
178            "fatal: couldn't find remote ref feature",
179            Some("origin"),
180            Some("feature"),
181        );
182        assert!(matches!(
183            err,
184            SyncError::RemoteBranchNotFound {
185                ref remote,
186                ref branch
187            } if remote == "origin" && branch == "feature"
188        ));
189    }
190
191    #[test]
192    fn classifies_authentication_errors() {
193        let transport = CommandGitTransport;
194        let err = transport.classify_git_error(
195            "git push origin main:main",
196            "Permission denied (publickey).",
197            Some("origin"),
198            Some("main"),
199        );
200        assert!(matches!(err, SyncError::AuthenticationFailed { .. }));
201    }
202
203    #[test]
204    fn classifies_hook_rejections() {
205        let transport = CommandGitTransport;
206        let err = transport.classify_git_error(
207            "git commit",
208            "error: failed to push some refs\npre-commit hook failed",
209            None,
210            None,
211        );
212        assert!(matches!(err, SyncError::HookRejected { .. }));
213    }
214}