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("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}