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    /// A filesystem or process-spawn operation failed.
32    #[error("{0}")]
33    Io(#[from] std::io::Error),
34
35    /// A repository declares pre-commit validation but its Git hook is
36    /// unavailable.
37    #[error(
38        "pre-commit validation is configured by `{config_file}`, but the Git pre-commit hook is \
39         not installed or executable. Install it with one of these commands:\n\n  prek install\n  \
40         pre-commit install\n\nAgentty will continue for now, but missing configured hooks will \
41         become an error in a future release."
42    )]
43    PreCommitHookMissing {
44        /// Repository-root-relative configuration file that declares
45        /// validation.
46        config_file: String,
47    },
48
49    /// A `tokio::task::spawn_blocking` join failed.
50    #[error("Join error: {0}")]
51    Join(#[from] tokio::task::JoinError),
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn command_failed_display_includes_command_and_stderr() {
60        // Arrange
61        let error = GitError::CommandFailed {
62            command: "git push origin main".to_string(),
63            stderr: "fatal: could not read Username".to_string(),
64        };
65
66        // Act
67        let display = error.to_string();
68
69        // Assert
70        assert!(matches!(
71            error,
72            GitError::CommandFailed {
73                ref command,
74                ref stderr,
75            } if command == "git push origin main" && stderr == "fatal: could not read Username"
76        ));
77        assert_eq!(
78            display,
79            "git push origin main: fatal: could not read Username"
80        );
81    }
82
83    #[test]
84    fn command_timed_out_display_includes_command_and_timeout() {
85        // Arrange
86        let error = GitError::CommandTimedOut {
87            command: "git worktree remove --force /tmp/worktree".to_string(),
88            timeout: Duration::from_secs(30),
89        };
90
91        // Act
92        let display = error.to_string();
93
94        // Assert
95        assert_eq!(
96            display,
97            "git worktree remove --force /tmp/worktree timed out after 30s"
98        );
99    }
100
101    #[test]
102    fn output_parse_display_shows_message() {
103        // Arrange
104        let error = GitError::OutputParse("unexpected rev-parse output".to_string());
105
106        // Act / Assert
107        assert!(
108            matches!(error, GitError::OutputParse(ref message) if message == "unexpected rev-parse output")
109        );
110        assert_eq!(error.to_string(), "unexpected rev-parse output");
111    }
112
113    #[test]
114    fn io_error_converts_via_from() {
115        // Arrange
116        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
117
118        // Act
119        let error = GitError::from(io_error);
120
121        // Assert
122        assert!(matches!(error, GitError::Io(_)));
123        assert!(error.to_string().contains("file missing"));
124    }
125
126    #[test]
127    fn pre_commit_hook_missing_display_explains_required_setup() {
128        // Arrange
129        let error = GitError::PreCommitHookMissing {
130            config_file: ".pre-commit-config.yaml".to_string(),
131        };
132
133        // Act
134        let display = error.to_string();
135
136        // Assert
137        assert!(display.contains(".pre-commit-config.yaml"));
138        assert!(display.contains("not installed or executable"));
139        assert!(display.contains("prek install"));
140        assert!(display.contains("pre-commit install"));
141        assert!(display.contains("will become an error in a future release"));
142    }
143}