Skip to main content

outpost_core/
error.rs

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}