Skip to main content

ag_git/
error.rs

1use std::time::Duration;
2
3use crate::rebase;
4
5/// Typed error returned by git infrastructure operations.
6///
7/// Wraps command execution failures, output parsing issues, and I/O errors so
8/// callers can distinguish error categories without parsing opaque strings.
9#[derive(Debug, thiserror::Error)]
10pub enum GitError {
11    /// A git subprocess exited with a non-zero status.
12    #[error("{command}: {stderr}")]
13    CommandFailed {
14        /// The git command that was executed (e.g. `"git rebase main"`).
15        command: String,
16        /// Human-readable detail extracted from stderr/stdout.
17        stderr: String,
18    },
19
20    /// A git subprocess exceeded its configured runtime bound.
21    #[error("{command} timed out after {timeout:?}")]
22    CommandTimedOut {
23        /// Git invocation that exceeded the timeout.
24        command: String,
25        /// Configured command timeout.
26        timeout: Duration,
27    },
28
29    /// Git command output could not be parsed into the expected structure.
30    #[error("{0}")]
31    OutputParse(String),
32
33    /// The requested repository or worktree is no longer available.
34    #[error("{detail}")]
35    RepositoryUnavailable {
36        /// Original repository-discovery failure detail.
37        detail: String,
38    },
39
40    /// A filesystem or process-spawn operation failed.
41    #[error("{0}")]
42    Io(#[from] std::io::Error),
43
44    /// A repository declares pre-commit validation but its Git hook is
45    /// unavailable.
46    #[error(
47        "pre-commit validation is configured by `{config_file}`, but the Git pre-commit hook is \
48         not installed or executable. Install it with one of these commands:\n\n  prek install\n  \
49         pre-commit install\n\nAgentty will continue for now, but missing configured hooks will \
50         become an error in a future release."
51    )]
52    PreCommitHookMissing {
53        /// Repository-root-relative configuration file that declares
54        /// validation.
55        config_file: String,
56    },
57
58    /// A `tokio::task::spawn_blocking` join failed.
59    #[error("Join error: {0}")]
60    Join(#[from] tokio::task::JoinError),
61}
62
63impl GitError {
64    /// Returns whether a failed command reports Git index-lock contention.
65    ///
66    /// This identifies the lock failure without implying that the lock is
67    /// stale or safe to remove.
68    #[must_use]
69    pub fn is_index_locked(&self) -> bool {
70        matches!(self, Self::CommandFailed { stderr, .. } if rebase::is_git_index_lock_error(stderr))
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn index_lock_classification_requires_a_matching_command_failure() {
80        // Arrange
81        let cases = [
82            (
83                "fatal: Unable to create '.git/index.lock': File exists.",
84                true,
85            ),
86            (
87                "fatal: Unable to create '.git/worktrees/session/index.lock': File exists.",
88                true,
89            ),
90            (
91                "fatal: Unable to create '.git/HEAD.lock': File exists.",
92                false,
93            ),
94            (
95                "fatal: Unable to create '.git/index.lock': Permission denied",
96                false,
97            ),
98            ("index.lock: another git process is running", true),
99            ("pre-commit hook rejected changes", false),
100            ("index.lock mentioned by a hook", false),
101        ];
102
103        for (stderr, expected) in cases {
104            let error = GitError::CommandFailed {
105                command: "git add -A".to_string(),
106                stderr: stderr.to_string(),
107            };
108
109            // Act / Assert
110            assert_eq!(error.is_index_locked(), expected, "{stderr}");
111        }
112
113        // Arrange
114        let error = GitError::OutputParse(cases[0].0.to_string());
115
116        // Act / Assert
117        assert!(!error.is_index_locked());
118    }
119
120    #[test]
121    fn command_failed_display_includes_command_and_stderr() {
122        // Arrange
123        let error = GitError::CommandFailed {
124            command: "git push origin main".to_string(),
125            stderr: "fatal: could not read Username".to_string(),
126        };
127
128        // Act
129        let display = error.to_string();
130
131        // Assert
132        assert!(matches!(
133            error,
134            GitError::CommandFailed {
135                ref command,
136                ref stderr,
137            } if command == "git push origin main" && stderr == "fatal: could not read Username"
138        ));
139        assert_eq!(
140            display,
141            "git push origin main: fatal: could not read Username"
142        );
143    }
144
145    #[test]
146    fn command_timed_out_display_includes_command_and_timeout() {
147        // Arrange
148        let error = GitError::CommandTimedOut {
149            command: "git worktree remove --force /tmp/worktree".to_string(),
150            timeout: Duration::from_secs(30),
151        };
152
153        // Act
154        let display = error.to_string();
155
156        // Assert
157        assert_eq!(
158            display,
159            "git worktree remove --force /tmp/worktree timed out after 30s"
160        );
161    }
162
163    #[test]
164    fn output_parse_display_shows_message() {
165        // Arrange
166        let error = GitError::OutputParse("unexpected rev-parse output".to_string());
167
168        // Act / Assert
169        assert!(
170            matches!(error, GitError::OutputParse(ref message) if message == "unexpected rev-parse output")
171        );
172        assert_eq!(error.to_string(), "unexpected rev-parse output");
173    }
174
175    #[test]
176    fn repository_unavailable_display_shows_original_detail() {
177        // Arrange
178        let error = GitError::RepositoryUnavailable {
179            detail: "git rev-parse: not a git repository".to_string(),
180        };
181
182        // Act
183        let display = error.to_string();
184
185        // Assert
186        assert_eq!(display, "git rev-parse: not a git repository");
187    }
188
189    #[test]
190    fn io_error_converts_via_from() {
191        // Arrange
192        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
193
194        // Act
195        let error = GitError::from(io_error);
196
197        // Assert
198        assert!(matches!(error, GitError::Io(_)));
199        assert!(error.to_string().contains("file missing"));
200    }
201
202    #[test]
203    fn pre_commit_hook_missing_display_explains_required_setup() {
204        // Arrange
205        let error = GitError::PreCommitHookMissing {
206            config_file: ".pre-commit-config.yaml".to_string(),
207        };
208
209        // Act
210        let display = error.to_string();
211
212        // Assert
213        assert!(display.contains(".pre-commit-config.yaml"));
214        assert!(display.contains("not installed or executable"));
215        assert!(display.contains("prek install"));
216        assert!(display.contains("pre-commit install"));
217        assert!(display.contains("will become an error in a future release"));
218    }
219}