1use std::path::PathBuf;
2
3use thiserror::Error;
4
5pub type OutpostResult<T> = Result<T, OutpostError>;
6
7#[derive(Debug, Error)]
8pub enum OutpostError {
9 #[error("not inside a Git repository: {}", .0.display())]
10 NotARepo(PathBuf),
11
12 #[error("not inside a managed outpost: {}", .0.display())]
13 NotAnOutpost(PathBuf),
14
15 #[error("source repository not found at {}", .0.display())]
16 SourceMissing(PathBuf),
17
18 #[error("{command} must be run from {expected}; effective cwd is {}", .cwd.display())]
19 WrongContext {
20 command: &'static str,
21 expected: &'static str,
22 cwd: PathBuf,
23 },
24
25 #[error("{command} requires <outpost> when run from source repository {}", .cwd.display())]
26 MissingOutpostPath { command: &'static str, cwd: PathBuf },
27
28 #[error("destination already exists: {}", .0.display())]
29 DestinationExists(PathBuf),
30
31 #[error("destination {} is inside an existing Git repository", .0.display())]
32 DestinationInsideRepo(PathBuf),
33
34 #[error("working tree is dirty in {}; {hint}", .repo.display())]
35 DirtyTree { repo: PathBuf, hint: &'static str },
36
37 #[error("branch {branch} has unpushed commits in {}; {hint}", .repo.display())]
38 UnpushedCommits {
39 repo: PathBuf,
40 branch: String,
41 hint: &'static str,
42 },
43
44 #[error("history diverges from source repository on branch {branch}")]
45 Divergence { branch: String },
46
47 #[error("branch not found: {branch} in {}", .repo.display())]
48 BranchNotFound { branch: String, repo: PathBuf },
49
50 #[error("no upstream tracking configured for branch {branch}")]
51 NoUpstreamTracking { branch: String },
52
53 #[error(
54 "upstream is not a branch ref (got {merge_ref}); cannot synchronize from a non-branch upstream"
55 )]
56 UpstreamNotABranch { merge_ref: String },
57
58 #[error("invalid ref name: {name}")]
59 InvalidRefName { name: String },
60
61 #[error(
62 "source repository {} has {branch} checked out; cannot push to a non-bare checked-out branch (configure receive.denyCurrentBranch=updateInstead on the source, or check out a different branch in the source)",
63 .r#source.display()
64 )]
65 PushIntoCheckedOutBranch { r#source: PathBuf, branch: String },
66
67 #[error("branch {branch} does not exist on the source repository")]
68 AmbiguousBranchCreation { branch: String },
69
70 #[error("outpost is locked: {}{reason}", .path.display())]
71 OutpostLocked { path: PathBuf, reason: String },
72
73 #[error("registry entry path is not a managed outpost of this source: {}", .0.display())]
74 RegistryEntryNotManaged(PathBuf),
75
76 #[error("registry entry not found: {}", .0.display())]
77 RegistryEntryNotFound(PathBuf),
78
79 #[error("invalid registry file at {}: {reason}", .path.display())]
80 BadRegistry { path: PathBuf, reason: String },
81
82 #[error("invalid outpost metadata at {}: {reason}", .outpost.display())]
83 BadMetadata { outpost: PathBuf, reason: String },
84
85 #[error("git command failed: `git {args}` (exit {code}): {stderr}")]
86 GitFailed {
87 args: String,
88 code: i32,
89 stderr: String,
90 },
91
92 #[error("git command terminated by signal: `git {args}`{signal_str}")]
93 GitTerminatedBySignal { args: String, signal_str: String },
94
95 #[error("io error at {}: {source}", .path.display())]
96 IoAt {
97 path: PathBuf,
98 source: std::io::Error,
99 },
100}
101
102impl OutpostError {
103 pub fn exit_code(&self) -> u8 {
104 use OutpostError::*;
105 match self {
106 NotARepo(_)
107 | NotAnOutpost(_)
108 | SourceMissing(_)
109 | WrongContext { .. }
110 | MissingOutpostPath { .. } => 2,
111 DestinationExists(_)
112 | DestinationInsideRepo(_)
113 | DirtyTree { .. }
114 | UnpushedCommits { .. }
115 | OutpostLocked { .. } => 3,
116 Divergence { .. }
117 | PushIntoCheckedOutBranch { .. }
118 | AmbiguousBranchCreation { .. } => 4,
119 BranchNotFound { .. }
120 | NoUpstreamTracking { .. }
121 | InvalidRefName { .. }
122 | UpstreamNotABranch { .. } => 5,
123 BadRegistry { .. }
124 | BadMetadata { .. }
125 | RegistryEntryNotManaged(_)
126 | RegistryEntryNotFound(_) => 6,
127 GitFailed { code, .. } => (*code).clamp(1, 125) as u8,
128 GitTerminatedBySignal { .. } => 137,
129 IoAt { .. } => 70,
130 }
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 fn path(value: &str) -> PathBuf {
139 PathBuf::from(value)
140 }
141
142 #[test]
143 fn display_strings_match_snapshot() {
144 let cases = [
145 (
146 OutpostError::NotARepo(path("/repo")),
147 "not inside a Git repository: /repo",
148 ),
149 (
150 OutpostError::NotAnOutpost(path("/outpost")),
151 "not inside a managed outpost: /outpost",
152 ),
153 (
154 OutpostError::SourceMissing(path("/source")),
155 "source repository not found at /source",
156 ),
157 (
158 OutpostError::WrongContext {
159 command: "pull",
160 expected: "a managed outpost",
161 cwd: path("/source"),
162 },
163 "pull must be run from a managed outpost; effective cwd is /source",
164 ),
165 (
166 OutpostError::MissingOutpostPath {
167 command: "lock",
168 cwd: path("/source"),
169 },
170 "lock requires <outpost> when run from source repository /source",
171 ),
172 (
173 OutpostError::DestinationExists(path("/dest")),
174 "destination already exists: /dest",
175 ),
176 (
177 OutpostError::DestinationInsideRepo(path("/dest")),
178 "destination /dest is inside an existing Git repository",
179 ),
180 (
181 OutpostError::DirtyTree {
182 repo: path("/repo"),
183 hint: "pass --force",
184 },
185 "working tree is dirty in /repo; pass --force",
186 ),
187 (
188 OutpostError::UnpushedCommits {
189 repo: path("/repo"),
190 branch: "main".to_owned(),
191 hint: "push first",
192 },
193 "branch main has unpushed commits in /repo; push first",
194 ),
195 (
196 OutpostError::Divergence {
197 branch: "main".to_owned(),
198 },
199 "history diverges from source repository on branch main",
200 ),
201 (
202 OutpostError::BranchNotFound {
203 branch: "feature".to_owned(),
204 repo: path("/repo"),
205 },
206 "branch not found: feature in /repo",
207 ),
208 (
209 OutpostError::NoUpstreamTracking {
210 branch: "feature".to_owned(),
211 },
212 "no upstream tracking configured for branch feature",
213 ),
214 (
215 OutpostError::UpstreamNotABranch {
216 merge_ref: "refs/tags/v1".to_owned(),
217 },
218 "upstream is not a branch ref (got refs/tags/v1); cannot synchronize from a non-branch upstream",
219 ),
220 (
221 OutpostError::InvalidRefName {
222 name: "-evil".to_owned(),
223 },
224 "invalid ref name: -evil",
225 ),
226 (
227 OutpostError::PushIntoCheckedOutBranch {
228 source: path("/source"),
229 branch: "main".to_owned(),
230 },
231 "source repository /source has main checked out; cannot push to a non-bare checked-out branch (configure receive.denyCurrentBranch=updateInstead on the source, or check out a different branch in the source)",
232 ),
233 (
234 OutpostError::AmbiguousBranchCreation {
235 branch: "feature".to_owned(),
236 },
237 "branch feature does not exist on the source repository",
238 ),
239 (
240 OutpostError::OutpostLocked {
241 path: path("/outpost"),
242 reason: ": release".to_owned(),
243 },
244 "outpost is locked: /outpost: release",
245 ),
246 (
247 OutpostError::RegistryEntryNotManaged(path("/outpost")),
248 "registry entry path is not a managed outpost of this source: /outpost",
249 ),
250 (
251 OutpostError::RegistryEntryNotFound(path("/missing")),
252 "registry entry not found: /missing",
253 ),
254 (
255 OutpostError::BadRegistry {
256 path: path("/repo/.outpost/registry.json"),
257 reason: "invalid json".to_owned(),
258 },
259 "invalid registry file at /repo/.outpost/registry.json: invalid json",
260 ),
261 (
262 OutpostError::BadMetadata {
263 outpost: path("/outpost"),
264 reason: "missing source".to_owned(),
265 },
266 "invalid outpost metadata at /outpost: missing source",
267 ),
268 (
269 OutpostError::GitFailed {
270 args: "status --short".to_owned(),
271 code: 1,
272 stderr: "fatal".to_owned(),
273 },
274 "git command failed: `git status --short` (exit 1): fatal",
275 ),
276 (
277 OutpostError::GitTerminatedBySignal {
278 args: "fetch".to_owned(),
279 signal_str: " (signal 9)".to_owned(),
280 },
281 "git command terminated by signal: `git fetch` (signal 9)",
282 ),
283 (
284 OutpostError::IoAt {
285 path: path("/repo/.outpost/registry.json"),
286 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
287 },
288 "io error at /repo/.outpost/registry.json: missing",
289 ),
290 ];
291
292 for (error, expected) in cases {
293 assert_eq!(error.to_string(), expected);
294 }
295 }
296
297 #[test]
298 fn exit_code_maps_each_variant() {
299 let cases = [
300 (OutpostError::NotARepo(path("/repo")), 2),
301 (OutpostError::NotAnOutpost(path("/outpost")), 2),
302 (OutpostError::SourceMissing(path("/source")), 2),
303 (
304 OutpostError::WrongContext {
305 command: "pull",
306 expected: "a managed outpost",
307 cwd: path("/source"),
308 },
309 2,
310 ),
311 (
312 OutpostError::MissingOutpostPath {
313 command: "lock",
314 cwd: path("/source"),
315 },
316 2,
317 ),
318 (OutpostError::DestinationExists(path("/dest")), 3),
319 (OutpostError::DestinationInsideRepo(path("/dest")), 3),
320 (
321 OutpostError::DirtyTree {
322 repo: path("/repo"),
323 hint: "pass --force",
324 },
325 3,
326 ),
327 (
328 OutpostError::UnpushedCommits {
329 repo: path("/repo"),
330 branch: "main".to_owned(),
331 hint: "push first",
332 },
333 3,
334 ),
335 (
336 OutpostError::Divergence {
337 branch: "main".to_owned(),
338 },
339 4,
340 ),
341 (
342 OutpostError::BranchNotFound {
343 branch: "feature".to_owned(),
344 repo: path("/repo"),
345 },
346 5,
347 ),
348 (
349 OutpostError::NoUpstreamTracking {
350 branch: "feature".to_owned(),
351 },
352 5,
353 ),
354 (
355 OutpostError::UpstreamNotABranch {
356 merge_ref: "refs/tags/v1".to_owned(),
357 },
358 5,
359 ),
360 (
361 OutpostError::InvalidRefName {
362 name: "-evil".to_owned(),
363 },
364 5,
365 ),
366 (
367 OutpostError::PushIntoCheckedOutBranch {
368 source: path("/source"),
369 branch: "main".to_owned(),
370 },
371 4,
372 ),
373 (
374 OutpostError::AmbiguousBranchCreation {
375 branch: "feature".to_owned(),
376 },
377 4,
378 ),
379 (
380 OutpostError::OutpostLocked {
381 path: path("/outpost"),
382 reason: ": release".to_owned(),
383 },
384 3,
385 ),
386 (OutpostError::RegistryEntryNotManaged(path("/outpost")), 6),
387 (OutpostError::RegistryEntryNotFound(path("/missing")), 6),
388 (
389 OutpostError::BadRegistry {
390 path: path("/repo/.outpost/registry.json"),
391 reason: "invalid json".to_owned(),
392 },
393 6,
394 ),
395 (
396 OutpostError::BadMetadata {
397 outpost: path("/outpost"),
398 reason: "missing source".to_owned(),
399 },
400 6,
401 ),
402 (
403 OutpostError::GitFailed {
404 args: "status".to_owned(),
405 code: 42,
406 stderr: "fatal".to_owned(),
407 },
408 42,
409 ),
410 (
411 OutpostError::GitTerminatedBySignal {
412 args: "fetch".to_owned(),
413 signal_str: " (signal 9)".to_owned(),
414 },
415 137,
416 ),
417 (
418 OutpostError::IoAt {
419 path: path("/repo/.outpost/registry.json"),
420 source: std::io::Error::new(std::io::ErrorKind::NotFound, "missing"),
421 },
422 70,
423 ),
424 ];
425
426 for (error, expected) in cases {
427 assert_eq!(error.exit_code(), expected);
428 }
429
430 assert_eq!(
431 OutpostError::GitFailed {
432 args: "status".to_owned(),
433 code: -1,
434 stderr: "fatal".to_owned(),
435 }
436 .exit_code(),
437 1
438 );
439 assert_eq!(
440 OutpostError::GitFailed {
441 args: "status".to_owned(),
442 code: 256,
443 stderr: "fatal".to_owned(),
444 }
445 .exit_code(),
446 125
447 );
448 }
449}