1use anyhow::{Context, Result};
16use std::path::Path;
17use std::process::Command;
18use thiserror::Error;
19
20use crate::runutil::{
21 ManagedCommand, TimeoutClass, execute_checked_command, execute_managed_command,
22};
23
24#[derive(Error, Debug)]
26pub enum GitError {
27 #[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
28 DirtyRepo { details: String },
29
30 #[error("git {args} failed (code={code:?}): {stderr}")]
31 CommandFailed {
32 args: String,
33 code: Option<i32>,
34 stderr: String,
35 },
36
37 #[error(
38 "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>."
39 )]
40 NoUpstream,
41
42 #[error(
43 "git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
44 )]
45 AuthFailed,
46
47 #[error("git push failed: {0}")]
48 PushFailed(String),
49
50 #[error("commit message is empty")]
51 EmptyCommitMessage,
52
53 #[error("no changes to commit")]
54 NoChangesToCommit,
55
56 #[error("no upstream configured for current branch")]
57 NoUpstreamConfigured,
58
59 #[error("unexpected rev-list output: {0}")]
60 UnexpectedRevListOutput(String),
61
62 #[error("Git LFS filter misconfigured: {details}")]
63 LfsFilterMisconfigured { details: String },
64
65 #[error(transparent)]
66 Other(#[from] anyhow::Error),
67}
68
69pub fn classify_push_error(stderr: &str) -> GitError {
71 let raw = stderr.trim();
72 let lower = raw.to_lowercase();
73
74 if lower.contains("no upstream")
75 || lower.contains("set-upstream")
76 || lower.contains("set the remote as upstream")
77 || (lower.contains("@{u}")
78 && (lower.contains("ambiguous argument")
79 || lower.contains("unknown revision")
80 || lower.contains("unknown revision or path")))
81 {
82 return GitError::NoUpstream;
83 }
84
85 if lower.contains("permission denied")
86 || lower.contains("authentication failed")
87 || lower.contains("access denied")
88 || lower.contains("could not read from remote repository")
89 || lower.contains("repository not found")
90 {
91 return GitError::AuthFailed;
92 }
93
94 let detail = if raw.is_empty() {
95 "unknown git error".to_string()
96 } else {
97 raw.to_string()
98 };
99 GitError::PushFailed(detail)
100}
101
102pub fn git_base_command(repo_root: &Path) -> Command {
109 let mut cmd = Command::new("git");
110 cmd.arg("-c").arg("core.fsmonitor=false");
111 cmd.arg("-C").arg(repo_root);
112 cmd
113}
114
115pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
117 let output = git_output(repo_root, args)
118 .with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
119
120 if output.status.success() {
121 return Ok(());
122 }
123
124 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
125 Err(GitError::CommandFailed {
126 args: args.join(" "),
127 code: output.status.code(),
128 stderr: stderr.trim().to_string(),
129 })
130}
131
132#[allow(dead_code)]
135#[derive(Debug, Clone)]
136pub(crate) enum GitMergeOutcome {
137 Clean,
139 Conflicts { stderr: String },
141}
142
143#[allow(dead_code)]
155pub(crate) fn git_merge_allow_conflicts(
156 repo_root: &Path,
157 merge_target: &str,
158) -> Result<GitMergeOutcome, GitError> {
159 let output = git_output(repo_root, &["merge", merge_target])
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
183pub(crate) fn git_output(
184 repo_root: &Path,
185 args: &[&str],
186) -> Result<std::process::Output, GitError> {
187 #[cfg(test)]
188 let _path_guard = crate::testsupport::path::path_lock()
189 .lock()
190 .expect("path lock");
191
192 let mut command = git_base_command(repo_root);
193 command.args(args);
194 execute_managed_command(ManagedCommand::new(
195 command,
196 format!("git {}", args.join(" ")),
197 TimeoutClass::Git,
198 ))
199 .map(|output| output.into_output())
200 .map_err(anyhow::Error::from)
201 .map_err(GitError::from)
202}
203
204pub(crate) fn git_probe_stdout(repo_root: &Path, args: &[&str]) -> Result<String, GitError> {
205 #[cfg(test)]
206 let _path_guard = crate::testsupport::path::path_lock()
207 .lock()
208 .expect("path lock");
209
210 let mut command = git_base_command(repo_root);
211 command.args(args);
212 execute_checked_command(ManagedCommand::new(
213 command,
214 format!("git {} in {}", args.join(" "), repo_root.display()),
215 TimeoutClass::MetadataProbe,
216 ))
217 .map(|output| output.stdout_lossy())
218 .map_err(GitError::from)
219}
220
221pub(crate) fn git_head_commit(repo_root: &Path) -> Result<String, GitError> {
222 git_probe_stdout(repo_root, &["rev-parse", "HEAD"])
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::testsupport::git as git_test;
229 use anyhow::Result;
230 use tempfile::TempDir;
231
232 #[test]
233 fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
234 let stderr =
235 "fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
236 let err = classify_push_error(stderr);
237 assert!(matches!(err, GitError::NoUpstream));
238 }
239
240 #[test]
241 fn git_head_commit_returns_current_head() -> Result<()> {
242 let temp = TempDir::new()?;
243 git_test::init_repo(temp.path())?;
244 std::fs::write(temp.path().join("README.md"), "git helper")?;
245 git_test::commit_all(temp.path(), "init")?;
246
247 let expected = git_test::git_output(temp.path(), &["rev-parse", "HEAD"])?;
248 let actual = git_head_commit(temp.path())?;
249
250 assert_eq!(actual, expected);
251 Ok(())
252 }
253}