Skip to main content

ag_git/
error.rs

1use std::time::Duration;
2
3/// Typed error returned by git infrastructure operations.
4///
5/// Wraps command execution failures, output parsing issues, and I/O errors so
6/// callers can distinguish error categories without parsing opaque strings.
7#[derive(Debug, thiserror::Error)]
8pub enum GitError {
9    /// A git subprocess exited with a non-zero status.
10    #[error("{command}: {stderr}")]
11    CommandFailed {
12        /// The git command that was executed (e.g. `"git rebase main"`).
13        command: String,
14        /// Human-readable detail extracted from stderr/stdout.
15        stderr: String,
16    },
17
18    /// A git subprocess exceeded its configured runtime bound.
19    #[error("{command} timed out after {timeout:?}")]
20    CommandTimedOut {
21        /// Git invocation that exceeded the timeout.
22        command: String,
23        /// Configured command timeout.
24        timeout: Duration,
25    },
26
27    /// Git command output could not be parsed into the expected structure.
28    #[error("{0}")]
29    OutputParse(String),
30
31    /// The requested repository or worktree is no longer available.
32    #[error("{detail}")]
33    RepositoryUnavailable {
34        /// Original repository-discovery failure detail.
35        detail: String,
36    },
37
38    /// A filesystem or process-spawn operation failed.
39    #[error("{0}")]
40    Io(#[from] std::io::Error),
41
42    /// A repository declares pre-commit validation but its Git hook is
43    /// unavailable.
44    #[error(
45        "pre-commit validation is configured by `{config_file}`, but the Git pre-commit hook is \
46         not installed or executable. Install it with one of these commands:\n\n  prek install\n  \
47         pre-commit install\n\nAgentty will continue for now, but missing configured hooks will \
48         become an error in a future release."
49    )]
50    PreCommitHookMissing {
51        /// Repository-root-relative configuration file that declares
52        /// validation.
53        config_file: String,
54    },
55
56    /// A `tokio::task::spawn_blocking` join failed.
57    #[error("Join error: {0}")]
58    Join(#[from] tokio::task::JoinError),
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn command_failed_display_includes_command_and_stderr() {
67        // Arrange
68        let error = GitError::CommandFailed {
69            command: "git push origin main".to_string(),
70            stderr: "fatal: could not read Username".to_string(),
71        };
72
73        // Act
74        let display = error.to_string();
75
76        // Assert
77        assert!(matches!(
78            error,
79            GitError::CommandFailed {
80                ref command,
81                ref stderr,
82            } if command == "git push origin main" && stderr == "fatal: could not read Username"
83        ));
84        assert_eq!(
85            display,
86            "git push origin main: fatal: could not read Username"
87        );
88    }
89
90    #[test]
91    fn command_timed_out_display_includes_command_and_timeout() {
92        // Arrange
93        let error = GitError::CommandTimedOut {
94            command: "git worktree remove --force /tmp/worktree".to_string(),
95            timeout: Duration::from_secs(30),
96        };
97
98        // Act
99        let display = error.to_string();
100
101        // Assert
102        assert_eq!(
103            display,
104            "git worktree remove --force /tmp/worktree timed out after 30s"
105        );
106    }
107
108    #[test]
109    fn output_parse_display_shows_message() {
110        // Arrange
111        let error = GitError::OutputParse("unexpected rev-parse output".to_string());
112
113        // Act / Assert
114        assert!(
115            matches!(error, GitError::OutputParse(ref message) if message == "unexpected rev-parse output")
116        );
117        assert_eq!(error.to_string(), "unexpected rev-parse output");
118    }
119
120    #[test]
121    fn repository_unavailable_display_shows_original_detail() {
122        // Arrange
123        let error = GitError::RepositoryUnavailable {
124            detail: "git rev-parse: not a git repository".to_string(),
125        };
126
127        // Act
128        let display = error.to_string();
129
130        // Assert
131        assert_eq!(display, "git rev-parse: not a git repository");
132    }
133
134    #[test]
135    fn io_error_converts_via_from() {
136        // Arrange
137        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
138
139        // Act
140        let error = GitError::from(io_error);
141
142        // Assert
143        assert!(matches!(error, GitError::Io(_)));
144        assert!(error.to_string().contains("file missing"));
145    }
146
147    #[test]
148    fn pre_commit_hook_missing_display_explains_required_setup() {
149        // Arrange
150        let error = GitError::PreCommitHookMissing {
151            config_file: ".pre-commit-config.yaml".to_string(),
152        };
153
154        // Act
155        let display = error.to_string();
156
157        // Assert
158        assert!(display.contains(".pre-commit-config.yaml"));
159        assert!(display.contains("not installed or executable"));
160        assert!(display.contains("prek install"));
161        assert!(display.contains("pre-commit install"));
162        assert!(display.contains("will become an error in a future release"));
163    }
164}