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("git identity is not configured: {key} is not set")]
116 IdentityMissing {
117 key: String,
119 },
120
121 #[error("invalid transport configuration: {kind}")]
123 InvalidTransport {
124 kind: String,
126 },
127
128 #[error("network error: {0}")]
130 Network(String),
131
132 #[error("remote authentication failed: {message}")]
140 RemoteAuth {
141 message: String,
143 },
144
145 #[error("remote rejected push to {refname}: {reason}")]
152 PushRejected {
153 refname: String,
156 reason: String,
158 },
159
160 #[error("git CLI command failed: git {args:?}: {stderr}")]
162 CommandFailed {
163 args: Vec<String>,
165 exit_code: Option<i32>,
167 stdout: String,
169 stderr: String,
171 stdout_truncated: bool,
173 stderr_truncated: bool,
175 },
176
177 #[error("invalid object ID: {value}")]
179 InvalidOid {
180 value: String,
182 },
183
184 #[error("git operation not implemented: {operation}")]
186 NotImplemented {
187 operation: &'static str,
189 },
190
191 #[error(transparent)]
193 Internal(#[from] git2::Error),
194}
195
196impl From<GitError> for AppError {
197 fn from(error: GitError) -> Self {
198 match error {
199 GitError::NotFound { path } => {
200 let display = path.display().to_string();
201 AppError::not_found("repository", Some(&display))
202 }
203 GitError::RefNotFound { refname } => AppError::not_found("ref", Some(&refname)),
204 GitError::RemoteNotFound { name } => AppError::not_found("remote", Some(&name)),
205 GitError::ConfigNotFound { key } => AppError::not_found("config", Some(&key)),
206 GitError::AmbiguousRef { refname } => {
207 AppError::invalid_input("ref", format!("ambiguous ref: {refname}"))
208 }
209 GitError::AlreadyExists { kind, name } => {
210 AppError::already_exists(format!("{kind} '{name}'"))
211 }
212 GitError::CheckedOutBranch { name } => {
213 AppError::conflict(format!("branch is currently checked out: {name}"))
214 }
215 GitError::Conflict { path } => {
216 AppError::conflict(format!("merge conflict in {}", path.display()))
217 }
218 GitError::DetachedHead => AppError::invalid_input("HEAD", "detached HEAD"),
219 GitError::InvalidLineRange { start, end } => {
220 AppError::invalid_input("line range", format!("{start}..{end}"))
221 }
222 GitError::InvalidPath { path } => AppError::invalid_input("path", path),
223 GitError::InvalidConfigKey { key } => AppError::invalid_input("key", key),
224 GitError::NoMergeBase { a, b } => {
225 AppError::not_found("merge base", Some(&format!("{a}..{b}")))
226 }
227 GitError::SigningNotSupported => AppError::invalid_input(
228 "sign",
229 "commit signing is not supported by the selected backend",
230 ),
231 GitError::IdentityMissing { key } => {
232 AppError::invalid_input("git identity", format!("{key} is not configured"))
233 .hint(
234 "Set it with `git config user.name \"…\"` and \
235 `git config user.email \"…\"` (add --global to apply it for every repository).",
236 )
237 }
238 GitError::InvalidTransport { kind } => AppError::invalid_input("transport", kind),
239 GitError::Network(message) => {
240 AppError::external_service("git", io::Error::other(message))
241 }
242 GitError::RemoteAuth { message } => {
243 AppError::unauthorized(format!("git remote authentication failed: {message}"))
244 .hint(
245 "Check the remote credentials and that the token/key grants push access \
246 to this repository (e.g. on GitHub, a fine-grained token needs the \
247 `contents: write` permission).",
248 )
249 }
250 GitError::PushRejected { refname, reason } => {
251 AppError::conflict(format!("remote rejected push to {refname}: {reason}")).hint(
252 "The remote rejected the update. Integrate remote changes (fetch and rebase) \
253 for a non-fast-forward, or, if the branch is protected, land the commit \
254 through a pull request and push tags only.",
255 )
256 }
257 GitError::CommandFailed {
258 args,
259 exit_code,
260 stdout,
261 stderr,
262 stdout_truncated,
263 stderr_truncated,
264 } => {
265 let mut detail = format!("git {}: {}", args.join(" "), stderr);
266 if let Some(exit_code) = exit_code {
267 detail.push_str(&format!(" (exit code: {exit_code})"));
268 }
269 if stdout_truncated || stderr_truncated {
270 detail.push_str(&format!(
271 " (stdout_truncated: {stdout_truncated}, stderr_truncated: {stderr_truncated})"
272 ));
273 }
274 if !stdout.is_empty() {
275 detail.push_str("\nstdout: ");
276 detail.push_str(&stdout);
277 }
278 AppError::external_service("git", io::Error::other(detail))
279 }
280 GitError::InvalidOid { value } => AppError::invalid_input("oid", value),
281 GitError::NotImplemented { operation } => AppError::new(
282 ErrorCode::InvalidInput,
283 format!("git operation not supported: {operation}"),
284 ),
285 GitError::Internal(inner) => AppError::internal(inner),
286 }
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn git_errors_map_to_actionable_app_error_codes() {
296 let cases = [
297 (
298 GitError::NotFound {
299 path: PathBuf::from("repo"),
300 },
301 ErrorCode::NotFound,
302 ),
303 (
304 GitError::RefNotFound {
305 refname: "main".to_string(),
306 },
307 ErrorCode::NotFound,
308 ),
309 (
310 GitError::RemoteNotFound {
311 name: "origin".to_string(),
312 },
313 ErrorCode::NotFound,
314 ),
315 (
316 GitError::ConfigNotFound {
317 key: "user.name".to_string(),
318 },
319 ErrorCode::NotFound,
320 ),
321 (
322 GitError::AmbiguousRef {
323 refname: "feature".to_string(),
324 },
325 ErrorCode::InvalidInput,
326 ),
327 (
328 GitError::AlreadyExists {
329 kind: "branch",
330 name: "main".to_string(),
331 },
332 ErrorCode::AlreadyExists,
333 ),
334 (
335 GitError::CheckedOutBranch {
336 name: "main".to_string(),
337 },
338 ErrorCode::Conflict,
339 ),
340 (
341 GitError::Conflict {
342 path: PathBuf::from("src/lib.rs"),
343 },
344 ErrorCode::Conflict,
345 ),
346 (GitError::DetachedHead, ErrorCode::InvalidInput),
347 (
348 GitError::InvalidLineRange { start: 5, end: 3 },
349 ErrorCode::InvalidInput,
350 ),
351 (
352 GitError::InvalidPath {
353 path: "../outside".to_string(),
354 },
355 ErrorCode::InvalidInput,
356 ),
357 (
358 GitError::InvalidConfigKey {
359 key: "bad key".to_string(),
360 },
361 ErrorCode::InvalidInput,
362 ),
363 (
364 GitError::NoMergeBase {
365 a: "a".to_string(),
366 b: "b".to_string(),
367 },
368 ErrorCode::NotFound,
369 ),
370 (GitError::SigningNotSupported, ErrorCode::InvalidInput),
371 (
372 GitError::IdentityMissing {
373 key: "user.name".to_string(),
374 },
375 ErrorCode::InvalidInput,
376 ),
377 (
378 GitError::InvalidTransport {
379 kind: "ssh".to_string(),
380 },
381 ErrorCode::InvalidInput,
382 ),
383 (
384 GitError::Network("offline".to_string()),
385 ErrorCode::ExternalService,
386 ),
387 (
388 GitError::RemoteAuth {
389 message: "401 Unauthorized".to_string(),
390 },
391 ErrorCode::Unauthorized,
392 ),
393 (
394 GitError::PushRejected {
395 refname: "refs/heads/main".to_string(),
396 reason: "protected branch".to_string(),
397 },
398 ErrorCode::Conflict,
399 ),
400 (
401 GitError::CommandFailed {
402 args: vec!["status".to_string()],
403 exit_code: Some(128),
404 stdout: "partial stdout".to_string(),
405 stderr: "fatal".to_string(),
406 stdout_truncated: true,
407 stderr_truncated: false,
408 },
409 ErrorCode::ExternalService,
410 ),
411 (
412 GitError::InvalidOid {
413 value: "not-a-sha".to_string(),
414 },
415 ErrorCode::InvalidInput,
416 ),
417 (
418 GitError::NotImplemented { operation: "sign" },
419 ErrorCode::InvalidInput,
420 ),
421 (
422 GitError::Internal(git2::Error::from_str("git2 failed")),
423 ErrorCode::Internal,
424 ),
425 ];
426
427 for (git_error, expected) in cases {
428 let app_error = AppError::from(git_error);
429 assert_eq!(app_error.code(), expected);
430 }
431 }
432
433 #[test]
434 fn command_failure_message_includes_diagnostics() {
435 let app_error = AppError::from(GitError::CommandFailed {
436 args: vec!["push".to_string(), "origin".to_string()],
437 exit_code: Some(1),
438 stdout: "hint".to_string(),
439 stderr: "denied".to_string(),
440 stdout_truncated: false,
441 stderr_truncated: true,
442 });
443
444 let detail = app_error
445 .cause()
446 .as_ref()
447 .map(ToString::to_string)
448 .unwrap_or_else(|| app_error.message().to_string());
449 assert!(detail.contains("push origin"));
450 assert!(detail.contains("denied"));
451 assert!(detail.contains("exit code: 1"));
452 assert!(detail.contains("stderr_truncated: true"));
453 assert!(detail.contains("stdout: hint"));
454 }
455
456 #[test]
457 fn identity_missing_message_is_actionable() {
458 let app_error = AppError::from(GitError::IdentityMissing {
459 key: "user.email".to_string(),
460 });
461
462 assert_eq!(app_error.code(), ErrorCode::InvalidInput);
463 let message = app_error.message();
464 assert!(message.contains("user.email"));
465 assert!(message.contains("git config user.name"));
466 assert!(message.contains("git config user.email"));
467 }
468
469 #[test]
470 fn remote_auth_message_is_actionable() {
471 let app_error = AppError::from(GitError::RemoteAuth {
472 message: "403 Forbidden".to_string(),
473 });
474
475 assert_eq!(app_error.code(), ErrorCode::Unauthorized);
476 assert!(app_error.message().contains("403 Forbidden"));
477 }
478
479 #[test]
480 fn push_rejected_message_names_ref_and_reason() {
481 let app_error = AppError::from(GitError::PushRejected {
482 refname: "refs/heads/main".to_string(),
483 reason: "protected branch hook declined".to_string(),
484 });
485
486 assert_eq!(app_error.code(), ErrorCode::Conflict);
487 let message = app_error.message();
488 assert!(message.contains("refs/heads/main"));
489 assert!(message.contains("protected branch hook declined"));
490 }
491}