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    /// Unsupported transport configuration.
111    #[error("invalid transport configuration: {kind}")]
112    InvalidTransport {
113        /// Transport kind that could not be applied.
114        kind: String,
115    },
116
117    /// Network error during remote operations.
118    #[error("network error: {0}")]
119    Network(String),
120
121    /// Git CLI command failure.
122    #[error("git CLI command failed: git {args:?}: {stderr}")]
123    CommandFailed {
124        /// CLI arguments that were attempted.
125        args: Vec<String>,
126        /// Process exit code, if available.
127        exit_code: Option<i32>,
128        /// Standard output (may contain conflict diagnostics).
129        stdout: String,
130        /// Standard error output.
131        stderr: String,
132        /// Whether stdout exceeded the configured capture limit.
133        stdout_truncated: bool,
134        /// Whether stderr exceeded the configured capture limit.
135        stderr_truncated: bool,
136    },
137
138    /// Invalid object ID string.
139    #[error("invalid object ID: {value}")]
140    InvalidOid {
141        /// Invalid hex value.
142        value: String,
143    },
144
145    /// Operation is intentionally not implemented.
146    #[error("git operation not implemented: {operation}")]
147    NotImplemented {
148        /// Unimplemented operation name.
149        operation: &'static str,
150    },
151
152    /// Internal git2 error.
153    #[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}