1use anyhow::{Context, Result};
16use std::path::Path;
17use std::process::Command;
18use thiserror::Error;
19
20use crate::runutil::{ManagedCommand, TimeoutClass, execute_managed_command};
21
22#[derive(Error, Debug)]
24pub enum GitError {
25 #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
26 DirtyRepo { details: String },
27
28 #[error("git {args} failed (code={code:?}): {stderr}")]
29 CommandFailed {
30 args: String,
31 code: Option<i32>,
32 stderr: String,
33 },
34
35 #[error(
36 "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>."
37 )]
38 NoUpstream,
39
40 #[error(
41 "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
42 )]
43 AuthFailed,
44
45 #[error("git push failed: {0}")]
46 PushFailed(String),
47
48 #[error("commit message is empty")]
49 EmptyCommitMessage,
50
51 #[error("no changes to commit")]
52 NoChangesToCommit,
53
54 #[error("no upstream configured for current branch")]
55 NoUpstreamConfigured,
56
57 #[error("unexpected rev-list output: {0}")]
58 UnexpectedRevListOutput(String),
59
60 #[error("Git LFS filter misconfigured: {details}")]
61 LfsFilterMisconfigured { details: String },
62
63 #[error(transparent)]
64 Other(#[from] anyhow::Error),
65}
66
67pub fn classify_push_error(stderr: &str) -> GitError {
69 let raw = stderr.trim();
70 let lower = raw.to_lowercase();
71
72 if lower.contains("no upstream")
73 || lower.contains("set-upstream")
74 || lower.contains("set the remote as upstream")
75 || (lower.contains("@{u}")
76 && (lower.contains("ambiguous argument")
77 || lower.contains("unknown revision")
78 || lower.contains("unknown revision or path")))
79 {
80 return GitError::NoUpstream;
81 }
82
83 if lower.contains("permission denied")
84 || lower.contains("authentication failed")
85 || lower.contains("access denied")
86 || lower.contains("could not read from remote repository")
87 || lower.contains("repository not found")
88 {
89 return GitError::AuthFailed;
90 }
91
92 let detail = if raw.is_empty() {
93 "unknown git error".to_string()
94 } else {
95 raw.to_string()
96 };
97 GitError::PushFailed(detail)
98}
99
100pub fn git_base_command(repo_root: &Path) -> Command {
107 let mut cmd = Command::new("git");
108 cmd.arg("-c").arg("core.fsmonitor=false");
109 cmd.arg("-C").arg(repo_root);
110 cmd
111}
112
113pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
115 let output = git_output(repo_root, args)
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_output(repo_root, &["merge", merge_target])
158 .with_context(|| format!("run git merge {} in {}", merge_target, repo_root.display()))?;
159
160 if output.status.success() {
161 return Ok(GitMergeOutcome::Clean);
162 }
163
164 let code = output.status.code();
165 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
166
167 if code == Some(1) {
169 return Ok(GitMergeOutcome::Conflicts {
170 stderr: stderr.trim().to_string(),
171 });
172 }
173
174 Err(GitError::CommandFailed {
175 args: format!("merge {}", merge_target),
176 code,
177 stderr: stderr.trim().to_string(),
178 })
179}
180
181pub(crate) fn git_output(
182 repo_root: &Path,
183 args: &[&str],
184) -> Result<std::process::Output, GitError> {
185 let mut command = git_base_command(repo_root);
186 command.args(args);
187 execute_managed_command(ManagedCommand::new(
188 command,
189 format!("git {}", args.join(" ")),
190 TimeoutClass::Git,
191 ))
192 .map(|output| output.into_output())
193 .map_err(anyhow::Error::from)
194 .map_err(GitError::from)
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
203 let stderr =
204 "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
205 let err = classify_push_error(stderr);
206 assert!(matches!(err, GitError::NoUpstream));
207 }
208}