Skip to main content

git_sync_rs/sync/
transport.rs

1use crate::error::{Result, SyncError};
2use std::path::Path;
3use std::process::{Command, Output};
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 run_commit_command(
23        &self,
24        repo_path: &Path,
25        message: &str,
26        skip_hooks: bool,
27        identity: Option<(&str, &str)>,
28    ) -> std::io::Result<Output> {
29        let mut command = Command::new("git");
30        command.arg("commit");
31        if skip_hooks {
32            command.arg("--no-verify");
33        }
34        command.arg("-m").arg(message).current_dir(repo_path);
35
36        if let Some((name, email)) = identity {
37            command
38                .env("GIT_AUTHOR_NAME", name)
39                .env("GIT_AUTHOR_EMAIL", email)
40                .env("GIT_COMMITTER_NAME", name)
41                .env("GIT_COMMITTER_EMAIL", email);
42        }
43
44        command.output()
45    }
46
47    fn is_missing_identity_error(output: &str) -> bool {
48        let lower = output.to_lowercase();
49        lower.contains("author identity unknown")
50            || lower.contains("committer identity unknown")
51            || lower.contains("please tell me who you are")
52            || lower.contains("unable to auto-detect email address")
53            || lower.contains("empty ident name")
54            || lower.contains("empty ident email")
55    }
56
57    fn sanitize_email_component(value: &str) -> String {
58        let mut out = String::with_capacity(value.len());
59        for ch in value.chars() {
60            if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' || ch == '-' {
61                out.push(ch.to_ascii_lowercase());
62            } else {
63                out.push('-');
64            }
65        }
66        out.trim_matches('-').to_string()
67    }
68
69    fn fallback_commit_identity() -> (String, String) {
70        let user = std::env::var("USER")
71            .ok()
72            .filter(|u| !u.trim().is_empty())
73            .unwrap_or_else(|| "git-sync-rs".to_string());
74        let hostname = hostname::get()
75            .ok()
76            .map(|h| h.to_string_lossy().to_string())
77            .filter(|h| !h.trim().is_empty())
78            .unwrap_or_else(|| "localhost".to_string());
79
80        let local = Self::sanitize_email_component(&user);
81        let domain = Self::sanitize_email_component(&hostname);
82        let local = if local.is_empty() {
83            "git-sync-rs".to_string()
84        } else {
85            local
86        };
87        let domain = if domain.is_empty() {
88            "localhost".to_string()
89        } else {
90            domain
91        };
92
93        ("git-sync-rs".to_string(), format!("{local}@{domain}"))
94    }
95
96    fn parse_commit_output(output: &Output) -> std::result::Result<CommitOutcome, String> {
97        if output.status.success() {
98            return Ok(CommitOutcome::Created);
99        }
100
101        let stderr = String::from_utf8_lossy(&output.stderr);
102        let stdout = String::from_utf8_lossy(&output.stdout);
103        let combined = format!("{stderr}\n{stdout}");
104        let lower = combined.to_lowercase();
105
106        if lower.contains("nothing to commit")
107            || lower.contains("nothing added to commit")
108            || lower.contains("no changes added to commit")
109        {
110            return Ok(CommitOutcome::NoChanges);
111        }
112
113        Err(combined)
114    }
115
116    fn classify_git_error(
117        &self,
118        command: &str,
119        stderr: &str,
120        remote: Option<&str>,
121        branch: Option<&str>,
122    ) -> SyncError {
123        let stderr_lower = stderr.to_lowercase();
124
125        if stderr.contains("couldn't find remote ref")
126            || stderr.contains("fatal: couldn't find remote ref")
127        {
128            return SyncError::RemoteBranchNotFound {
129                remote: remote.unwrap_or("origin").to_string(),
130                branch: branch.unwrap_or("<unknown>").to_string(),
131            };
132        }
133
134        if stderr_lower.contains("authentication failed")
135            || stderr_lower.contains("permission denied")
136            || stderr_lower.contains("could not read from remote repository")
137        {
138            return SyncError::AuthenticationFailed {
139                operation: command.to_string(),
140            };
141        }
142
143        if command.contains("commit")
144            && (stderr_lower.contains("hook declined")
145                || stderr_lower.contains("pre-commit")
146                || stderr_lower.contains("pre-commit hook failed")
147                || stderr_lower.contains("commit-msg")
148                || stderr_lower.contains("commit-msg hook failed"))
149        {
150            return SyncError::HookRejected {
151                details: stderr.trim().to_string(),
152            };
153        }
154
155        SyncError::GitCommandFailed {
156            command: command.to_string(),
157            stderr: stderr.trim().to_string(),
158        }
159    }
160}
161
162impl GitTransport for CommandGitTransport {
163    fn fetch_branch(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
164        let output = Command::new("git")
165            .arg("fetch")
166            .arg(remote)
167            .arg(branch)
168            .current_dir(repo_path)
169            .output()
170            .map_err(|e| SyncError::Other(format!("Failed to run git fetch: {}", e)))?;
171
172        if output.status.success() {
173            return Ok(());
174        }
175
176        let stderr = String::from_utf8_lossy(&output.stderr);
177        Err(self.classify_git_error(
178            &format!("git fetch {} {}", remote, branch),
179            &stderr,
180            Some(remote),
181            Some(branch),
182        ))
183    }
184
185    fn push_refspec(&self, repo_path: &Path, remote: &str, refspec: &str) -> Result<()> {
186        let output = Command::new("git")
187            .arg("push")
188            .arg(remote)
189            .arg(refspec)
190            .current_dir(repo_path)
191            .output()
192            .map_err(|e| SyncError::Other(format!("Failed to run git push: {}", e)))?;
193
194        if output.status.success() {
195            return Ok(());
196        }
197
198        let stderr = String::from_utf8_lossy(&output.stderr);
199        Err(self.classify_git_error(
200            &format!("git push {} {}", remote, refspec),
201            &stderr,
202            Some(remote),
203            None,
204        ))
205    }
206
207    fn push_branch_upstream(&self, repo_path: &Path, remote: &str, branch: &str) -> Result<()> {
208        let output = Command::new("git")
209            .arg("push")
210            .arg("-u")
211            .arg(remote)
212            .arg(branch)
213            .current_dir(repo_path)
214            .output()
215            .map_err(|e| SyncError::Other(format!("Failed to run git push: {}", e)))?;
216
217        if output.status.success() {
218            return Ok(());
219        }
220
221        let stderr = String::from_utf8_lossy(&output.stderr);
222        Err(self.classify_git_error(
223            &format!("git push -u {} {}", remote, branch),
224            &stderr,
225            Some(remote),
226            Some(branch),
227        ))
228    }
229
230    fn commit(&self, repo_path: &Path, message: &str, skip_hooks: bool) -> Result<CommitOutcome> {
231        let output = self
232            .run_commit_command(repo_path, message, skip_hooks, None)
233            .map_err(|e| SyncError::Other(format!("Failed to run git commit: {}", e)))?;
234
235        match Self::parse_commit_output(&output) {
236            Ok(outcome) => Ok(outcome),
237            Err(combined) => {
238                if Self::is_missing_identity_error(&combined) {
239                    let (name, email) = Self::fallback_commit_identity();
240                    let retry = self
241                        .run_commit_command(repo_path, message, skip_hooks, Some((&name, &email)))
242                        .map_err(|e| {
243                            SyncError::Other(format!("Failed to rerun git commit: {}", e))
244                        })?;
245
246                    return match Self::parse_commit_output(&retry) {
247                        Ok(outcome) => Ok(outcome),
248                        Err(retry_combined) => {
249                            let combined_errors = format!(
250                                "git commit failed due to missing identity, fallback identity retry also failed.\n\ninitial:\n{}\n\nfallback retry:\n{}",
251                                combined.trim(),
252                                retry_combined.trim()
253                            );
254                            Err(self.classify_git_error("git commit", &combined_errors, None, None))
255                        }
256                    };
257                }
258
259                Err(self.classify_git_error("git commit", &combined, None, None))
260            }
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::{CommandGitTransport, CommitOutcome, GitTransport};
268    use crate::error::SyncError;
269    use std::process::Command;
270    use std::sync::{Mutex, OnceLock};
271    use tempfile::tempdir;
272
273    static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
274
275    #[test]
276    fn classifies_missing_remote_ref_errors() {
277        let transport = CommandGitTransport;
278        let err = transport.classify_git_error(
279            "git fetch origin feature",
280            "fatal: couldn't find remote ref feature",
281            Some("origin"),
282            Some("feature"),
283        );
284        assert!(matches!(
285            err,
286            SyncError::RemoteBranchNotFound {
287                ref remote,
288                ref branch
289            } if remote == "origin" && branch == "feature"
290        ));
291    }
292
293    #[test]
294    fn classifies_authentication_errors() {
295        let transport = CommandGitTransport;
296        let err = transport.classify_git_error(
297            "git push origin main:main",
298            "Permission denied (publickey).",
299            Some("origin"),
300            Some("main"),
301        );
302        assert!(matches!(err, SyncError::AuthenticationFailed { .. }));
303    }
304
305    #[test]
306    fn classifies_hook_rejections() {
307        let transport = CommandGitTransport;
308        let err = transport.classify_git_error(
309            "git commit",
310            "error: failed to push some refs\npre-commit hook failed",
311            None,
312            None,
313        );
314        assert!(matches!(err, SyncError::HookRejected { .. }));
315    }
316
317    #[test]
318    fn detects_missing_identity_errors() {
319        assert!(CommandGitTransport::is_missing_identity_error(
320            "Author identity unknown\nfatal: unable to auto-detect email address"
321        ));
322        assert!(CommandGitTransport::is_missing_identity_error(
323            "Please tell me who you are."
324        ));
325        assert!(CommandGitTransport::is_missing_identity_error(
326            "fatal: empty ident name (for <>) not allowed"
327        ));
328        assert!(!CommandGitTransport::is_missing_identity_error(
329            "nothing to commit, working tree clean"
330        ));
331    }
332
333    #[test]
334    fn commit_retries_with_fallback_identity_when_git_identity_missing() {
335        let _guard = ENV_LOCK
336            .get_or_init(|| Mutex::new(()))
337            .lock()
338            .expect("acquire environment mutation lock");
339
340        let temp = tempdir().expect("create tempdir");
341        let repo_path = temp.path().join("repo");
342        std::fs::create_dir(&repo_path).expect("create repo dir");
343
344        run_git(
345            temp.path(),
346            &["init", repo_path.to_str().expect("path utf8")],
347        );
348        std::fs::write(repo_path.join("file.txt"), "hello\n").expect("write test file");
349        run_git(&repo_path, &["add", "file.txt"]);
350
351        let old_home = std::env::var("HOME").ok();
352        let old_xdg_config_home = std::env::var("XDG_CONFIG_HOME").ok();
353        let old_git_config_global = std::env::var("GIT_CONFIG_GLOBAL").ok();
354        let old_git_config_nosystem = std::env::var("GIT_CONFIG_NOSYSTEM").ok();
355        let old_author_name = std::env::var("GIT_AUTHOR_NAME").ok();
356        let old_author_email = std::env::var("GIT_AUTHOR_EMAIL").ok();
357        let old_committer_name = std::env::var("GIT_COMMITTER_NAME").ok();
358        let old_committer_email = std::env::var("GIT_COMMITTER_EMAIL").ok();
359
360        let no_config_home = temp.path().join("empty-home");
361        std::fs::create_dir(&no_config_home).expect("create empty HOME");
362        std::env::set_var("HOME", &no_config_home);
363        std::env::set_var("XDG_CONFIG_HOME", &no_config_home);
364        std::env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
365        std::env::set_var("GIT_CONFIG_NOSYSTEM", "1");
366        std::env::remove_var("GIT_AUTHOR_NAME");
367        std::env::remove_var("GIT_AUTHOR_EMAIL");
368        std::env::remove_var("GIT_COMMITTER_NAME");
369        std::env::remove_var("GIT_COMMITTER_EMAIL");
370
371        let transport = CommandGitTransport;
372        let expected_identity = CommandGitTransport::fallback_commit_identity();
373        let result = transport
374            .commit(&repo_path, "auto commit message", false)
375            .expect("commit should succeed with fallback identity");
376        assert_eq!(result, CommitOutcome::Created);
377
378        let log_out = Command::new("git")
379            .args(["log", "-1", "--pretty=format:%an|%ae"])
380            .current_dir(&repo_path)
381            .output()
382            .expect("read commit identity");
383        assert!(log_out.status.success(), "git log failed");
384        let observed = String::from_utf8_lossy(&log_out.stdout).trim().to_string();
385        assert_eq!(
386            observed,
387            format!("{}|{}", expected_identity.0, expected_identity.1)
388        );
389
390        restore_env("HOME", old_home);
391        restore_env("XDG_CONFIG_HOME", old_xdg_config_home);
392        restore_env("GIT_CONFIG_GLOBAL", old_git_config_global);
393        restore_env("GIT_CONFIG_NOSYSTEM", old_git_config_nosystem);
394        restore_env("GIT_AUTHOR_NAME", old_author_name);
395        restore_env("GIT_AUTHOR_EMAIL", old_author_email);
396        restore_env("GIT_COMMITTER_NAME", old_committer_name);
397        restore_env("GIT_COMMITTER_EMAIL", old_committer_email);
398    }
399
400    fn run_git(cwd: &std::path::Path, args: &[&str]) {
401        let output = Command::new("git")
402            .args(args)
403            .current_dir(cwd)
404            .output()
405            .expect("run git command");
406        assert!(
407            output.status.success(),
408            "git {} failed: {}",
409            args.join(" "),
410            String::from_utf8_lossy(&output.stderr)
411        );
412    }
413
414    fn restore_env(name: &str, value: Option<String>) {
415        if let Some(v) = value {
416            std::env::set_var(name, v);
417        } else {
418            std::env::remove_var(name);
419        }
420    }
421}