git_sync_rs/sync/
transport.rs1use 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}