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}