Skip to main content

majordomus_cli/worktree/
error.rs

1//! What can stop a worktree operation, and what a person is told to do about it.
2//!
3//! Every variant names the path or branch involved, the invariant that was not met, and
4//! the command that repairs it. A caller never renders a debug struct: the `Display` of
5//! this type is the message, and the transports add nothing to it.
6
7use std::path::PathBuf;
8
9/// The exit code of a refusal: the operation is well-formed and the state of the tree says
10/// no. The executable's contract already spends `10` on "contract unmet".
11pub const EXIT_REFUSED: u8 = 10;
12/// The exit code when the thing asked for does not exist.
13pub const EXIT_MISSING: u8 = 12;
14/// The exit code when git itself failed, or the topology cannot be read at all.
15pub const EXIT_INTERNAL: u8 = 13;
16
17/// Everything that stops a worktree operation.
18#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
19pub enum WorktreeError {
20    /// The start directory is not inside a git work tree.
21    #[error("not a git repository: {start} is not inside a git work tree; the worktree topology needs one")]
22    NotInGitRepository {
23        /// Where the search began.
24        start: PathBuf,
25    },
26
27    /// The repository is bare. The topology is defined against a primary checkout.
28    #[error(
29        "the worktree topology requires a non-bare primary checkout; {git_dir} is a bare repository, \
30         which has no primary work tree to derive a sibling container from"
31    )]
32    BareRepositoryUnsupported {
33        /// The bare repository directory.
34        git_dir: PathBuf,
35    },
36
37    /// `git worktree list` answered, and nothing in it is the primary worktree.
38    #[error(
39        "cannot determine the primary worktree of {git_common_dir}: `git worktree list --porcelain` \
40         named no non-bare main work tree; the repository metadata is incomplete"
41    )]
42    CannotDeterminePrimaryWorktree {
43        /// The common git directory the topology was read from.
44        git_common_dir: PathBuf,
45    },
46
47    /// The primary checkout is at the filesystem root, so it has no parent to be a sibling in.
48    #[error(
49        "cannot derive a sibling worktree container for {primary}: it has no parent directory"
50    )]
51    NoParentDirectory {
52        /// The primary checkout.
53        primary: PathBuf,
54    },
55
56    /// A branch name cannot be used as given.
57    #[error("invalid branch name '{given}': {reason}")]
58    InvalidBranchName {
59        /// What was given.
60        given: String,
61        /// Why it was refused.
62        reason: String,
63    },
64
65    /// The derived worktree path does not stay inside the container. Cannot happen for a
66    /// valid branch name; refused by name rather than assumed away.
67    #[error(
68        "the worktree path derived for branch '{branch}' ({path}) is not inside the container {root}; \
69         nothing was created"
70    )]
71    PathEscape {
72        /// The branch.
73        branch: String,
74        /// What was derived.
75        path: PathBuf,
76        /// Where it had to be.
77        root: PathBuf,
78    },
79
80    /// The container path exists and is not a directory.
81    #[error(
82        "the worktree container {path} exists and is not a directory; it is where every linked \
83         worktree of this repository belongs, so it cannot be a file or a symlink to one"
84    )]
85    ContainerNotADirectory {
86        /// The container path.
87        path: PathBuf,
88    },
89
90    /// The worktree asked for is already registered at its canonical path.
91    #[error(
92        "branch '{branch}' already has its canonical worktree at {path}; nothing was created. \
93         Work there: cd \"$(majordomus worktree path {branch})\""
94    )]
95    WorktreeAlreadyExists {
96        /// The registered path.
97        path: PathBuf,
98        /// The branch it holds.
99        branch: String,
100    },
101
102    /// The branch is checked out in a worktree that is not its canonical one.
103    #[error(
104        "branch '{branch}' is checked out at {path}, which is not its canonical worktree \
105         {expected}; git allows one checkout of a branch at a time. Bring it home with \
106         `majordomus worktree migrate` and work there"
107    )]
108    BranchAlreadyCheckedOut {
109        /// The branch.
110        branch: String,
111        /// Where it is checked out.
112        path: PathBuf,
113        /// Where it belongs.
114        expected: PathBuf,
115    },
116
117    /// The destination exists and is something else.
118    #[error(
119        "{path} already exists and is {what}; nothing was created, nothing was moved and nothing \
120         was deleted. Move it aside, or choose another branch name"
121    )]
122    DestinationConflict {
123        /// The occupied path.
124        path: PathBuf,
125        /// What occupies it, in words.
126        what: String,
127    },
128
129    /// The base ref asked for does not resolve.
130    #[error("base ref '{base}' does not resolve in this repository; nothing was created and nothing was fetched")]
131    BaseDoesNotExist {
132        /// What was asked for.
133        base: String,
134    },
135
136    /// The current worktree is not where its branch belongs.
137    #[error(
138        "worktree {path} holds branch '{branch}' but that branch belongs at {expected}; \
139         feature work happens in the canonical worktree. Run `majordomus worktree migrate --plan` \
140         to see the move, `majordomus worktree migrate` to make it, then continue there"
141    )]
142    Misplaced {
143        /// The offending worktree.
144        path: PathBuf,
145        /// Its branch.
146        branch: String,
147        /// Where the branch belongs.
148        expected: PathBuf,
149    },
150
151    /// The primary checkout holds a branch that is not the trunk.
152    #[error(
153        "the primary checkout {path} is on branch '{branch}', and the primary checkout hosts the trunk \
154         ('{trunk}'); that branch belongs at {expected}. Nothing was changed: switch the primary \
155         checkout back to the trunk when it is clean, and work on '{branch}' in its canonical worktree \
156         (`majordomus worktree create {branch}`)"
157    )]
158    PrimaryOnNonTrunk {
159        /// The primary checkout.
160        path: PathBuf,
161        /// The branch it holds.
162        branch: String,
163        /// The trunk.
164        trunk: String,
165        /// Where the branch belongs.
166        expected: PathBuf,
167    },
168
169    /// The worktree has uncommitted or untracked content and the operation would destroy it.
170    #[error(
171        "worktree {path} is dirty ({summary}); {operation} would lose that work. Commit it there \
172         first, or pass --force to say that losing it is intended"
173    )]
174    DirtyWorktree {
175        /// The worktree.
176        path: PathBuf,
177        /// What is dirty, in words.
178        summary: String,
179        /// The operation that refused.
180        operation: String,
181    },
182
183    /// The worktree is locked; git refuses to move or remove it and so does this.
184    #[error(
185        "worktree {path} is locked{}; unlock it first (git worktree unlock {path})",
186        reason.as_ref().filter(|r| !r.is_empty()).map(|r| format!(" ({r})")).unwrap_or_default()
187    )]
188    LockedWorktree {
189        /// The worktree.
190        path: PathBuf,
191        /// The reason git recorded, when it recorded one.
192        reason: Option<String>,
193    },
194
195    /// The primary checkout was named to an operation that only linked worktrees accept.
196    #[error(
197        "{path} is this repository's primary checkout; `worktree {operation}` operates on linked \
198         worktrees only and never moves or deletes the primary checkout"
199    )]
200    PrimaryWorktreeProtected {
201        /// The primary checkout.
202        path: PathBuf,
203        /// The operation that refused.
204        operation: String,
205    },
206
207    /// A selector matched nothing.
208    #[error(
209        "no worktree of this repository matches '{selector}'; a selector is an exact branch name \
210         or an exact path. `majordomus worktree list` shows them"
211    )]
212    NoSuchWorktree {
213        /// What was asked for.
214        selector: String,
215    },
216
217    /// A path outside every worktree of this repository was named.
218    #[error(
219        "{path} is not inside a worktree of this repository (git directory {git_common_dir}); \
220         the topology answers only about its own repository"
221    )]
222    NotThisRepository {
223        /// The path named.
224        path: PathBuf,
225        /// This repository's common git directory.
226        git_common_dir: PathBuf,
227    },
228
229    /// A migration cannot be carried out safely, with the reason.
230    #[error("cannot migrate {path} safely: {reason}; nothing was moved")]
231    MigrationUnsafe {
232        /// The worktree that would have moved.
233        path: PathBuf,
234        /// Why it was refused.
235        reason: String,
236    },
237
238    /// The fingerprint after a move differs from the one before it.
239    #[error(
240        "migration of {path} could not be verified: {}. The worktree is registered at {path}; \
241         inspect it before touching anything else (majordomus worktree status --repo {path})",
242        differences.join("; ")
243    )]
244    VerificationFailed {
245        /// Where the worktree is now.
246        path: PathBuf,
247        /// What differs, one line each.
248        differences: Vec<String>,
249    },
250
251    /// The repository-scoped lock could not be taken.
252    #[error(
253        "another process is changing this repository's worktrees (lock {path}{}); \
254         nothing was changed. Retry when it finishes",
255        holder.as_ref().map(|h| format!(", held by {h}")).unwrap_or_default()
256    )]
257    LockUnavailable {
258        /// The lock file.
259        path: PathBuf,
260        /// What the lock file says holds it, when it says.
261        holder: Option<String>,
262    },
263
264    /// The topology is invalid: an error-level diagnostic stands.
265    #[error("the worktree topology has {count} error(s); `majordomus worktree doctor` lists them")]
266    TopologyInvalid {
267        /// How many error-level diagnostics.
268        count: usize,
269    },
270
271    /// A git command failed; its own words are carried through.
272    #[error("git {command} failed ({status}): {stderr}")]
273    GitCommandFailed {
274        /// The arguments, joined for reading.
275        command: String,
276        /// How it exited.
277        status: String,
278        /// What it said on standard error.
279        stderr: String,
280    },
281
282    /// `git` could not be run at all.
283    #[error("cannot run git: {reason}")]
284    GitUnavailable {
285        /// Why not.
286        reason: String,
287    },
288
289    /// A file or directory could not be read or written.
290    #[error("{path}: {reason}")]
291    Io {
292        /// The path.
293        path: PathBuf,
294        /// What the operating system said.
295        reason: String,
296    },
297}
298
299impl WorktreeError {
300    /// The exit code this error leaves the process with.
301    ///
302    /// ```
303    /// use majordomus_cli::worktree::WorktreeError;
304    /// use std::path::PathBuf;
305    /// let e = WorktreeError::NoSuchWorktree { selector: "nope".into() };
306    /// assert_eq!(e.exit_code(), 12);
307    /// let e = WorktreeError::DirtyWorktree {
308    ///     path: PathBuf::from("/a"), summary: "1 change".into(), operation: "remove".into(),
309    /// };
310    /// assert_eq!(e.exit_code(), 10);
311    /// ```
312    pub fn exit_code(&self) -> u8 {
313        use WorktreeError::*;
314        match self {
315            NoSuchWorktree { .. } | BaseDoesNotExist { .. } => EXIT_MISSING,
316            GitCommandFailed { .. } | GitUnavailable { .. } | Io { .. } => EXIT_INTERNAL,
317            _ => EXIT_REFUSED,
318        }
319    }
320
321    /// The stable machine name of this refusal: what a script or a test matches on, so that
322    /// neither has to match the prose. It is the variant's own name.
323    ///
324    /// ```
325    /// use majordomus_cli::worktree::WorktreeError;
326    /// use std::path::PathBuf;
327    /// let e = WorktreeError::Misplaced {
328    ///     path: PathBuf::from("/tmp/x"), branch: "feature/x".into(), expected: PathBuf::from("/a/foo-wt/feature/x"),
329    /// };
330    /// assert_eq!(e.code(), "Misplaced");
331    /// ```
332    pub fn code(&self) -> &'static str {
333        use WorktreeError::*;
334        match self {
335            NotInGitRepository { .. } => "NotInGitRepository",
336            BareRepositoryUnsupported { .. } => "BareRepositoryUnsupported",
337            CannotDeterminePrimaryWorktree { .. } => "CannotDeterminePrimaryWorktree",
338            NoParentDirectory { .. } => "NoParentDirectory",
339            InvalidBranchName { .. } => "InvalidBranchName",
340            PathEscape { .. } => "PathEscape",
341            ContainerNotADirectory { .. } => "ContainerNotADirectory",
342            WorktreeAlreadyExists { .. } => "WorktreeAlreadyExists",
343            BranchAlreadyCheckedOut { .. } => "BranchAlreadyCheckedOut",
344            DestinationConflict { .. } => "DestinationConflict",
345            BaseDoesNotExist { .. } => "BaseDoesNotExist",
346            Misplaced { .. } => "Misplaced",
347            PrimaryOnNonTrunk { .. } => "PrimaryOnNonTrunk",
348            DirtyWorktree { .. } => "DirtyWorktree",
349            LockedWorktree { .. } => "LockedWorktree",
350            PrimaryWorktreeProtected { .. } => "PrimaryWorktreeProtected",
351            NoSuchWorktree { .. } => "NoSuchWorktree",
352            NotThisRepository { .. } => "NotThisRepository",
353            MigrationUnsafe { .. } => "MigrationUnsafe",
354            VerificationFailed { .. } => "VerificationFailed",
355            LockUnavailable { .. } => "LockUnavailable",
356            TopologyInvalid { .. } => "TopologyInvalid",
357            GitCommandFailed { .. } => "GitCommandFailed",
358            GitUnavailable { .. } => "GitUnavailable",
359            Io { .. } => "Io",
360        }
361    }
362
363    /// An IO failure against a path.
364    pub fn io(path: impl Into<PathBuf>, e: &std::io::Error) -> Self {
365        WorktreeError::Io {
366            path: path.into(),
367            reason: e.to_string(),
368        }
369    }
370}
371
372/// The result of every worktree operation.
373pub type Result<T> = std::result::Result<T, WorktreeError>;