1use std::io;
4use std::path::PathBuf;
5
6use rskit_errors::{AppError, ErrorCode};
7
8#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum GitError {
12 #[error("repository not found at {path}")]
14 NotFound {
15 path: PathBuf,
17 },
18
19 #[error("ref not found: {refname}")]
21 RefNotFound {
22 refname: String,
24 },
25
26 #[error("remote not found: {name}")]
28 RemoteNotFound {
29 name: String,
31 },
32
33 #[error("config key not found: {key}")]
35 ConfigNotFound {
36 key: String,
38 },
39
40 #[error("ambiguous ref: {refname}")]
42 AmbiguousRef {
43 refname: String,
45 },
46
47 #[error("{kind} already exists: {name}")]
49 AlreadyExists {
50 kind: &'static str,
52 name: String,
54 },
55
56 #[error("branch is currently checked out: {name}")]
58 CheckedOutBranch {
59 name: String,
61 },
62
63 #[error("merge conflict in {path}")]
65 Conflict {
66 path: PathBuf,
68 },
69
70 #[error("detached HEAD")]
72 DetachedHead,
73
74 #[error("invalid line range: {start}..{end}")]
76 InvalidLineRange {
77 start: usize,
79 end: usize,
81 },
82
83 #[error("invalid path: {path}")]
85 InvalidPath {
86 path: String,
88 },
89
90 #[error("invalid config key: {key}")]
92 InvalidConfigKey {
93 key: String,
95 },
96
97 #[error("no merge base found between {a} and {b}")]
99 NoMergeBase {
100 a: String,
102 b: String,
104 },
105
106 #[error("commit signing is not supported by the selected backend")]
108 SigningNotSupported,
109
110 #[error("invalid transport configuration: {kind}")]
112 InvalidTransport {
113 kind: String,
115 },
116
117 #[error("network error: {0}")]
119 Network(String),
120
121 #[error("git CLI command failed: git {args:?}: {stderr}")]
123 CommandFailed {
124 args: Vec<String>,
126 exit_code: Option<i32>,
128 stdout: String,
130 stderr: String,
132 stdout_truncated: bool,
134 stderr_truncated: bool,
136 },
137
138 #[error("invalid object ID: {value}")]
140 InvalidOid {
141 value: String,
143 },
144
145 #[error("git operation not implemented: {operation}")]
147 NotImplemented {
148 operation: &'static str,
150 },
151
152 #[error(transparent)]
154 Internal(#[from] git2::Error),
155}
156
157impl From<GitError> for AppError {
158 fn from(error: GitError) -> Self {
159 match error {
160 GitError::NotFound { path } => {
161 let display = path.display().to_string();
162 AppError::not_found("repository", Some(&display))
163 }
164 GitError::RefNotFound { refname } => AppError::not_found("ref", Some(&refname)),
165 GitError::RemoteNotFound { name } => AppError::not_found("remote", Some(&name)),
166 GitError::ConfigNotFound { key } => AppError::not_found("config", Some(&key)),
167 GitError::AmbiguousRef { refname } => {
168 AppError::invalid_input("ref", format!("ambiguous ref: {refname}"))
169 }
170 GitError::AlreadyExists { kind, name } => {
171 AppError::already_exists(format!("{kind} '{name}'"))
172 }
173 GitError::CheckedOutBranch { name } => {
174 AppError::conflict(format!("branch is currently checked out: {name}"))
175 }
176 GitError::Conflict { path } => {
177 AppError::conflict(format!("merge conflict in {}", path.display()))
178 }
179 GitError::DetachedHead => AppError::invalid_input("HEAD", "detached HEAD"),
180 GitError::InvalidLineRange { start, end } => {
181 AppError::invalid_input("line range", format!("{start}..{end}"))
182 }
183 GitError::InvalidPath { path } => AppError::invalid_input("path", path),
184 GitError::InvalidConfigKey { key } => AppError::invalid_input("key", key),
185 GitError::NoMergeBase { a, b } => {
186 AppError::not_found("merge base", Some(&format!("{a}..{b}")))
187 }
188 GitError::SigningNotSupported => AppError::invalid_input(
189 "sign",
190 "commit signing is not supported by the selected backend",
191 ),
192 GitError::InvalidTransport { kind } => AppError::invalid_input("transport", kind),
193 GitError::Network(message) => {
194 AppError::external_service("git", io::Error::other(message))
195 }
196 GitError::CommandFailed {
197 args,
198 exit_code,
199 stdout,
200 stderr,
201 stdout_truncated,
202 stderr_truncated,
203 } => {
204 let mut detail = format!("git {}: {}", args.join(" "), stderr);
205 if let Some(exit_code) = exit_code {
206 detail.push_str(&format!(" (exit code: {exit_code})"));
207 }
208 if stdout_truncated || stderr_truncated {
209 detail.push_str(&format!(
210 " (stdout_truncated: {stdout_truncated}, stderr_truncated: {stderr_truncated})"
211 ));
212 }
213 if !stdout.is_empty() {
214 detail.push_str("\nstdout: ");
215 detail.push_str(&stdout);
216 }
217 AppError::external_service("git", io::Error::other(detail))
218 }
219 GitError::InvalidOid { value } => AppError::invalid_input("oid", value),
220 GitError::NotImplemented { operation } => AppError::new(
221 ErrorCode::InvalidInput,
222 format!("git operation not supported: {operation}"),
223 ),
224 GitError::Internal(inner) => AppError::internal(inner),
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn git_errors_map_to_actionable_app_error_codes() {
235 let cases = [
236 (
237 GitError::NotFound {
238 path: PathBuf::from("repo"),
239 },
240 ErrorCode::NotFound,
241 ),
242 (
243 GitError::RefNotFound {
244 refname: "main".to_string(),
245 },
246 ErrorCode::NotFound,
247 ),
248 (
249 GitError::RemoteNotFound {
250 name: "origin".to_string(),
251 },
252 ErrorCode::NotFound,
253 ),
254 (
255 GitError::ConfigNotFound {
256 key: "user.name".to_string(),
257 },
258 ErrorCode::NotFound,
259 ),
260 (
261 GitError::AmbiguousRef {
262 refname: "feature".to_string(),
263 },
264 ErrorCode::InvalidInput,
265 ),
266 (
267 GitError::AlreadyExists {
268 kind: "branch",
269 name: "main".to_string(),
270 },
271 ErrorCode::AlreadyExists,
272 ),
273 (
274 GitError::CheckedOutBranch {
275 name: "main".to_string(),
276 },
277 ErrorCode::Conflict,
278 ),
279 (
280 GitError::Conflict {
281 path: PathBuf::from("src/lib.rs"),
282 },
283 ErrorCode::Conflict,
284 ),
285 (GitError::DetachedHead, ErrorCode::InvalidInput),
286 (
287 GitError::InvalidLineRange { start: 5, end: 3 },
288 ErrorCode::InvalidInput,
289 ),
290 (
291 GitError::InvalidPath {
292 path: "../outside".to_string(),
293 },
294 ErrorCode::InvalidInput,
295 ),
296 (
297 GitError::InvalidConfigKey {
298 key: "bad key".to_string(),
299 },
300 ErrorCode::InvalidInput,
301 ),
302 (
303 GitError::NoMergeBase {
304 a: "a".to_string(),
305 b: "b".to_string(),
306 },
307 ErrorCode::NotFound,
308 ),
309 (GitError::SigningNotSupported, ErrorCode::InvalidInput),
310 (
311 GitError::InvalidTransport {
312 kind: "ssh".to_string(),
313 },
314 ErrorCode::InvalidInput,
315 ),
316 (
317 GitError::Network("offline".to_string()),
318 ErrorCode::ExternalService,
319 ),
320 (
321 GitError::CommandFailed {
322 args: vec!["status".to_string()],
323 exit_code: Some(128),
324 stdout: "partial stdout".to_string(),
325 stderr: "fatal".to_string(),
326 stdout_truncated: true,
327 stderr_truncated: false,
328 },
329 ErrorCode::ExternalService,
330 ),
331 (
332 GitError::InvalidOid {
333 value: "not-a-sha".to_string(),
334 },
335 ErrorCode::InvalidInput,
336 ),
337 (
338 GitError::NotImplemented { operation: "sign" },
339 ErrorCode::InvalidInput,
340 ),
341 (
342 GitError::Internal(git2::Error::from_str("git2 failed")),
343 ErrorCode::Internal,
344 ),
345 ];
346
347 for (git_error, expected) in cases {
348 let app_error = AppError::from(git_error);
349 assert_eq!(app_error.code(), expected);
350 }
351 }
352
353 #[test]
354 fn command_failure_message_includes_diagnostics() {
355 let app_error = AppError::from(GitError::CommandFailed {
356 args: vec!["push".to_string(), "origin".to_string()],
357 exit_code: Some(1),
358 stdout: "hint".to_string(),
359 stderr: "denied".to_string(),
360 stdout_truncated: false,
361 stderr_truncated: true,
362 });
363
364 let detail = app_error
365 .cause()
366 .as_ref()
367 .map(ToString::to_string)
368 .unwrap_or_else(|| app_error.message().to_string());
369 assert!(detail.contains("push origin"));
370 assert!(detail.contains("denied"));
371 assert!(detail.contains("exit code: 1"));
372 assert!(detail.contains("stderr_truncated: true"));
373 assert!(detail.contains("stdout: hint"));
374 }
375}