Skip to main content

rskit_git/
error.rs

1//! Git-specific error types.
2
3use std::io;
4use std::path::PathBuf;
5
6use rskit_errors::{AppError, ErrorCode};
7
8/// Git domain errors.
9#[derive(Debug, thiserror::Error)]
10#[non_exhaustive]
11pub enum GitError {
12    /// Repository not found at path.
13    #[error("repository not found at {path}")]
14    NotFound {
15        /// Filesystem path that did not contain a git repository.
16        path: PathBuf,
17    },
18
19    /// Git reference not found.
20    #[error("ref not found: {refname}")]
21    RefNotFound {
22        /// Missing reference name.
23        refname: String,
24    },
25
26    /// Remote not found.
27    #[error("remote not found: {name}")]
28    RemoteNotFound {
29        /// Missing remote name.
30        name: String,
31    },
32
33    /// Config key not found.
34    #[error("config key not found: {key}")]
35    ConfigNotFound {
36        /// Missing config key.
37        key: String,
38    },
39
40    /// Ambiguous git reference.
41    #[error("ambiguous ref: {refname}")]
42    AmbiguousRef {
43        /// Reference name that resolved to multiple candidates.
44        refname: String,
45    },
46
47    /// Existing resource conflict.
48    #[error("{kind} already exists: {name}")]
49    AlreadyExists {
50        /// Existing resource kind.
51        kind: &'static str,
52        /// Existing resource name.
53        name: String,
54    },
55
56    /// Attempted to delete the currently checked out branch.
57    #[error("branch is currently checked out: {name}")]
58    CheckedOutBranch {
59        /// Branch name.
60        name: String,
61    },
62
63    /// Merge conflict in a file.
64    #[error("merge conflict in {path}")]
65    Conflict {
66        /// Path containing merge conflict markers or index conflicts.
67        path: PathBuf,
68    },
69
70    /// HEAD is detached (not pointing to a branch).
71    #[error("detached HEAD")]
72    DetachedHead,
73
74    /// Invalid blame line range.
75    #[error("invalid line range: {start}..{end}")]
76    InvalidLineRange {
77        /// One-based starting line.
78        start: usize,
79        /// One-based ending line.
80        end: usize,
81    },
82
83    /// Invalid repository path.
84    #[error("invalid path: {path}")]
85    InvalidPath {
86        /// Invalid repository-relative path.
87        path: String,
88    },
89
90    /// Invalid git config key.
91    #[error("invalid config key: {key}")]
92    InvalidConfigKey {
93        /// Invalid git config key.
94        key: String,
95    },
96
97    /// No merge base exists between the two commits.
98    #[error("no merge base found between {a} and {b}")]
99    NoMergeBase {
100        /// First commit reference.
101        a: String,
102        /// Second commit reference.
103        b: String,
104    },
105
106    /// Commit signing is not supported by the selected backend.
107    #[error("commit signing is not supported by the selected backend")]
108    SigningNotSupported,
109
110    /// Git author/committer identity is not configured.
111    ///
112    /// libgit2 cannot build a default signature because `user.name` and/or
113    /// `user.email` are unset in the effective git configuration. This is an
114    /// actionable configuration problem, not an internal failure.
115    #[error("git identity is not configured: {key} is not set")]
116    IdentityMissing {
117        /// Missing identity config key (for example `user.name` or `user.email`).
118        key: String,
119    },
120
121    /// Unsupported transport configuration.
122    #[error("invalid transport configuration: {kind}")]
123    InvalidTransport {
124        /// Transport kind that could not be applied.
125        kind: String,
126    },
127
128    /// Network error during remote operations.
129    #[error("network error: {0}")]
130    Network(String),
131
132    /// Authentication or authorization failure during a remote operation.
133    ///
134    /// libgit2 reports the transport-level failure (bad credentials, expired
135    /// token, insufficient scope/permission) via an `Auth` code or an
136    /// `Http`/`Ssh`/`Callback` class. This is an actionable access problem, not
137    /// an internal failure, so its (credential-redacted) message is surfaced to
138    /// the user.
139    #[error("remote authentication failed: {message}")]
140    RemoteAuth {
141        /// Credential-redacted description of the authentication failure.
142        message: String,
143    },
144
145    /// The remote rejected the push of one or more references.
146    ///
147    /// Covers a non-fast-forward update, a protected-branch/hook rejection, and
148    /// any other server-reported ref rejection. The rejection is reported by
149    /// the remote (not an internal failure), so the offending ref and the
150    /// (credential-redacted) reason are surfaced to the user.
151    #[error("remote rejected push to {refname}: {reason}")]
152    PushRejected {
153        /// Fully-qualified destination reference(s) the remote rejected; a
154        /// comma-separated list when several refs were pushed together.
155        refname: String,
156        /// Credential-redacted rejection reason reported by the remote.
157        reason: String,
158    },
159
160    /// Git CLI command failure.
161    #[error("git CLI command failed: git {args:?}: {stderr}")]
162    CommandFailed {
163        /// CLI arguments that were attempted.
164        args: Vec<String>,
165        /// Process exit code, if available.
166        exit_code: Option<i32>,
167        /// Standard output (may contain conflict diagnostics).
168        stdout: String,
169        /// Standard error output.
170        stderr: String,
171        /// Whether stdout exceeded the configured capture limit.
172        stdout_truncated: bool,
173        /// Whether stderr exceeded the configured capture limit.
174        stderr_truncated: bool,
175    },
176
177    /// Invalid object ID string.
178    #[error("invalid object ID: {value}")]
179    InvalidOid {
180        /// Invalid hex value.
181        value: String,
182    },
183
184    /// Operation is intentionally not implemented.
185    #[error("git operation not implemented: {operation}")]
186    NotImplemented {
187        /// Unimplemented operation name.
188        operation: &'static str,
189    },
190
191    /// Internal git2 error.
192    #[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}