1use anyhow::{Context, Result};
16use std::path::Path;
17use std::process::Command;
18use thiserror::Error;
19
20#[derive(Error, Debug)]
22pub enum GitError {
23 #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
24 DirtyRepo { details: String },
25
26 #[error("git {args} failed (code={code:?}): {stderr}")]
27 CommandFailed {
28 args: String,
29 code: Option<i32>,
30 stderr: String,
31 },
32
33 #[error(
34 "git push failed: no upstream configured for current branch. Set it with: git push -u origin <branch> OR git branch --set-upstream-to origin/<branch>."
35 )]
36 NoUpstream,
37
38 #[error(
39 "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
40 )]
41 AuthFailed,
42
43 #[error("git push failed: {0}")]
44 PushFailed(String),
45
46 #[error("commit message is empty")]
47 EmptyCommitMessage,
48
49 #[error("no changes to commit")]
50 NoChangesToCommit,
51
52 #[error("no upstream configured for current branch")]
53 NoUpstreamConfigured,
54
55 #[error("unexpected rev-list output: {0}")]
56 UnexpectedRevListOutput(String),
57
58 #[error("Git LFS filter misconfigured: {details}")]
59 LfsFilterMisconfigured { details: String },
60
61 #[error(transparent)]
62 Other(#[from] anyhow::Error),
63}
64
65pub fn classify_push_error(stderr: &str) -> GitError {
67 let raw = stderr.trim();
68 let lower = raw.to_lowercase();
69
70 if lower.contains("no upstream")
71 || lower.contains("set-upstream")
72 || lower.contains("set the remote as upstream")
73 || (lower.contains("@{u}")
74 && (lower.contains("ambiguous argument")
75 || lower.contains("unknown revision")
76 || lower.contains("unknown revision or path")))
77 {
78 return GitError::NoUpstream;
79 }
80
81 if lower.contains("permission denied")
82 || lower.contains("authentication failed")
83 || lower.contains("access denied")
84 || lower.contains("could not read from remote repository")
85 || lower.contains("repository not found")
86 {
87 return GitError::AuthFailed;
88 }
89
90 let detail = if raw.is_empty() {
91 "unknown git error".to_string()
92 } else {
93 raw.to_string()
94 };
95 GitError::PushFailed(detail)
96}
97
98pub fn git_base_command(repo_root: &Path) -> Command {
105 let mut cmd = Command::new("git");
106 cmd.arg("-c").arg("core.fsmonitor=false");
107 cmd.arg("-C").arg(repo_root);
108 cmd
109}
110
111pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
113 let output = git_base_command(repo_root)
114 .args(args)
115 .output()
116 .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
117
118 if output.status.success() {
119 return Ok(());
120 }
121
122 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
123 Err(GitError::CommandFailed {
124 args: args.join(" "),
125 code: output.status.code(),
126 stderr: stderr.trim().to_string(),
127 })
128}
129
130#[allow(dead_code)]
133#[derive(Debug, Clone)]
134pub(crate) enum GitMergeOutcome {
135 Clean,
137 Conflicts { stderr: String },
139}
140
141#[allow(dead_code)]
153pub(crate) fn git_merge_allow_conflicts(
154 repo_root: &Path,
155 merge_target: &str,
156) -> Result<GitMergeOutcome, GitError> {
157 let output = git_base_command(repo_root)
158 .args(["merge", merge_target])
159 .output()
160 .with_context(|| format!("run git merge {} in {}", merge_target, repo_root.display()))?;
161
162 if output.status.success() {
163 return Ok(GitMergeOutcome::Clean);
164 }
165
166 let code = output.status.code();
167 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
168
169 if code == Some(1) {
171 return Ok(GitMergeOutcome::Conflicts {
172 stderr: stderr.trim().to_string(),
173 });
174 }
175
176 Err(GitError::CommandFailed {
177 args: format!("merge {}", merge_target),
178 code,
179 stderr: stderr.trim().to_string(),
180 })
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
189 let stderr =
190 "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
191 let err = classify_push_error(stderr);
192 assert!(matches!(err, GitError::NoUpstream));
193 }
194}