1use std::time::Duration;
2
3use crate::rebase;
4
5#[derive(Debug, thiserror::Error)]
10pub enum GitError {
11 #[error("{command}: {stderr}")]
13 CommandFailed {
14 command: String,
16 stderr: String,
18 },
19
20 #[error("{command} timed out after {timeout:?}")]
22 CommandTimedOut {
23 command: String,
25 timeout: Duration,
27 },
28
29 #[error("{0}")]
31 OutputParse(String),
32
33 #[error("{detail}")]
35 RepositoryUnavailable {
36 detail: String,
38 },
39
40 #[error("{0}")]
42 Io(#[from] std::io::Error),
43
44 #[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 config_file: String,
56 },
57
58 #[error("Join error: {0}")]
60 Join(#[from] tokio::task::JoinError),
61}
62
63impl GitError {
64 #[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 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 assert_eq!(error.is_index_locked(), expected, "{stderr}");
111 }
112
113 let error = GitError::OutputParse(cases[0].0.to_string());
115
116 assert!(!error.is_index_locked());
118 }
119
120 #[test]
121 fn command_failed_display_includes_command_and_stderr() {
122 let error = GitError::CommandFailed {
124 command: "git push origin main".to_string(),
125 stderr: "fatal: could not read Username".to_string(),
126 };
127
128 let display = error.to_string();
130
131 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 let error = GitError::CommandTimedOut {
149 command: "git worktree remove --force /tmp/worktree".to_string(),
150 timeout: Duration::from_secs(30),
151 };
152
153 let display = error.to_string();
155
156 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 let error = GitError::OutputParse("unexpected rev-parse output".to_string());
167
168 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 let error = GitError::RepositoryUnavailable {
179 detail: "git rev-parse: not a git repository".to_string(),
180 };
181
182 let display = error.to_string();
184
185 assert_eq!(display, "git rev-parse: not a git repository");
187 }
188
189 #[test]
190 fn io_error_converts_via_from() {
191 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
193
194 let error = GitError::from(io_error);
196
197 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 let error = GitError::PreCommitHookMissing {
206 config_file: ".pre-commit-config.yaml".to_string(),
207 };
208
209 let display = error.to_string();
211
212 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}