use std::io;
use std::path::PathBuf;
use rskit_errors::{AppError, ErrorCode};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GitError {
#[error("repository not found at {path}")]
NotFound {
path: PathBuf,
},
#[error("ref not found: {refname}")]
RefNotFound {
refname: String,
},
#[error("remote not found: {name}")]
RemoteNotFound {
name: String,
},
#[error("config key not found: {key}")]
ConfigNotFound {
key: String,
},
#[error("ambiguous ref: {refname}")]
AmbiguousRef {
refname: String,
},
#[error("{kind} already exists: {name}")]
AlreadyExists {
kind: &'static str,
name: String,
},
#[error("branch is currently checked out: {name}")]
CheckedOutBranch {
name: String,
},
#[error("merge conflict in {path}")]
Conflict {
path: PathBuf,
},
#[error("detached HEAD")]
DetachedHead,
#[error("invalid line range: {start}..{end}")]
InvalidLineRange {
start: usize,
end: usize,
},
#[error("invalid path: {path}")]
InvalidPath {
path: String,
},
#[error("invalid config key: {key}")]
InvalidConfigKey {
key: String,
},
#[error("no merge base found between {a} and {b}")]
NoMergeBase {
a: String,
b: String,
},
#[error("commit signing is not supported by the selected backend")]
SigningNotSupported,
#[error("invalid transport configuration: {kind}")]
InvalidTransport {
kind: String,
},
#[error("network error: {0}")]
Network(String),
#[error("git CLI command failed: git {args:?}: {stderr}")]
CommandFailed {
args: Vec<String>,
exit_code: Option<i32>,
stdout: String,
stderr: String,
stdout_truncated: bool,
stderr_truncated: bool,
},
#[error("invalid object ID: {value}")]
InvalidOid {
value: String,
},
#[error("git operation not implemented: {operation}")]
NotImplemented {
operation: &'static str,
},
#[error(transparent)]
Internal(#[from] git2::Error),
}
impl From<GitError> for AppError {
fn from(error: GitError) -> Self {
match error {
GitError::NotFound { path } => {
let display = path.display().to_string();
AppError::not_found("repository", Some(&display))
}
GitError::RefNotFound { refname } => AppError::not_found("ref", Some(&refname)),
GitError::RemoteNotFound { name } => AppError::not_found("remote", Some(&name)),
GitError::ConfigNotFound { key } => AppError::not_found("config", Some(&key)),
GitError::AmbiguousRef { refname } => {
AppError::invalid_input("ref", format!("ambiguous ref: {refname}"))
}
GitError::AlreadyExists { kind, name } => {
AppError::already_exists(format!("{kind} '{name}'"))
}
GitError::CheckedOutBranch { name } => {
AppError::conflict(format!("branch is currently checked out: {name}"))
}
GitError::Conflict { path } => {
AppError::conflict(format!("merge conflict in {}", path.display()))
}
GitError::DetachedHead => AppError::invalid_input("HEAD", "detached HEAD"),
GitError::InvalidLineRange { start, end } => {
AppError::invalid_input("line range", format!("{start}..{end}"))
}
GitError::InvalidPath { path } => AppError::invalid_input("path", path),
GitError::InvalidConfigKey { key } => AppError::invalid_input("key", key),
GitError::NoMergeBase { a, b } => {
AppError::not_found("merge base", Some(&format!("{a}..{b}")))
}
GitError::SigningNotSupported => AppError::invalid_input(
"sign",
"commit signing is not supported by the selected backend",
),
GitError::InvalidTransport { kind } => AppError::invalid_input("transport", kind),
GitError::Network(message) => {
AppError::external_service("git", io::Error::other(message))
}
GitError::CommandFailed {
args,
exit_code,
stdout,
stderr,
stdout_truncated,
stderr_truncated,
} => {
let mut detail = format!("git {}: {}", args.join(" "), stderr);
if let Some(exit_code) = exit_code {
detail.push_str(&format!(" (exit code: {exit_code})"));
}
if stdout_truncated || stderr_truncated {
detail.push_str(&format!(
" (stdout_truncated: {stdout_truncated}, stderr_truncated: {stderr_truncated})"
));
}
if !stdout.is_empty() {
detail.push_str("\nstdout: ");
detail.push_str(&stdout);
}
AppError::external_service("git", io::Error::other(detail))
}
GitError::InvalidOid { value } => AppError::invalid_input("oid", value),
GitError::NotImplemented { operation } => AppError::new(
ErrorCode::InvalidInput,
format!("git operation not supported: {operation}"),
),
GitError::Internal(inner) => AppError::internal(inner),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_errors_map_to_actionable_app_error_codes() {
let cases = [
(
GitError::NotFound {
path: PathBuf::from("repo"),
},
ErrorCode::NotFound,
),
(
GitError::RefNotFound {
refname: "main".to_string(),
},
ErrorCode::NotFound,
),
(
GitError::RemoteNotFound {
name: "origin".to_string(),
},
ErrorCode::NotFound,
),
(
GitError::ConfigNotFound {
key: "user.name".to_string(),
},
ErrorCode::NotFound,
),
(
GitError::AmbiguousRef {
refname: "feature".to_string(),
},
ErrorCode::InvalidInput,
),
(
GitError::AlreadyExists {
kind: "branch",
name: "main".to_string(),
},
ErrorCode::AlreadyExists,
),
(
GitError::CheckedOutBranch {
name: "main".to_string(),
},
ErrorCode::Conflict,
),
(
GitError::Conflict {
path: PathBuf::from("src/lib.rs"),
},
ErrorCode::Conflict,
),
(GitError::DetachedHead, ErrorCode::InvalidInput),
(
GitError::InvalidLineRange { start: 5, end: 3 },
ErrorCode::InvalidInput,
),
(
GitError::InvalidPath {
path: "../outside".to_string(),
},
ErrorCode::InvalidInput,
),
(
GitError::InvalidConfigKey {
key: "bad key".to_string(),
},
ErrorCode::InvalidInput,
),
(
GitError::NoMergeBase {
a: "a".to_string(),
b: "b".to_string(),
},
ErrorCode::NotFound,
),
(GitError::SigningNotSupported, ErrorCode::InvalidInput),
(
GitError::InvalidTransport {
kind: "ssh".to_string(),
},
ErrorCode::InvalidInput,
),
(
GitError::Network("offline".to_string()),
ErrorCode::ExternalService,
),
(
GitError::CommandFailed {
args: vec!["status".to_string()],
exit_code: Some(128),
stdout: "partial stdout".to_string(),
stderr: "fatal".to_string(),
stdout_truncated: true,
stderr_truncated: false,
},
ErrorCode::ExternalService,
),
(
GitError::InvalidOid {
value: "not-a-sha".to_string(),
},
ErrorCode::InvalidInput,
),
(
GitError::NotImplemented { operation: "sign" },
ErrorCode::InvalidInput,
),
(
GitError::Internal(git2::Error::from_str("git2 failed")),
ErrorCode::Internal,
),
];
for (git_error, expected) in cases {
let app_error = AppError::from(git_error);
assert_eq!(app_error.code(), expected);
}
}
#[test]
fn command_failure_message_includes_diagnostics() {
let app_error = AppError::from(GitError::CommandFailed {
args: vec!["push".to_string(), "origin".to_string()],
exit_code: Some(1),
stdout: "hint".to_string(),
stderr: "denied".to_string(),
stdout_truncated: false,
stderr_truncated: true,
});
let detail = app_error
.cause()
.as_ref()
.map(ToString::to_string)
.unwrap_or_else(|| app_error.message().to_string());
assert!(detail.contains("push origin"));
assert!(detail.contains("denied"));
assert!(detail.contains("exit code: 1"));
assert!(detail.contains("stderr_truncated: true"));
assert!(detail.contains("stdout: hint"));
}
}