use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
use thiserror::Error;
use crate::runutil::{
ManagedCommand, TimeoutClass, execute_checked_command, execute_managed_command,
};
#[derive(Error, Debug)]
pub enum GitError {
#[error("repo is dirty; commit/stash your changes before running Ralph.{details}")]
DirtyRepo { details: String },
#[error("git {args} failed (code={code:?}): {stderr}")]
CommandFailed {
args: String,
code: Option<i32>,
stderr: String,
},
#[error(
"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>."
)]
NoUpstream,
#[error(
"git push failed: authentication/permission denied. Verify the remote URL, credentials, and that you have push access."
)]
AuthFailed,
#[error("git push failed: {0}")]
PushFailed(String),
#[error("commit message is empty")]
EmptyCommitMessage,
#[error("no changes to commit")]
NoChangesToCommit,
#[error("no upstream configured for current branch")]
NoUpstreamConfigured,
#[error("unexpected rev-list output: {0}")]
UnexpectedRevListOutput(String),
#[error("Git LFS filter misconfigured: {details}")]
LfsFilterMisconfigured { details: String },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub fn classify_push_error(stderr: &str) -> GitError {
let raw = stderr.trim();
let lower = raw.to_lowercase();
if lower.contains("no upstream")
|| lower.contains("set-upstream")
|| lower.contains("set the remote as upstream")
|| (lower.contains("@{u}")
&& (lower.contains("ambiguous argument")
|| lower.contains("unknown revision")
|| lower.contains("unknown revision or path")))
{
return GitError::NoUpstream;
}
if lower.contains("permission denied")
|| lower.contains("authentication failed")
|| lower.contains("access denied")
|| lower.contains("could not read from remote repository")
|| lower.contains("repository not found")
{
return GitError::AuthFailed;
}
let detail = if raw.is_empty() {
"unknown git error".to_string()
} else {
raw.to_string()
};
GitError::PushFailed(detail)
}
pub fn git_base_command(repo_root: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-c").arg("core.fsmonitor=false");
cmd.arg("-C").arg(repo_root);
cmd
}
pub fn git_run(repo_root: &Path, args: &[&str]) -> Result<(), GitError> {
let output = git_output(repo_root, args)
.with_context(|| format!("run git {} in {}", args.join(" "), repo_root.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(GitError::CommandFailed {
args: args.join(" "),
code: output.status.code(),
stderr: stderr.trim().to_string(),
})
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub(crate) enum GitMergeOutcome {
Clean,
Conflicts { stderr: String },
}
#[allow(dead_code)]
pub(crate) fn git_merge_allow_conflicts(
repo_root: &Path,
merge_target: &str,
) -> Result<GitMergeOutcome, GitError> {
let output = git_output(repo_root, &["merge", merge_target])
.with_context(|| format!("run git merge {} in {}", merge_target, repo_root.display()))?;
if output.status.success() {
return Ok(GitMergeOutcome::Clean);
}
let code = output.status.code();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if code == Some(1) {
return Ok(GitMergeOutcome::Conflicts {
stderr: stderr.trim().to_string(),
});
}
Err(GitError::CommandFailed {
args: format!("merge {}", merge_target),
code,
stderr: stderr.trim().to_string(),
})
}
pub(crate) fn git_output(
repo_root: &Path,
args: &[&str],
) -> Result<std::process::Output, GitError> {
#[cfg(test)]
let _path_guard = crate::testsupport::path::path_lock()
.lock()
.expect("path lock");
let mut command = git_base_command(repo_root);
command.args(args);
execute_managed_command(ManagedCommand::new(
command,
format!("git {}", args.join(" ")),
TimeoutClass::Git,
))
.map(|output| output.into_output())
.map_err(anyhow::Error::from)
.map_err(GitError::from)
}
pub(crate) fn git_probe_stdout(repo_root: &Path, args: &[&str]) -> Result<String, GitError> {
#[cfg(test)]
let _path_guard = crate::testsupport::path::path_lock()
.lock()
.expect("path lock");
let mut command = git_base_command(repo_root);
command.args(args);
execute_checked_command(ManagedCommand::new(
command,
format!("git {} in {}", args.join(" "), repo_root.display()),
TimeoutClass::MetadataProbe,
))
.map(|output| output.stdout_lossy())
.map_err(GitError::from)
}
pub(crate) fn git_head_commit(repo_root: &Path) -> Result<String, GitError> {
git_probe_stdout(repo_root, &["rev-parse", "HEAD"])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testsupport::git as git_test;
use anyhow::Result;
use tempfile::TempDir;
#[test]
fn classify_push_error_maps_ambiguous_upstream_to_no_upstream() {
let stderr =
"fatal: ambiguous argument '@{u}': unknown revision or path not in the working tree.";
let err = classify_push_error(stderr);
assert!(matches!(err, GitError::NoUpstream));
}
#[test]
fn git_head_commit_returns_current_head() -> Result<()> {
let temp = TempDir::new()?;
git_test::init_repo(temp.path())?;
std::fs::write(temp.path().join("README.md"), "git helper")?;
git_test::commit_all(temp.path(), "init")?;
let expected = git_test::git_output(temp.path(), &["rev-parse", "HEAD"])?;
let actual = git_head_commit(temp.path())?;
assert_eq!(actual, expected);
Ok(())
}
}