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