Skip to main content

grit_lib/
error.rs

1//! Shared error types for grit-lib.
2//!
3//! Library code uses [`enum@Error`] (a `thiserror` enum) so callers can match on
4//! specific failure modes. The binary wraps these with `anyhow` for human-
5//! readable top-level reporting.
6//!
7//! Library guide: [errors](https://grit-scm.com/docs/library/embedding)
8//!
9//! ```rust
10//! use grit_lib::error::Error;
11//! # fn main() {
12//! let _ = Error::Message("example".into());
13//! # }
14//! ```
15
16use thiserror::Error;
17
18pub use crate::git_dir::GitDirPathOperation;
19pub use crate::midx_error::MidxError;
20pub use crate::worktree::io_error::WorktreePathOperation;
21
22/// Why a Git config numeric value was rejected (`git config int` strict parsing).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum BadNumericSource {
25    /// Suffix unit letter is invalid or missing where required.
26    InvalidUnit,
27    /// Parsed value or scaled product does not fit.
28    OutOfRange,
29}
30
31impl BadNumericSource {
32    fn detail(self) -> &'static str {
33        match self {
34            Self::InvalidUnit => "invalid unit",
35            Self::OutOfRange => "out of range",
36        }
37    }
38}
39
40impl std::fmt::Display for BadNumericSource {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.write_str(self.detail())
43    }
44}
45
46/// Why a tree transition would overwrite existing worktree content.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum OverwriteReason {
49    /// Staged or unstaged edits on paths that differ between trees.
50    LocalChanges,
51    /// Untracked paths that a checkout would replace.
52    Untracked,
53}
54
55impl std::fmt::Display for OverwriteReason {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::LocalChanges => f.write_str("local_changes"),
59            Self::Untracked => f.write_str("untracked"),
60        }
61    }
62}
63
64/// Why a remote could not be read or written ([`Error::RemoteUnreadable`]).
65///
66/// Every reason is a failure to talk to the remote at all, as opposed to the remote refusing
67/// an update. [`Self::Transport`] is the one usually worth retrying on a flaky network.
68#[derive(Debug, Clone, PartialEq, Eq)]
69#[non_exhaustive]
70pub enum RemoteUnreadableReason {
71    /// A local path or `file://` URL does not name a Git directory (valid `HEAD`, plus
72    /// `objects/` and `refs/` in the common directory).
73    RepositoryNotFound,
74    /// The connection failed or broke: name resolution, refused or reset connection, TLS, or
75    /// timeouts.
76    Transport {
77        /// Transport-level description of the failure, with URL credentials redacted.
78        detail: String,
79    },
80    /// A smart-HTTP server answered with an error status other than an authentication
81    /// challenge (those are [`Error::Auth`]).
82    HttpStatus {
83        /// HTTP status code (`>= 400`).
84        status: u16,
85    },
86    /// An HTTP response looked successful but was not a Git smart-HTTP ref advertisement
87    /// (for example `200 text/html` from a parked or mistyped host).
88    NotGitServer {
89        /// HTTP status code (often `200`).
90        status: u16,
91        /// Response `Content-Type` when present (media type only, without parameters).
92        content_type: Option<String>,
93    },
94    /// The transport ran, but the remote never completed the Git protocol handshake (for
95    /// example `ssh` exited, or a `git://` peer closed, before any ref advertisement).
96    NoHandshake,
97}
98
99impl RemoteUnreadableReason {
100    /// Stable snake-case name for machine-readable output (`repository_not_found`,
101    /// `transport`, `http_status`, `not_git_server`, `no_handshake`).
102    #[must_use]
103    pub fn slug(&self) -> &'static str {
104        match self {
105            Self::RepositoryNotFound => "repository_not_found",
106            Self::Transport { .. } => "transport",
107            Self::HttpStatus { .. } => "http_status",
108            Self::NotGitServer { .. } => "not_git_server",
109            Self::NoHandshake => "no_handshake",
110        }
111    }
112}
113
114fn remote_unreadable_message(url: Option<&str>, reason: &RemoteUnreadableReason) -> String {
115    let target = url.map_or_else(|| "the remote".to_owned(), |u| format!("'{u}'"));
116    match reason {
117        RemoteUnreadableReason::RepositoryNotFound => {
118            format!("could not find repository at {target}")
119        }
120        RemoteUnreadableReason::Transport { detail } => {
121            format!("could not reach {target}: {detail}")
122        }
123        RemoteUnreadableReason::HttpStatus { status } => match url {
124            Some(u) => format!("HTTP {status} from {u}"),
125            None => format!("HTTP {status} from the remote"),
126        },
127        RemoteUnreadableReason::NotGitServer {
128            status,
129            content_type,
130        } => {
131            let got = match content_type
132                .as_deref()
133                .map(|ct| ct.split(';').next().unwrap_or(ct).trim())
134                .filter(|ct| !ct.is_empty())
135            {
136                Some(ct) => format!("{status} {ct}"),
137                None => status.to_string(),
138            };
139            format!("{target} did not answer like a Git server (got {got})")
140        }
141        RemoteUnreadableReason::NoHandshake => {
142            format!("could not read from {target}: no Git protocol response")
143        }
144    }
145}
146
147/// Why an HTTP remote refused access ([`Error::Auth`]).
148#[derive(Debug, Clone, PartialEq, Eq, Error)]
149#[non_exhaustive]
150pub enum AuthFailure {
151    /// The server asked for credentials (`401`) and no credential provider is wired.
152    #[error("no credential provider is configured")]
153    NoCredentialProvider,
154    /// The credential provider could not supply credentials (no helper has them, and
155    /// prompting is not possible).
156    #[error("{detail}")]
157    CredentialsUnavailable {
158        /// The provider's explanation (for example that no helper has credentials and
159        /// prompting is disabled).
160        detail: String,
161    },
162    /// The provider answered without a usable username and password.
163    #[error("the credential helper returned no usable username and password")]
164    UnusableCredentials,
165    /// The URL could not be split into the credential fields helpers expect.
166    #[error("the URL cannot be described to a credential helper")]
167    UrlNotDescribable,
168    /// The server only offers authentication schemes grit does not speak (no `Basic`).
169    #[error("unsupported authentication scheme: {}", schemes.join(", "))]
170    UnsupportedScheme {
171        /// The `WWW-Authenticate` challenges the server sent.
172        schemes: Vec<String>,
173    },
174    /// Credentials were sent and the server rejected them (`401` again).
175    #[error("the server rejected the credentials")]
176    Rejected,
177}
178
179impl AuthFailure {
180    /// Whether credentials were sent and refused, as opposed to never being available.
181    #[must_use]
182    pub fn is_rejected(&self) -> bool {
183        matches!(self, Self::Rejected)
184    }
185}
186
187/// The command a dirty worktree blocked ([`Error::DirtyWorktree`]).
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189#[non_exhaustive]
190pub enum WorktreeOperation {
191    /// Switching branches or checking out a commit.
192    Switch,
193    /// Picking (cherry-picking) or reverting commits.
194    Pick,
195    /// Merging.
196    Merge,
197    /// Rebasing with `--exec`.
198    RebaseExec,
199    /// Starting a rebase that stops on conflicts (the stop rewrites the index and worktree).
200    Rebase,
201}
202
203impl std::fmt::Display for WorktreeOperation {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        f.write_str(match self {
206            Self::Switch => "switching",
207            Self::Pick => "picking",
208            Self::Merge => "merging",
209            Self::RebaseExec => "rebasing with --exec",
210            Self::Rebase => "rebasing",
211        })
212    }
213}
214
215/// Configuration parse and validation failures.
216#[derive(Debug, Error, Clone, PartialEq, Eq)]
217#[non_exhaustive]
218pub enum ConfigError {
219    /// A line in a config file could not be parsed.
220    #[error("bad config line {line} in {location}")]
221    BadConfigLine {
222        /// Config source label or path where the error was detected.
223        location: String,
224        /// One-based line number within that source.
225        line: usize,
226    },
227
228    /// Same as [`Self::BadConfigLine`] with an explicit file path (Git file diagnostics).
229    #[error("bad config line {line} in file '{file}'")]
230    BadConfigLineInFile {
231        /// Path to the config file involved in the error.
232        file: String,
233        /// One-based line number within that file.
234        line: usize,
235    },
236
237    /// A config key requires a value but none was given.
238    #[error("missing value for '{key}'")]
239    MissingValue {
240        /// Config key name that failed validation.
241        key: String,
242    },
243
244    /// `fetch.negotiationalgorithm` and similar keys with missing values.
245    #[error("bad config variable '{key}' in file '{file}' at line {line}")]
246    BadConfigVariable {
247        /// Config key name that failed validation.
248        key: String,
249        /// Path to the config file involved in the error.
250        file: String,
251        /// One-based line number within the config source.
252        line: usize,
253    },
254
255    /// `unset` or a scoped read/write was requested for a key that is not set in that file.
256    #[error("key '{key}' is not set")]
257    KeyNotSet {
258        /// Config key that was not present.
259        key: String,
260    },
261
262    /// Integer config value could not be parsed or is out of range.
263    #[error("bad numeric config value '{value}' for '{key}': {reason}")]
264    BadNumericValue {
265        /// Config key name that failed validation.
266        key: String,
267        /// Raw config value that could not be parsed.
268        value: String,
269        /// Why the value or operation was rejected.
270        reason: BadNumericSource,
271    },
272
273    /// Same as [`Self::BadNumericValue`] with file context.
274    #[error("bad numeric config value '{value}' for '{key}' in file {file}: {reason}")]
275    BadNumericValueInFile {
276        /// Config key name that failed validation.
277        key: String,
278        /// Raw config value that could not be parsed.
279        value: String,
280        /// Path to the config file involved in the error.
281        file: String,
282        /// Why the value or operation was rejected.
283        reason: BadNumericSource,
284    },
285
286    /// `diff.context` (or similar) failed variable validation.
287    #[error("bad config variable '{key}' in file '{file}' at line {line}")]
288    BadConfigVariableAtLine {
289        /// Config key name that failed validation.
290        key: String,
291        /// Path to the config file involved in the error.
292        file: String,
293        /// One-based line number within the config source.
294        line: usize,
295    },
296
297    /// Command-line config could not supply a parseable value for a key.
298    #[error("unable to parse '{key}' from command-line config")]
299    UnableToParseFromCommandLine {
300        /// Config key name that failed validation.
301        key: String,
302    },
303
304    /// `includeIf.hasconfig:remote.*.url` cannot pull remote URLs from included files.
305    #[error(
306        "remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url"
307    )]
308    RemoteUrlInHasconfigInclude,
309
310    /// `[include]` / `[includeIf]` nesting exceeded the Git-compatible depth limit.
311    #[error("exceeded maximum include depth (depth {depth} exceeds limit {limit})")]
312    IncludeDepthExceeded {
313        /// Current include nesting depth when the limit was hit.
314        depth: usize,
315        /// Maximum allowed include nesting depth.
316        limit: usize,
317    },
318
319    /// Generic config error with free-form detail (legacy call sites).
320    #[error("{0}")]
321    Other(String),
322
323    /// The config file could not be written because a `.lock` file is already present.
324    #[error("could not lock config file {path}: File exists")]
325    ConfigFileLocked {
326        /// Path to the config file that could not be locked.
327        path: String,
328    },
329
330    /// A single-value operation was requested but the key has multiple values.
331    #[error("cannot overwrite multiple values with a single value for '{key}'")]
332    MultipleValues {
333        /// Config key name that failed validation.
334        key: String,
335    },
336
337    /// Per-worktree config was requested but `extensions.worktreeConfig` is off.
338    #[error(
339        "worktree config is only allowed when extensions.worktreeConfig is enabled in the common config"
340    )]
341    WorktreeConfigNotEnabled,
342
343    /// `remove-section` or `rename-section` did not find the named section.
344    #[error("section '{section}' not found")]
345    SectionNotFound {
346        /// Section header name (e.g. `remote.origin`).
347        section: String,
348    },
349
350    /// Inline `--comment` text must not span lines.
351    #[error("no multi-line comment allowed")]
352    MultilineCommentNotAllowed,
353
354    /// `status.showUntrackedFiles` must be `no`, `normal`, `all`, or a Git boolean synonym.
355    #[error(
356        "invalid value '{value}' for 'status.showUntrackedFiles' (expected no, normal, all, boolean words, or integer 0 vs nonzero)"
357    )]
358    InvalidUntrackedFilesMode {
359        /// Raw config value that could not be parsed.
360        value: String,
361    },
362}
363
364impl From<String> for ConfigError {
365    fn from(value: String) -> Self {
366        Self::Other(value)
367    }
368}
369
370impl From<&str> for ConfigError {
371    fn from(value: &str) -> Self {
372        Self::Other(value.to_owned())
373    }
374}
375
376impl ConfigError {
377    /// Git stderr body for this error (without a `fatal:` prefix).
378    #[must_use]
379    pub fn message_body(&self) -> String {
380        self.to_string()
381    }
382}
383
384/// Reference lock failures while creating or updating a ref.
385#[derive(Debug, Error, Clone, PartialEq, Eq)]
386#[non_exhaustive]
387pub enum RefLockError {
388    /// A non-empty directory occupies the path where the ref file must be created.
389    #[error(
390        "cannot lock ref '{refname}': there is a non-empty directory '{path}' blocking reference '{refname}'"
391    )]
392    DirectoryInTheWay {
393        /// Full ref name (for example `refs/heads/main`).
394        refname: String,
395        /// Directory path blocking creation of the ref file.
396        path: String,
397    },
398
399    /// Another process holds the `packed-refs.lock` file (or it already exists).
400    #[error("Unable to create '{lock_path}': File exists.")]
401    PackedRefsLockHeld {
402        /// Path to the lock file that could not be created.
403        lock_path: String,
404    },
405}
406
407/// Clean/smudge filter and EOL conversion failures.
408#[derive(Debug, Error, Clone, PartialEq, Eq)]
409#[non_exhaustive]
410pub enum FilterError {
411    /// External clean or smudge filter command failed.
412    #[error("{path}: {phase} filter '{driver}' failed")]
413    ExternalFilterFailed {
414        /// Filter driver name from `.gitattributes`.
415        driver: String,
416        /// Repository-relative or filesystem path related to the failure.
417        path: String,
418        /// Whether the clean or smudge filter hook failed.
419        phase: FilterPhase,
420    },
421
422    /// A required clean or smudge filter could not be started (for example, no shell to run
423    /// it), so the content was neither filtered nor stored unfiltered.
424    #[error("{path}: cannot start {phase} filter '{driver}' ({program}): {detail}")]
425    ExternalFilterSpawnFailed {
426        /// Filter driver name from `.gitattributes`.
427        driver: String,
428        /// Repository-relative path being filtered.
429        path: String,
430        /// Whether the clean or smudge filter was being started.
431        phase: FilterPhase,
432        /// Program grit tried to spawn (`sh` for shell-command filters).
433        program: String,
434        /// OS error kind from the failed spawn.
435        kind: std::io::ErrorKind,
436        /// OS error message from the failed spawn.
437        detail: String,
438    },
439
440    /// Filter output did not round-trip (clean/smudge mismatch).
441    #[error("{path}: {detail}")]
442    NotFilteredProperly {
443        /// Repository-relative or filesystem path related to the failure.
444        path: String,
445        /// Why clean/smudge output did not round-trip.
446        detail: String,
447    },
448
449    /// Invalid `working-tree-encoding` attribute value.
450    #[error("true/false are no valid working-tree-encodings")]
451    InvalidWorkingTreeEncoding,
452
453    /// Line-ending conversion would modify the worktree against user settings.
454    #[error("{detail}")]
455    LineEndingWouldChange {
456        /// Additional human-readable detail for the failure.
457        detail: String,
458    },
459
460    /// Shell filter subprocess exited non-zero.
461    #[error("filter command exited with status {status}")]
462    Failed {
463        /// Subprocess exit status from the filter command.
464        status: i32,
465    },
466}
467
468/// Which filter hook failed.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub enum FilterPhase {
471    /// Index → worktree (smudge).
472    Smudge,
473    /// Worktree → index (clean).
474    Clean,
475}
476
477impl std::fmt::Display for FilterPhase {
478    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479        match self {
480            Self::Smudge => f.write_str("smudge"),
481            Self::Clean => f.write_str("clean"),
482        }
483    }
484}
485
486/// Sparse-checkout cone pattern parse warnings (non-fatal).
487#[derive(Debug, Clone, PartialEq, Eq)]
488pub enum SparseCheckoutWarning {
489    /// A cone pattern line was not recognized.
490    UnrecognizedPattern {
491        /// Raw pattern text from the sparse-checkout file.
492        pattern: String,
493    },
494    /// A negative cone pattern line was not recognized.
495    UnrecognizedNegativePattern {
496        /// Raw negative pattern text from the sparse-checkout file.
497        pattern: String,
498    },
499    /// The same cone pattern appears more than once in the sparse-checkout file.
500    RepeatedPattern {
501        /// Duplicated pattern text.
502        pattern: String,
503    },
504    /// Cone pattern matching was disabled due to invalid sparse-checkout contents.
505    DisablingConePatternMatching,
506}
507
508impl SparseCheckoutWarning {
509    /// Human-readable body without a `warning:` prefix.
510    #[must_use]
511    pub fn body(&self) -> String {
512        match self {
513            Self::UnrecognizedPattern { pattern } => {
514                format!("unrecognized pattern: '{pattern}'")
515            }
516            Self::UnrecognizedNegativePattern { pattern } => {
517                format!("unrecognized negative pattern: '{pattern}'")
518            }
519            Self::RepeatedPattern { pattern } => format!(
520                "your sparse-checkout file may have issues: pattern '{pattern}' is repeated"
521            ),
522            Self::DisablingConePatternMatching => "disabling cone pattern matching".to_owned(),
523        }
524    }
525
526    /// Git-style `warning: …` line.
527    #[must_use]
528    pub fn format_line(&self) -> String {
529        crate::diagnostics::warning_line(&self.body())
530    }
531}
532
533/// Unified diff / patch application errors.
534#[derive(Debug, Error, Clone, PartialEq, Eq)]
535#[non_exhaustive]
536pub enum ApplyError {
537    /// Patch bytes are malformed at a known input location.
538    #[error("corrupt patch at {input}:{line}")]
539    CorruptPatch {
540        /// Patch input label (file name or `stdin`).
541        input: String,
542        /// One-based line number within the patch input.
543        line: usize,
544    },
545
546    /// Preimage context could not be located in the blob at any search offset.
547    #[error(
548        "patch failed: context mismatch at hunk {hunk_index} (expected near line {expected_line})"
549    )]
550    ContextMismatch {
551        /// Zero-based index of the hunk in the input slice.
552        hunk_index: usize,
553        /// One-based line number from the hunk header (`old_start`).
554        expected_line: usize,
555    },
556
557    /// A later hunk would modify lines already changed by an earlier hunk.
558    #[error("patch failed: overlapping hunks at hunk {hunk_index}")]
559    HunksOverlap {
560        /// Zero-based index of the hunk that overlaps prior applied changes.
561        hunk_index: usize,
562    },
563
564    /// The patch removes more lines than exist in the blob (without a valid EOF match).
565    #[error("patch failed: unexpected end of file at hunk {hunk_index}")]
566    UnexpectedEof {
567        /// Zero-based index of the offending hunk.
568        hunk_index: usize,
569    },
570
571    /// Other apply parse failures.
572    #[error("{0}")]
573    Other(String),
574
575    /// A text hunk could not be matched or applied.
576    #[error("patch failed: {path}:{line}")]
577    PatchFailed {
578        /// File path from the patch.
579        path: String,
580        /// One-based line number in the patch input.
581        line: usize,
582    },
583
584    /// Index or blob apply failed for a path.
585    #[error("could not apply patch to {path}: {message}")]
586    Failed {
587        /// Repository-relative path.
588        path: String,
589        /// Failure detail.
590        message: String,
591    },
592
593    /// `--cached` apply requires a stage-0 index entry at the preimage path.
594    #[error("'{path}' is not in the index")]
595    NotInIndex {
596        /// Missing path.
597        path: String,
598    },
599
600    /// A binary diff marker without `GIT binary patch` payload cannot be applied.
601    #[error("binary patch data missing for '{path}'")]
602    BinaryDataMissing {
603        /// Path from the patch.
604        path: String,
605    },
606
607    /// The caller's [`crate::progress::Cancel`] or
608    /// [`crate::progress::ProgressSink::should_cancel`] stopped the apply between files.
609    #[error("operation cancelled")]
610    Cancelled,
611}
612
613/// Path lookup failures while formatting a diff.
614#[derive(Debug, Error, Clone, PartialEq, Eq)]
615#[non_exhaustive]
616pub enum DiffPathError {
617    /// The requested path is not part of the diff being formatted.
618    #[error("No such path '{path}' in the diff")]
619    NoSuchPathInDiff {
620        /// Path that was requested from the diff.
621        path: String,
622    },
623}
624
625/// `--pathspec-from-file` / `--pathspec-file-nul` parse failure.
626#[derive(Debug, Error, Clone, PartialEq, Eq)]
627#[non_exhaustive]
628pub enum PathspecFromFileParseError {
629    /// A NUL-separated entry is not valid UTF-8.
630    #[error("invalid UTF-8 in pathspec-from-file (NUL mode)")]
631    InvalidUtf8InNulRecord,
632    /// A NUL-terminated entry used C-style quoting (forbidden in NUL mode).
633    #[error("pathspec-from-file: line is not NUL terminated: {line}")]
634    QuotedLineInNulMode {
635        /// Offending entry text.
636        line: String,
637    },
638    /// A line-mode entry had invalid double-quote wrapping.
639    #[error("invalid C-style quoting: {line}")]
640    InvalidCStyleQuoting {
641        /// Raw line from the file.
642        line: String,
643    },
644    /// Backslash escape at end of a quoted line.
645    #[error("invalid escape at end of string")]
646    InvalidEscapeAtEnd,
647    /// Octal escape shorter than three digits.
648    #[error("truncated octal escape")]
649    TruncatedOctalEscape,
650    /// Octal escape bytes are not valid UTF-8.
651    #[error("invalid octal bytes")]
652    InvalidOctalBytes,
653    /// Octal digits do not form a valid byte value.
654    #[error("invalid octal escape value")]
655    InvalidOctalValue,
656    /// Unknown escape after backslash in a quoted line.
657    #[error("invalid escape sequence \\{escape}")]
658    InvalidEscapeSequence {
659        /// Character after the backslash.
660        escape: char,
661    },
662    /// Quoted line decoded to invalid UTF-8.
663    #[error("invalid UTF-8 in quoted pathspec")]
664    InvalidUtf8InQuotedLine,
665}
666
667/// Failure while resolving argv and `--pathspec-from-file` pathspecs against a work tree.
668#[derive(Debug, Error, Clone, PartialEq, Eq)]
669#[non_exhaustive]
670pub enum PathspecResolveError {
671    /// An argv pathspec was the empty string.
672    #[error("invalid path ''")]
673    EmptyPathspec,
674    /// Resolved path lies outside the repository work tree.
675    #[error("{elt}: '{path}' is outside repository at '{work_tree}'")]
676    OutsideRepository {
677        /// User-facing pathspec token (argv element).
678        elt: String,
679        /// Resolved absolute path outside the work tree.
680        path: String,
681        /// Canonical work tree root.
682        work_tree: String,
683    },
684    /// `--pathspec-from-file` bytes could not be parsed.
685    #[error(transparent)]
686    FromFile(#[from] PathspecFromFileParseError),
687    /// Command requires a working tree but the repository is bare.
688    #[error("this operation needs a working tree")]
689    NeedsWorkTree,
690}
691
692/// Why a `:(attr:...)` pathspec clause was rejected.
693#[derive(Debug, Error, Clone, PartialEq, Eq)]
694#[non_exhaustive]
695pub enum AttrPathspecError {
696    /// The clause names no attribute requirement (`:(attr:)`).
697    #[error("attr requirement list is empty")]
698    Empty,
699    /// More than one `attr:` clause appears in one pathspec.
700    #[error("a pathspec may contain at most one attr clause")]
701    MultipleAttrClauses,
702    /// A requirement names an attribute that is not valid (`-a=b`, `bad name`, `bad/name`).
703    #[error("'{0}' is not a usable attribute name")]
704    InvalidName(String),
705    /// A `\` escape ends the value or precedes whitespace.
706    #[error("attr value ends with a dangling escape")]
707    DanglingEscape,
708    /// A `\` escape precedes something other than `,`.
709    #[error("attr values may only escape ','")]
710    UnsupportedEscape,
711}
712
713/// Path outside the repository during pathspec or transport resolution.
714#[derive(Debug, Error, Clone, PartialEq, Eq)]
715#[non_exhaustive]
716pub enum PathOutsideRepoError {
717    /// A resolved path escapes the repository work tree or git directory.
718    #[error("{path}: '{subpath}' is outside repository at '{repo_root}'")]
719    OutsideRepository {
720        /// Repository-relative or filesystem path related to the failure.
721        path: String,
722        /// Path component that lies outside the repository root.
723        subpath: String,
724        /// Canonical repository root used for the outside-path check.
725        repo_root: String,
726    },
727}
728
729/// Why a path could not be opened as a Git repository; carried by [`Error::NotARepository`].
730#[derive(Debug, Error)]
731#[non_exhaustive]
732pub enum NotARepositoryReason {
733    /// No usable git directory exists at `path`, or discovery found none at or above it.
734    #[error("{}", crate::git_path::path_for_error_message(path))]
735    NoRepositoryAt {
736        /// Directory, git directory, or `.git` path that was probed.
737        path: std::path::PathBuf,
738    },
739    /// A `.git` entry is a FIFO, socket or device rather than a regular file or directory.
740    #[error(
741        "invalid gitfile format: {} is not a regular file",
742        crate::git_path::path_for_error_message(path)
743    )]
744    GitfileNotRegularFile {
745        /// The offending `.git` entry.
746        path: std::path::PathBuf,
747    },
748    /// A gitfile has no `gitdir: <path>` line.
749    #[error("invalid gitfile format")]
750    GitfileMissingGitdir {
751        /// The gitfile that was parsed.
752        path: std::path::PathBuf,
753    },
754    /// A gitfile exists but could not be read (permissions, invalid UTF-8, ...).
755    #[error("{error}")]
756    GitfileUnreadable {
757        /// The gitfile that could not be read.
758        path: std::path::PathBuf,
759        /// The underlying read failure.
760        error: std::io::Error,
761    },
762}
763
764fn maintenance_running_message(holder: &crate::maintenance::GcLockHolder) -> String {
765    match (holder.pid, holder.host.as_deref()) {
766        (Some(pid), Some(host)) => {
767            format!("repository maintenance is already running (process {pid} on {host})")
768        }
769        (Some(pid), None) => format!("repository maintenance is already running (process {pid})"),
770        _ => "repository maintenance is already running".to_owned(),
771    }
772}
773
774fn restore_unmerged_message(paths: &[String]) -> String {
775    match paths {
776        [path] => format!("path '{path}' is unmerged"),
777        _ => format!(
778            "paths are unmerged: {}",
779            paths
780                .iter()
781                .map(|p| format!("'{p}'"))
782                .collect::<Vec<_>>()
783                .join(", ")
784        ),
785    }
786}
787
788/// The top-level error type for all grit-lib operations.
789///
790/// # Examples
791///
792/// ```
793/// use grit_lib::error::Error;
794///
795/// let err = Error::Message("repository not found".into());
796/// assert!(err.to_string().contains("repository not found"));
797/// ```
798#[derive(Debug, Error)]
799#[non_exhaustive]
800pub enum Error {
801    /// A repository could not be found or is structurally invalid.
802    #[error("not a git repository (or any of the parent directories): {0}")]
803    NotARepository(NotARepositoryReason),
804
805    /// A bare repository was found but access is forbidden by safe.bareRepository.
806    #[error("cannot use bare repository '{0}' (safe.bareRepository is 'explicit')")]
807    ForbiddenBareRepository(String),
808
809    /// The repository is owned by a different user (safe.directory).
810    #[error("detected dubious ownership in repository at '{0}'")]
811    DubiousOwnership(String),
812
813    /// Repository format version is not supported by this implementation.
814    #[error("unsupported repository format version '{0}'")]
815    UnsupportedRepositoryFormatVersion(u32),
816
817    /// Repository declares an unsupported extension.
818    #[error("unknown repository extension '{0}'")]
819    UnsupportedRepositoryExtension(String),
820
821    /// `extensions.refStorage` names an unsupported backend.
822    #[error("invalid value for 'extensions.refstorage': '{value}'")]
823    InvalidRefStorageFormat {
824        /// Raw `extensions.refStorage` value from the repository config.
825        value: String,
826    },
827
828    /// [`crate::repo::Repository::init_with_outcome`] refused to change object format on reinit.
829    #[error("cannot change object format in an existing repository")]
830    InitObjectFormatChange,
831
832    /// [`crate::repo::Repository::init_with_outcome`] with incompatible `--bare` and separate git dir.
833    #[error("cannot combine bare repository with separate git directory")]
834    InitBareWithSeparateGitDir,
835
836    /// A supplied object ID string was not valid hex or the wrong length.
837    #[error("invalid object id '{0}'")]
838    InvalidObjectId(String),
839
840    /// SHA-1 object hashing detected a chosen-prefix collision (sha1dc).
841    #[error("SHA-1 hash collision detected for object {oid}")]
842    HashCollision {
843        /// Plain SHA-1 digest for the colliding input (not safe to use as an object id).
844        oid: crate::objects::ObjectId,
845    },
846
847    /// The requested object does not exist in the object store.
848    #[error("object not found: {0}")]
849    ObjectNotFound(String),
850
851    /// A revision expression (branch, tag, object id, `x~n`, `ORIG_HEAD`, …) names nothing in
852    /// the repository. Holds the expression as the caller wrote it.
853    #[error("unknown revision '{0}'")]
854    RevisionNotFound(String),
855
856    /// A partial-clone promisor object is not present locally and no fetcher ran.
857    #[error("promised object not available locally: {0}")]
858    PromisedObjectMissing(crate::objects::ObjectId),
859
860    /// An object's stored data is corrupt or malformed.
861    #[error("corrupt object: {0}")]
862    CorruptObject(String),
863
864    /// A pack index (`.idx`) file's trailing checksum does not match its contents.
865    ///
866    /// Only reported by reads that verify the index trailer, such as
867    /// [`crate::pack::read_pack_index`]; the checksum of the `.pack` the index
868    /// describes is checked separately.
869    #[error("corrupt object: index checksum mismatch for {}: {mismatch}", .path.display())]
870    PackIndexChecksumMismatch {
871        /// The `.idx` file that failed verification.
872        path: std::path::PathBuf,
873        /// Expected and stored trailer digests.
874        mismatch: crate::hash::TrailerMismatch,
875    },
876
877    /// Indexing or unpacking a pack left deltas whose bases are neither in the pack nor
878    /// in the object database: a thin pack without its bases, or a ref-delta cycle.
879    #[error("corrupt object: {count} delta(s) could not be resolved")]
880    UnresolvedDeltas {
881        /// Number of deltas left unresolved.
882        count: u64,
883    },
884
885    /// [`crate::objects::Objects::write`] refused a payload that `git fsck` would reject;
886    /// nothing was written.
887    #[error("refusing to write invalid {kind} object: {}", .check.report_line())]
888    InvalidObject {
889        /// Kind the payload was meant to be stored as.
890        kind: crate::objects::ObjectKind,
891        /// The first fsck rule the payload breaks (`check.id` is Git's fsck message id).
892        check: crate::fsck_standalone::FsckError,
893    },
894
895    /// [`crate::objects::Objects::write_tree`] refused an entry.
896    #[error(transparent)]
897    InvalidTreeEntry(#[from] crate::objects::TreeEntryError),
898
899    /// A [`crate::ident::Signature`] cannot be written as an author, committer or tagger line.
900    #[error(transparent)]
901    InvalidSignature(#[from] crate::ident::SignatureError),
902
903    /// An object exists but has a different kind than the operation requires, such as a
904    /// tree or blob where a commit was expected.
905    #[error("object {oid} is a {actual}, not a {expected}")]
906    UnexpectedObjectKind {
907        /// The object that was read.
908        oid: crate::objects::ObjectId,
909        /// The kind the operation required.
910        expected: crate::objects::ObjectKind,
911        /// The kind the object actually has.
912        actual: crate::objects::ObjectKind,
913    },
914
915    /// Two commits that were asked to be merged share no merge base, and the caller did not
916    /// allow merging unrelated histories.
917    #[error("commits {ours} and {theirs} have no common ancestor")]
918    UnrelatedHistories {
919        /// The commit being merged into.
920        ours: crate::objects::ObjectId,
921        /// The commit being merged in.
922        theirs: crate::objects::ObjectId,
923    },
924
925    /// A commit-graph write was given a commit whose parent is neither among the commits
926    /// being written nor in the base graph layers, so its parent edge cannot be encoded.
927    #[error("commit-graph cannot encode parent {parent} of commit {commit}: parent not in graph")]
928    CommitGraphParentMissing {
929        /// Commit whose parent list was being encoded.
930        commit: crate::objects::ObjectId,
931        /// Parent that is missing from the graph.
932        parent: crate::objects::ObjectId,
933    },
934
935    /// In-pack delta resolution exceeded the read-side chain depth limit (guards
936    /// against cyclic ref-delta chains and unbounded recursion; Git has no read limit).
937    #[error("delta chain too deep (limit {limit})")]
938    DeltaChainTooDeep {
939        /// Maximum delta chain depth allowed while reading packed objects.
940        limit: usize,
941    },
942
943    /// An unsupported or unknown object type was encountered.
944    #[error("unknown object type '{0}'")]
945    UnknownObjectType(String),
946
947    /// Loose object header type field exceeds Git's 32-byte limit.
948    #[error("header for {oid} too long, exceeds 32 bytes")]
949    ObjectHeaderTooLong {
950        /// Object id (hex) of the loose object with an oversized header.
951        oid: String,
952    },
953
954    /// An I/O error from the underlying filesystem.
955    #[error("I/O error: {0}")]
956    Io(#[from] std::io::Error),
957
958    /// A lock file (for example `.git/index.lock` or `refs/heads/main.lock`) already exists, so
959    /// another process is updating the same file or a crashed one left the lock behind.
960    ///
961    /// Operations that return this before changing anything leave the repository untouched.
962    /// The lock file belongs to whoever created it and is never removed. Retry once the other
963    /// process finishes, or delete `path` when no process owns it (a `holder` with
964    /// `running: false` means the lock is stale).
965    #[error("lock file {} already exists", path.display())]
966    LockHeld {
967        /// The lock file that could not be created.
968        path: std::path::PathBuf,
969        /// Owner recorded in the lock's pid sidecar (`core.lockfilePid`), when one was written.
970        holder: Option<crate::lockfile::LockHolder>,
971    },
972
973    /// A zlib compression or decompression failure.
974    #[error("zlib error: {0}")]
975    Zlib(String),
976
977    /// Loose object bytes hash to a different OID than the file path implies (`git fsck` / `read_loose_object`).
978    #[error("{real_oid}: hash-path mismatch, found at: {path}")]
979    LooseHashMismatch {
980        /// Repository-relative or filesystem path to the loose object file.
981        path: String,
982        /// Hex object id of the unpacked contents.
983        real_oid: String,
984    },
985
986    /// A prepared loose object could not be written during parallel index staging.
987    #[error("could not store {path}: {source}")]
988    LooseObjectWrite {
989        /// Index-relative path of the blob being stored.
990        path: String,
991        /// Underlying filesystem error from the object store.
992        source: std::io::Error,
993    },
994
995    /// Filesystem I/O failed on a repository-relative worktree path (checkout, staging, diff).
996    #[error("{}", crate::worktree::io_error::format_worktree_path_io_message(.path, *operation, .source))]
997    WorktreePathIo {
998        /// Index- or worktree-relative path grit was operating on.
999        path: String,
1000        /// What the operation was trying to do when I/O failed.
1001        operation: WorktreePathOperation,
1002        /// Underlying OS error.
1003        source: std::io::Error,
1004    },
1005
1006    /// Filesystem I/O failed on a path under the repository git directory (objects, refs, logs, …).
1007    #[error("{}", crate::git_dir::io_error::format_git_dir_io_message(.path, *operation, .source))]
1008    GitDirPathIo {
1009        /// Display path such as `.git/objects` or `.git/refs/heads/main`.
1010        path: String,
1011        /// What the operation was trying to do when I/O failed.
1012        operation: GitDirPathOperation,
1013        /// Underlying OS error.
1014        source: std::io::Error,
1015    },
1016
1017    /// A streamed object write or hash ended before the declared size was read.
1018    #[error("object stream ended after {actual} of {expected} bytes")]
1019    ObjectStreamTruncated {
1020        /// Payload size the caller declared.
1021        expected: u64,
1022        /// Bytes the stream delivered before end of file.
1023        actual: u64,
1024    },
1025
1026    /// A worktree file hashed earlier in an operation had different content when stored.
1027    #[error("{path} changed while it was being added")]
1028    WorktreeFileChanged {
1029        /// Index-relative path of the file.
1030        path: String,
1031    },
1032
1033    /// The index file is missing, truncated, or has a bad header.
1034    #[error("index error: {0}")]
1035    IndexError(String),
1036
1037    /// Stage-1/2/3 entries are present; a stage-0 tree cannot be built.
1038    #[error("index contains unmerged entries")]
1039    IndexUnmerged,
1040
1041    /// Another maintenance run holds the repository's `gc.pid` lock
1042    /// ([`crate::maintenance::GcLock`]); nothing was changed.
1043    #[error("{}", maintenance_running_message(holder))]
1044    MaintenanceRunning {
1045        /// Process recorded in `gc.pid`.
1046        holder: crate::maintenance::GcLockHolder,
1047    },
1048
1049    /// Explicit [`crate::porcelain::add::stage`] refused paths that still contain conflict markers.
1050    #[error("unmerged paths still have conflict markers")]
1051    UnmergedConflictMarkers {
1052        /// Repository-relative paths that were not staged because markers remain.
1053        paths: Vec<String>,
1054    },
1055
1056    /// A file path is a prefix of another index path at the same tree level.
1057    #[error(
1058        "index path prefix conflict between '{}' and '{}'",
1059        directory.display(),
1060        file.display()
1061    )]
1062    IndexPathPrefixConflict {
1063        /// Shorter path (treated as the ambiguous directory prefix).
1064        directory: std::path::PathBuf,
1065        /// Longer path that extends the prefix with an additional component.
1066        file: std::path::PathBuf,
1067    },
1068
1069    /// The `FSMN` index extension ends before its header, token, or EWAH payload.
1070    #[error("index fsmonitor extension payload is truncated")]
1071    IndexFsmonitorExtensionTruncated,
1072
1073    /// The `FSMN` extension header version is not supported.
1074    #[error("index fsmonitor extension has unsupported header version {0}")]
1075    IndexFsmonitorExtensionBadVersion(u32),
1076
1077    /// The EWAH bitmap embedded in `FSMN` could not be decoded.
1078    #[error("index fsmonitor extension EWAH bitmap is invalid")]
1079    IndexFsmonitorExtensionEwahInvalid,
1080
1081    /// The cache-tree extension references more entries than the index contains. Git emits this
1082    /// (verbatim, prefixed with `error: `) when a tree with duplicate path entries is read into the
1083    /// index (`t4058-diff-duplicates`).
1084    #[error("corrupted cache-tree has entries not present in index")]
1085    CacheTreeCorrupt,
1086
1087    /// A reference name or value is invalid.
1088    #[error("invalid ref: {0}")]
1089    InvalidRef(String),
1090
1091    /// A `packed-refs` line Git would reject when reading the ref database.
1092    #[error("unexpected line in {path}: {line}")]
1093    PackedRefsUnexpectedLine {
1094        /// Path to the `packed-refs` file being read.
1095        path: String,
1096        /// Offending line contents.
1097        line: String,
1098    },
1099
1100    /// A general path-related error (invalid UTF-8, out-of-bounds, etc.).
1101    #[error("path error: {0}")]
1102    PathError(String),
1103
1104    /// `--pathspec-from-file` parse failure.
1105    #[error(transparent)]
1106    PathspecFromFile(#[from] PathspecFromFileParseError),
1107
1108    /// A tree/index path component is forbidden (`.git`, HFS/NTFS aliases, etc.).
1109    #[error("invalid path '{0}'")]
1110    InvalidPath(String),
1111
1112    /// A repository-relative path failed [`crate::path_validate`] rules while populating the index or checkout.
1113    #[error("invalid path '{path}': {reason}")]
1114    InvalidPathComponent {
1115        /// Full repository-relative path that was rejected.
1116        path: String,
1117        /// Which component rule failed.
1118        reason: crate::path_validate::PathComponentError,
1119    },
1120
1121    /// A configuration file parsing or access error.
1122    #[error(transparent)]
1123    Config(#[from] ConfigError),
1124
1125    /// Branch create, delete, or upstream configuration failure.
1126    #[error(transparent)]
1127    Branch(#[from] crate::porcelain::branch_error::BranchError),
1128
1129    /// Reference lock failure while updating refs.
1130    #[error(transparent)]
1131    RefLock(#[from] RefLockError),
1132
1133    /// Reference store transaction or iteration failure.
1134    #[error(transparent)]
1135    RefStore(crate::refs::store::RefStoreError),
1136
1137    /// Reftable file, stack, or transaction failure.
1138    #[error(transparent)]
1139    Reftable(#[from] crate::reftable::ReftableError),
1140
1141    /// Filter or EOL conversion failure.
1142    #[error(transparent)]
1143    Filter(#[from] FilterError),
1144
1145    /// Patch application failure.
1146    #[error(transparent)]
1147    Apply(#[from] ApplyError),
1148
1149    /// Diff path selection failure.
1150    #[error(transparent)]
1151    DiffPath(#[from] DiffPathError),
1152
1153    /// Path resolves outside the repository.
1154    #[error(transparent)]
1155    PathOutsideRepo(#[from] PathOutsideRepoError),
1156
1157    /// A commit/tag signing or signature-verification error.
1158    #[error("{0}")]
1159    Signing(String),
1160
1161    /// HTTP authentication failed: the server required credentials (`401`) and
1162    /// either no credential provider was wired, the provider could not supply a
1163    /// usable username/password, the server demanded an unsupported auth scheme,
1164    /// or the supplied credentials were rejected.
1165    ///
1166    /// Distinct from [`Error::Message`] so embedders can detect an authentication
1167    /// failure (and e.g. fall back to an interactive/subprocess path) rather than
1168    /// string-matching, and so the failure surfaces typed instead of hanging.
1169    #[error("authentication failed for {url}: {reason}")]
1170    Auth {
1171        /// Repository URL with credentials redacted and smart-HTTP endpoint suffixes
1172        /// (`/info/refs?service=…`, `/git-upload-pack`, `/git-receive-pack`) removed.
1173        url: String,
1174        /// What went wrong.
1175        reason: AuthFailure,
1176    },
1177
1178    /// An atomic wire push was requested, but the remote did not advertise the
1179    /// `atomic` capability. No ref-update commands or pack data were sent.
1180    #[error("the remote does not support atomic pushes")]
1181    PushAtomicUnsupported,
1182
1183    /// A push carried `--push-option` values but the remote `git-receive-pack`
1184    /// did not advertise the `push-options` capability, so the options cannot be
1185    /// transmitted.
1186    ///
1187    /// Distinct from [`Error::Message`] so embedders can detect this specific
1188    /// negotiation failure (and e.g. fall back to a subprocess push) rather than
1189    /// string-matching. The message matches Git's
1190    /// `fatal: the receiving end does not support push options`.
1191    #[error("the receiving end does not support push options")]
1192    PushOptionsUnsupported,
1193
1194    /// `git fetch --unshallow` on a repository that is not shallow.
1195    #[error("repository is not shallow")]
1196    FetchUnshallowNotShallow,
1197
1198    /// A deepen/shallow fetch was requested but the remote did not advertise the
1199    /// `shallow` capability.
1200    #[error("the remote does not support shallow fetches")]
1201    FetchShallowUnsupported,
1202
1203    /// A `--deepen` fetch was requested but the remote did not advertise
1204    /// `deepen-relative`.
1205    #[error("the remote does not support deepen-relative")]
1206    FetchDeepenRelativeUnsupported,
1207
1208    /// A bundle fetch or verify found prerequisite commits missing from history.
1209    #[error("repository lacks these prerequisite commits")]
1210    BundleMissingPrerequisites,
1211
1212    /// Streaming fetch/clone temp pack disappeared before index-pack (often real-time antivirus).
1213    #[error(
1214        "received pack file missing: {}. If this persists, check real-time antivirus or disk cleanup for quarantined pack temp files.",
1215        path.display()
1216    )]
1217    ReceivedPackTempMissing {
1218        /// Path to the temp pack that was expected on disk.
1219        path: std::path::PathBuf,
1220    },
1221
1222    /// A pathspec's `:(attr:...)` magic is malformed.
1223    #[error("invalid attr magic in pathspec '{spec}': {reason}")]
1224    InvalidAttrPathspec {
1225        /// The offending pathspec as given.
1226        spec: String,
1227        /// What is wrong with its attr clause.
1228        #[source]
1229        reason: AttrPathspecError,
1230    },
1231
1232    /// A pathspec did not match any file known to Git (index or HEAD tree).
1233    #[error("pathspec '{spec}' did not match any file(s) known to git")]
1234    PathspecNoMatch {
1235        /// User-supplied pathspec that matched nothing.
1236        spec: String,
1237    },
1238
1239    /// [`crate::porcelain::restore::restore_paths`] conflict options were combined with `--staged`.
1240    #[error("conflict restore options cannot be used with --staged")]
1241    RestoreConflictWithStaged,
1242
1243    /// [`crate::porcelain::restore::restore_paths`] conflict options were combined with `--source`.
1244    #[error("conflict restore options cannot be used with --source")]
1245    RestoreConflictWithSource,
1246
1247    /// A conflict side was missing from the index for the requested path.
1248    #[error("path '{path}' does not have their version")]
1249    RestoreConflictMissingTheirs {
1250        /// Repository-relative path.
1251        path: String,
1252    },
1253
1254    /// A conflict side was missing from the index for the requested path.
1255    #[error("path '{path}' does not have our version")]
1256    RestoreConflictMissingOurs {
1257        /// Repository-relative path.
1258        path: String,
1259    },
1260
1261    /// Three-way remerge needs stages 1, 2, and 3 in the index.
1262    #[error("path '{path}' is not unmerged")]
1263    RestoreConflictNotUnmerged {
1264        /// Repository-relative path.
1265        path: String,
1266    },
1267
1268    /// [`crate::porcelain::restore::restore_paths`] was asked to restore the worktree from the
1269    /// index for paths that still have conflict stages; pick a side or rebuild the markers instead.
1270    ///
1271    /// Nothing is written when this is returned.
1272    #[error("{}", restore_unmerged_message(paths))]
1273    RestorePathUnmerged {
1274        /// Matched repository-relative paths with conflict stages (sorted, deduplicated).
1275        paths: Vec<String>,
1276    },
1277
1278    /// A literal pathspec names a file under a symlink directory component.
1279    #[error("pathspec '{spec}' is beyond a symbolic link")]
1280    PathspecBeyondSymlink {
1281        /// User-supplied pathspec that crosses a symlink directory.
1282        spec: String,
1283    },
1284
1285    /// [`crate::porcelain::commit::CommitScope::Paths`] was used while a merge is in progress.
1286    #[error("cannot do a partial commit during a merge")]
1287    PartialCommitDuringMerge,
1288
1289    /// Explicit [`crate::porcelain::add::stage`] pathspecs name paths excluded by ignore rules.
1290    #[error("the following paths are ignored:\n{}", paths.join("\n"))]
1291    PathspecIgnored {
1292        /// Repository-relative paths (or user labels) that cannot be staged while ignored.
1293        paths: Vec<String>,
1294    },
1295
1296    /// [`crate::porcelain::paths::remove_paths`] refused paths whose removal would lose
1297    /// content, because [`crate::porcelain::paths::RemoveOptions::force`] was not set.
1298    #[error(
1299        "refusing to remove paths with unsaved content:\n{}",
1300        paths.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n")
1301    )]
1302    RemoveBlocked {
1303        /// Each refused path with the reason it was refused.
1304        paths: Vec<crate::porcelain::paths::BlockedRemoval>,
1305    },
1306
1307    /// A directory pathspec was used without `recursive` ([`crate::porcelain::paths::remove_paths`]).
1308    #[error("not removing '{0}' recursively without -r")]
1309    NotRemovingRecursively(String),
1310
1311    /// A porcelain path operation requires a checked-out work tree.
1312    #[error("this operation must be run in a work tree")]
1313    RequiresWorkTree,
1314
1315    /// A commit was requested with an empty message body after normalization.
1316    #[error("empty commit message")]
1317    EmptyCommitMessage,
1318
1319    /// [`crate::porcelain::paths::remove_paths`] was called with no pathspecs.
1320    #[error("you must specify path(s) to remove")]
1321    RemovePathsRequired,
1322
1323    /// [`crate::porcelain::commit::commit_with_scope`] was called with an empty
1324    /// [`CommitScope::Paths`](crate::porcelain::commit::CommitScope::Paths) pathspec list.
1325    #[error("you must specify path(s)")]
1326    CommitPathsRequired,
1327
1328    /// [`crate::porcelain::paths::move_path`] / [`crate::porcelain::paths::move_paths`] source is not tracked.
1329    #[error("not under version control, source={src}")]
1330    MoveSourceNotTracked {
1331        /// Untracked or missing source path.
1332        src: String,
1333    },
1334
1335    /// Move refused because the destination already exists without `--force`.
1336    #[error("destination exists, source={src}, destination={destination}")]
1337    MoveDestinationExists {
1338        /// Source path in the move request.
1339        src: String,
1340        /// Existing destination path.
1341        destination: String,
1342    },
1343
1344    /// Move refused because the destination lies under the source directory.
1345    #[error("cannot move '{src}' to a subdirectory of itself, '{destination}'")]
1346    MoveIntoSubdirectory {
1347        /// Source directory path.
1348        src: String,
1349        /// Invalid destination under the source.
1350        destination: String,
1351    },
1352
1353    /// Move refused because source and destination are the same path.
1354    #[error("cannot move into itself, source={src}, destination={destination}")]
1355    MoveIntoSelf {
1356        /// Source path.
1357        src: String,
1358        /// Destination equal to the source.
1359        destination: String,
1360    },
1361
1362    /// Move refused because the source has no index entries.
1363    #[error("nothing to move, source={src}")]
1364    MoveNothingToMove {
1365        /// Source path with no index entries.
1366        src: String,
1367    },
1368
1369    /// Multi-source move would write more than one source to the same destination path.
1370    #[error("multiple sources for the same target, destination={destination}")]
1371    MoveDuplicateDestination {
1372        /// Destination path that would receive more than one source.
1373        destination: String,
1374    },
1375
1376    /// A worktree source path is missing or not a file, directory, or symlink.
1377    #[error("bad source, source={src}, destination={destination}")]
1378    MoveBadSource {
1379        /// Source path.
1380        src: String,
1381        /// Planned destination path.
1382        destination: String,
1383    },
1384
1385    /// Internal invariant: a planned rename references a missing index entry.
1386    #[error("internal error: missing index entry for '{path}'")]
1387    MoveMissingIndexEntry {
1388        /// Index path that should exist during a move.
1389        path: String,
1390    },
1391
1392    /// The remote could not be read or written: a missing repository, a failed connection, an
1393    /// HTTP error status, or no Git handshake.
1394    ///
1395    /// Use [`Error::is_remote_unreadable`] to test for it through wrapper errors.
1396    ///
1397    /// Not used for cancellation; see [`Error::is_cancelled`].
1398    #[error("{}", remote_unreadable_message(url.as_deref(), reason))]
1399    RemoteUnreadable {
1400        /// Remote URL or resolved local path, with credentials redacted. `None` when the
1401        /// failing layer only had a connection, not the URL it was opened from.
1402        url: Option<String>,
1403        /// What went wrong.
1404        reason: RemoteUnreadableReason,
1405    },
1406
1407    /// A resumable bundle download or upload failed.
1408    #[error(transparent)]
1409    Resumable(#[from] Box<crate::resumable::ResumableError>),
1410
1411    /// Explicit fetch refspec resolution failed (missing or ambiguous source).
1412    #[error("{0}")]
1413    FetchRefspec(#[from] crate::fetch_request::FetchRefspecError),
1414
1415    /// Fetch refused to overwrite a local branch with a non-fast-forward update.
1416    #[error("non-fast-forward update for {local_ref}")]
1417    FetchNonFastForward {
1418        /// Local ref that would have been overwritten.
1419        local_ref: String,
1420    },
1421
1422    /// Fetch refused to update the branch checked out in this or a linked worktree.
1423    #[error("cannot fetch into checked-out branch '{branch}'")]
1424    FetchIntoCheckedOutBranch {
1425        /// Short branch name under `refs/heads/`.
1426        branch: String,
1427    },
1428
1429    /// Checkout or switch would overwrite local or untracked worktree content.
1430    #[error("{message}")]
1431    WouldOverwrite {
1432        /// Whether tracked local edits or untracked paths block the operation.
1433        reason: OverwriteReason,
1434        /// Repository-relative paths that would be overwritten (sorted, deduplicated).
1435        paths: Vec<String>,
1436        /// Human-readable summary (depends on [`OverwriteReason`]).
1437        message: String,
1438    },
1439
1440    /// Staged or unstaged changes block an operation that needs a clean worktree.
1441    #[error("you have uncommitted changes — commit them before {operation}")]
1442    DirtyWorktree {
1443        /// The operation that was refused.
1444        operation: WorktreeOperation,
1445        /// Repository-relative paths with staged or unstaged changes (sorted, deduplicated;
1446        /// empty when the caller only knew that something changed).
1447        paths: Vec<String>,
1448    },
1449
1450    /// Author or committer on [`crate::porcelain::commit::CommitRequest`] is invalid.
1451    #[error(transparent)]
1452    InvalidIdentity(#[from] crate::ident::CommitIdentityError),
1453
1454    /// A long-running library operation stopped at a cancellation checkpoint.
1455    ///
1456    /// Use [`Error::is_cancelled`] to test for it through wrapper errors
1457    /// ([`crate::remote::RemoteError::is_cancelled`], [`crate::remote::clone::CloneError::is_cancelled`], …).
1458    #[error("operation cancelled")]
1459    Cancelled,
1460
1461    /// A checkout failed or was cancelled before it finished, and putting the work tree back
1462    /// failed for some paths.
1463    ///
1464    /// Refs and the index are unchanged, as after any failed checkout; only `unrestored`
1465    /// differ from the index. Use [`Error::is_cancelled`] to see whether `source` was a
1466    /// cancellation.
1467    #[error("{source}; {} work-tree path(s) could not be restored", unrestored.len())]
1468    CheckoutRollbackIncomplete {
1469        /// Why the checkout stopped.
1470        source: Box<Error>,
1471        /// Repository-relative paths left different from the index (sorted, deduplicated).
1472        unrestored: Vec<String>,
1473    },
1474
1475    /// No usable author or committer identity is configured
1476    /// ([`crate::repo::Repository::identity`]).
1477    #[error(transparent)]
1478    Identity(#[from] crate::config::ident_resolve::IdentityError),
1479
1480    /// User-facing message that should be printed verbatim (no extra prefix).
1481    ///
1482    /// Used for revision errors that must match Git's `fatal:` lines exactly.
1483    #[error("{0}")]
1484    Message(String),
1485
1486    /// A merge was requested with fast-forward only, but the histories diverged.
1487    #[error("not possible to fast-forward; refusing to merge")]
1488    NotFastForward,
1489
1490    /// The index tree matches the parent commit tree and [`allow_empty`](crate::porcelain::commit::CommitRequest::allow_empty) is false.
1491    #[error("nothing to commit")]
1492    NothingToCommit,
1493
1494    /// No staged changes are available for a [`CommitScope::Staged`](crate::porcelain::commit::CommitScope::Staged)
1495    /// commit, but the work tree has unstaged or untracked paths.
1496    #[error("nothing staged to commit")]
1497    NothingStagedToCommit {
1498        /// Tracked paths that differ between the index and the work tree.
1499        unstaged: usize,
1500        /// Untracked paths under the work tree.
1501        untracked: usize,
1502    },
1503
1504    /// [`create_commit`](crate::porcelain::commit::create_commit) requires a branch checkout.
1505    #[error("HEAD is detached")]
1506    DetachedHead,
1507
1508    /// `HEAD` is on a branch with no commits yet, but the operation needs a commit
1509    /// ([`Repository::head_commit`](crate::repo::Repository::head_commit),
1510    /// [`replay_commit`](crate::porcelain::replay::replay_commit)).
1511    #[error("no commits yet on this branch")]
1512    UnbornHead,
1513
1514    /// `HEAD` could not be resolved to a branch, detached commit, or unborn branch.
1515    #[error("HEAD is in an unknown state")]
1516    InvalidHead,
1517
1518    /// [`replay_commit`](crate::porcelain::replay::replay_commit) does not replay merge commits
1519    /// until callers can select a mainline parent explicitly.
1520    #[error("merge commit {oid}")]
1521    MergeCommit {
1522        /// Object id of the merge commit that cannot be replayed yet.
1523        oid: crate::objects::ObjectId,
1524    },
1525
1526    /// Cherry-pick source is already checked out as `HEAD`.
1527    #[error("commit already at HEAD")]
1528    ReplaySourceAtHead,
1529
1530    /// Sequencer todo lists a command grit does not implement yet.
1531    #[error("unknown sequencer command '{command}'")]
1532    SequencerUnknownCommand {
1533        /// Verb read from `.git/sequencer/todo`.
1534        command: String,
1535    },
1536
1537    /// [`abort_replay_sequence`](crate::porcelain::replay_sequence::abort_replay_sequence) refuses when `HEAD` moved since the replay started.
1538    #[error("cannot abort replay: HEAD moved since replay started")]
1539    ReplayAbortHeadMoved,
1540
1541    /// [`continue_replay_sequence`](crate::porcelain::replay_sequence::continue_replay_sequence) refuses while unmerged index entries remain.
1542    #[error("cannot continue replay: unmerged index entries remain")]
1543    ReplayContinueUnmerged,
1544
1545    /// No cherry-pick or revert sequence is in progress.
1546    #[error("no cherry-pick or revert in progress")]
1547    NoReplayInProgress,
1548
1549    /// The command would move `HEAD` while a paused operation still depends on it
1550    /// (see [`operation_blocking_switch`](crate::state::operation_blocking_switch)).
1551    #[error("{} in progress", .0.description())]
1552    OperationInProgress(crate::state::InProgressOperation),
1553
1554    /// A local branch is checked out as `HEAD` in a linked or main worktree.
1555    #[error("branch '{branch}' is already checked out in '{worktree_path}'")]
1556    BranchCheckedOutInWorktree {
1557        /// Short branch name (e.g. `main`).
1558        branch: String,
1559        /// Path to the worktree that has the branch checked out.
1560        worktree_path: String,
1561    },
1562
1563    /// `refs/heads/<branch>` does not exist.
1564    #[error("no branch named '{branch}'")]
1565    BranchNotFound {
1566        /// Short branch name (e.g. `topic`).
1567        branch: String,
1568    },
1569
1570    /// [`list_branches`](crate::branch_tracking::list_branches) with [`BranchDetail::Names`](crate::branch_tracking::BranchDetail::Names) cannot use date sort.
1571    #[error("branch list with Names detail cannot use date sort (date sort reads commit objects)")]
1572    BranchListNamesDateSort,
1573
1574    /// [`delete_branch`](crate::porcelain::branch::delete_branch) refuses to delete the branch
1575    /// `HEAD` points at.
1576    #[error("cannot delete '{branch}' — it is the current branch")]
1577    CannotDeleteCurrentBranch {
1578        /// Short branch name (e.g. `main`).
1579        branch: String,
1580    },
1581
1582    /// [`delete_branch`](crate::porcelain::branch::delete_branch) without `force` found commits
1583    /// on the branch that are not reachable from its upstream (or from `HEAD` when it has none).
1584    #[error("the branch '{branch}' is not fully merged")]
1585    BranchNotMerged {
1586        /// Short branch name (e.g. `topic`).
1587        branch: String,
1588    },
1589
1590    /// `refs/tags/<tag>` does not exist.
1591    #[error("no tag named '{tag}'")]
1592    TagNotFound {
1593        /// Short tag name (e.g. `v1.0`).
1594        tag: String,
1595    },
1596
1597    /// Tag create/delete validation failure ([`crate::porcelain::tag::TagError`]).
1598    #[error(transparent)]
1599    Tag(#[from] crate::porcelain::tag::TagError),
1600
1601    /// Linked worktree add/remove/move/lock/repair failure ([`crate::worktree::WorktreeError`]).
1602    #[error(transparent)]
1603    Worktree(#[from] crate::worktree::WorktreeError),
1604
1605    /// Stash commit object has fewer parents or an invalid shape for apply/show.
1606    #[error("corrupt stash commit: {0}")]
1607    CorruptStash(&'static str),
1608
1609    /// `stash@{{n}}` is out of range for the current stash reflog.
1610    #[error("stash entry stash@{{{n}}} not found")]
1611    StashNotFound {
1612        /// Stash reflog index (`stash@{{n}}`).
1613        n: usize,
1614    },
1615
1616    /// Ref does not exist and has no reflog entries.
1617    #[error("unknown ref '{refname}'")]
1618    ReflogUnknownRef {
1619        /// User-supplied or DWIM ref name.
1620        refname: String,
1621    },
1622
1623    /// A reflog selector is missing its reference or `@{n}` delimiters.
1624    #[error("expected ref@{{n}} form, got '{spec}'")]
1625    ReflogInvalidSelector {
1626        /// Selector after trimming surrounding whitespace.
1627        spec: String,
1628    },
1629
1630    /// A reflog selector index is not a nonnegative integer that fits in `usize`.
1631    #[error("invalid reflog index in '{spec}'")]
1632    ReflogInvalidIndex {
1633        /// Selector after trimming surrounding whitespace.
1634        spec: String,
1635        /// Integer parsing failure, including overflow.
1636        #[source]
1637        source: std::num::ParseIntError,
1638    },
1639
1640    /// `ref@{{n}}` is out of range for the current reflog.
1641    #[error("reflog entry {refname}@{{{index}}} not found")]
1642    ReflogIndexOutOfRange {
1643        /// Ref whose reflog was read.
1644        refname: String,
1645        /// Requested newest-first index.
1646        index: usize,
1647    },
1648
1649    /// Pushing or creating a stash ([`crate::repo_handles::RepositoryStash::push`]) requires at least one commit.
1650    #[error("cannot stash without a commit")]
1651    StashNoInitialCommit,
1652
1653    /// Stash create/apply/pop requires a working tree (not a bare repository).
1654    #[error("cannot stash in a bare repository")]
1655    StashBareRepository,
1656
1657    /// [`crate::repo_handles::RepositoryStash::apply`] with `restore_index` could
1658    /// not merge the current index with the stashed index parent.
1659    #[error("conflicts in index while applying stash with --index")]
1660    StashIndexConflict,
1661
1662    /// Incompatible [`StashPushOptions`](crate::porcelain::stash::StashPushOptions) flags.
1663    #[error("{0}")]
1664    StashOptionConflict(String),
1665
1666    /// Partial [`crate::repo_handles::RepositoryStash::push`] hunk selection failed.
1667    #[error(transparent)]
1668    HunkSelection(#[from] crate::porcelain::hunk_select::HunkSelectError),
1669
1670    /// HEAD does not resolve while applying a stash entry.
1671    #[error("missing HEAD while applying stash")]
1672    StashMissingHead,
1673
1674    /// Applying a stash would overwrite locally modified worktree content.
1675    #[error(
1676        "your local changes to the following files would be overwritten by stash apply: {paths}"
1677    )]
1678    StashWouldOverwriteLocalChanges {
1679        /// Space-separated list of paths that would be overwritten.
1680        paths: String,
1681    },
1682
1683    /// The index could not be written after a successful stash apply.
1684    #[error("writing index after stash apply: {0}")]
1685    StashIndexWrite(String),
1686
1687    /// A symlink blob in a stash commit is not valid UTF-8.
1688    #[error("symlink target is not UTF-8")]
1689    StashSymlinkNotUtf8,
1690
1691    /// Applying a stash with an untracked-files parent would replace an existing worktree path.
1692    #[error("stash untracked restore blocked: '{path}' already exists on disk")]
1693    StashCouldNotRestoreUntracked {
1694        /// Worktree path that already exists and blocks untracked restore.
1695        path: String,
1696    },
1697
1698    /// [`CommitRequest::amend`](crate::porcelain::commit::CommitRequest::amend) was set on an unborn branch.
1699    #[error("cannot amend: no commits on this branch yet")]
1700    AmendUnborn,
1701
1702    /// [`RepositoryNotes::add`](crate::repo_handles::RepositoryNotes::add) was called without
1703    /// `force` when a note already exists on the object.
1704    #[error("note for object {object} already exists")]
1705    NoteAlreadyExists {
1706        /// Annotated object that already has a note.
1707        object: crate::objects::ObjectId,
1708    },
1709
1710    /// A configured or environment notes ref is not under `refs/notes/`.
1711    #[error("notes ref '{0}' must start with 'refs/notes/'")]
1712    InvalidNotesRef(String),
1713
1714    /// A notes ref resolves to an object that cannot hold notes (notes reads accept a commit or
1715    /// a tree; notes merges require a commit).
1716    #[error("notes ref '{notes_ref}' points to a {found}, not a notes commit")]
1717    NotesRefNotCommit {
1718        /// Notes ref (or revision) that was read.
1719        notes_ref: String,
1720        /// Kind of object the ref resolved to.
1721        found: crate::objects::ObjectKind,
1722    },
1723
1724    /// An object reached while reading a notes tree has the wrong kind (for example a note
1725    /// entry that is not a blob).
1726    #[error("notes object {oid} is a {found}, expected a {expected}")]
1727    NotesObjectKind {
1728        /// Object that was read.
1729        oid: crate::objects::ObjectId,
1730        /// Kind the notes layout requires at this position.
1731        expected: crate::objects::ObjectKind,
1732        /// Kind actually stored.
1733        found: crate::objects::ObjectKind,
1734    },
1735
1736    /// [`notes_merge_inner`](crate::notes::notes_merge_inner) was asked to merge two notes refs
1737    /// that both have no notes commit.
1738    #[error("cannot merge notes: neither '{local}' nor '{remote}' has any notes")]
1739    NotesMergeBothEmpty {
1740        /// Local notes ref (merge destination).
1741        local: String,
1742        /// Remote notes ref (merge source).
1743        remote: String,
1744    },
1745
1746    /// A manual notes merge was started while an earlier one still has conflict files in the
1747    /// notes merge worktree.
1748    #[error("unfinished notes merge: conflict files remain in {}", path.display())]
1749    NotesMergeInProgress {
1750        /// The notes merge worktree directory.
1751        path: std::path::PathBuf,
1752    },
1753
1754    /// Multi-pack-index load or write failure.
1755    #[error(transparent)]
1756    Midx(#[from] MidxError),
1757
1758    /// Revision parsing failed ([`crate::rev_parse_error::RevParseError`]).
1759    #[error(transparent)]
1760    RevParse(#[from] crate::rev_parse_error::RevParseError),
1761
1762    /// Revision walking / `rev-list` option parsing failed ([`crate::rev_list_error::RevListError`]).
1763    #[error(transparent)]
1764    RevList(#[from] crate::rev_list_error::RevListError),
1765
1766    /// A Git hook subprocess failed or could not be started.
1767    #[error(transparent)]
1768    Hook(#[from] crate::hooks::HookError),
1769
1770    /// History rewrite planning failed ([`crate::history::HistoryError`]).
1771    #[error(transparent)]
1772    History(#[from] crate::history::HistoryError),
1773
1774    /// The operation needs the files-backed `objects/` directory, but the repository's
1775    /// [`Odb`](crate::odb::Odb) uses a custom [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary)
1776    /// store. Nothing was written; use a files-backed repository for this operation.
1777    #[error("{operation} requires the files-backed object store; it is not supported with a custom primary")]
1778    UnsupportedObjectStore {
1779        /// Operation that was refused (for example `"fetch"`, `"push"`, `"gc"`).
1780        operation: &'static str,
1781    },
1782
1783    /// [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) was given an
1784    /// [`OdbBuilder`](crate::odb::OdbBuilder) rooted somewhere other than the repository's
1785    /// `objects/` directory.
1786    #[error(
1787        "object database {} is not the repository's objects directory {}",
1788        builder.display(),
1789        repository.display()
1790    )]
1791    OdbObjectsDirMismatch {
1792        /// The builder's objects directory, canonicalized when it exists.
1793        builder: std::path::PathBuf,
1794        /// The repository's canonical objects directory.
1795        repository: std::path::PathBuf,
1796    },
1797
1798    /// [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) was given an
1799    /// [`OdbBuilder`](crate::odb::OdbBuilder) whose custom primary or read source hashes
1800    /// objects with a different algorithm than the repository's `extensions.objectformat`
1801    /// (SHA-1 when unset).
1802    #[error(
1803        "object store hashes with {} but the repository's object format is {}",
1804        store.name(),
1805        repository.name()
1806    )]
1807    #[non_exhaustive]
1808    ObjectFormatMismatch {
1809        /// The repository's object format from its config.
1810        repository: crate::objects::HashAlgo,
1811        /// The first injected store's [`hash_algo`](crate::odb::store::ObjectStore::hash_algo)
1812        /// that differs from `repository`.
1813        store: crate::objects::HashAlgo,
1814    },
1815
1816    /// Parsing a [`crate::tree_sha256::TreeSha256`] from hex failed (expected 64 hex digits).
1817    #[error("invalid tree-sha256 hex")]
1818    TreeSha256InvalidHex,
1819
1820    /// A revision passed to [`crate::tree_sha256::tree_sha256`] did not peel to a commit or tree.
1821    #[error("tree-sha256: revision resolves to {found:?}, not a commit or tree")]
1822    TreeSha256InvalidRevision {
1823        /// Object kind after peeling annotated tags.
1824        found: crate::objects::ObjectKind,
1825    },
1826
1827    /// One or more submodules required for [`crate::tree_sha256::tree_sha256`] are missing.
1828    #[error("tree-sha256: missing submodules")]
1829    TreeSha256MissingSubmodules(Vec<crate::tree_sha256::MissingSubmodule>),
1830
1831    /// `--tree-sha256` was requested with signing explicitly disabled.
1832    #[error("tree-sha256 requires a signed tag")]
1833    TreeSha256RequiresSigning,
1834
1835    /// [`crate::tree_sha256::verify_tag`] was given an object that is not an annotated tag.
1836    #[error("verify-tag: object {oid} is a {found:?}, not an annotated tag")]
1837    TagVerifyNotTag {
1838        /// Object id passed to verification.
1839        oid: crate::objects::ObjectId,
1840        /// Kind stored in the ODB.
1841        found: crate::objects::ObjectKind,
1842    },
1843
1844    /// Annotated tag header block has no `object` line.
1845    #[error("verify-tag: tag has no object header")]
1846    TagVerifyMissingObjectHeader,
1847
1848    /// Annotated tag carries more than one `tree-sha256` header line.
1849    #[error("verify-tag: tag has more than one tree-sha256 header")]
1850    TagVerifyDuplicateTreeSha256Header,
1851
1852    /// Annotated tag tree-sha256 header parsing failed.
1853    #[error(transparent)]
1854    TreeSha256Claim(#[from] crate::tree_sha256::TreeSha256ClaimError),
1855
1856    /// `grit blame --ignore-rev` did not resolve to a commit.
1857    #[error("cannot find revision {rev} to ignore")]
1858    BlameIgnoreRevUnknown {
1859        /// Revision expression from `--ignore-rev`.
1860        rev: String,
1861    },
1862
1863    /// `--ignore-revs-file` could not be read.
1864    #[error("could not open object name list: {path}")]
1865    BlameIgnoreRevsFileOpen {
1866        /// Path argument (before expansion).
1867        path: String,
1868        /// Underlying open error.
1869        #[source]
1870        source: std::io::Error,
1871    },
1872
1873    /// A line in an ignore-revs file is not a full-length object id.
1874    #[error("invalid object name: {rev}")]
1875    BlameIgnoreRevsFileInvalidRev {
1876        /// Path argument for the file.
1877        path: String,
1878        /// Offending line text after comment/trim handling.
1879        rev: String,
1880    },
1881}
1882
1883impl Error {
1884    /// Stable snake_case name of this error's variant (for example `"not_a_repository"`),
1885    /// for scripts and serialized output that must branch on the failure kind.
1886    ///
1887    /// The name is the variant name in snake_case; it changes only when the variant is
1888    /// renamed. Match on the error value itself in Rust code.
1889    ///
1890    /// # Examples
1891    ///
1892    /// ```
1893    /// use grit_lib::error::Error;
1894    ///
1895    /// assert_eq!(Error::NothingToCommit.kind_name(), "nothing_to_commit");
1896    /// assert_eq!(Error::Cancelled.kind_name(), "cancelled");
1897    /// ```
1898    #[must_use]
1899    pub fn kind_name(&self) -> &'static str {
1900        match self {
1901            Self::NotARepository(..) => "not_a_repository",
1902            Self::ForbiddenBareRepository(..) => "forbidden_bare_repository",
1903            Self::DubiousOwnership(..) => "dubious_ownership",
1904            Self::UnsupportedRepositoryFormatVersion(..) => "unsupported_repository_format_version",
1905            Self::UnsupportedRepositoryExtension(..) => "unsupported_repository_extension",
1906            Self::InvalidRefStorageFormat { .. } => "invalid_ref_storage_format",
1907            Self::InitObjectFormatChange => "init_object_format_change",
1908            Self::InitBareWithSeparateGitDir => "init_bare_with_separate_git_dir",
1909            Self::InvalidObjectId(..) => "invalid_object_id",
1910            Self::HashCollision { .. } => "hash_collision",
1911            Self::ObjectNotFound(..) => "object_not_found",
1912            Self::RevisionNotFound(..) => "revision_not_found",
1913            Self::PromisedObjectMissing(..) => "promised_object_missing",
1914            Self::CorruptObject(..) => "corrupt_object",
1915            Self::PackIndexChecksumMismatch { .. } => "pack_index_checksum_mismatch",
1916            Self::UnresolvedDeltas { .. } => "unresolved_deltas",
1917            Self::InvalidObject { .. } => "invalid_object",
1918            Self::InvalidTreeEntry(..) => "invalid_tree_entry",
1919            Self::InvalidSignature(..) => "invalid_signature",
1920            Self::UnexpectedObjectKind { .. } => "unexpected_object_kind",
1921            Self::CommitGraphParentMissing { .. } => "commit_graph_parent_missing",
1922            Self::DeltaChainTooDeep { .. } => "delta_chain_too_deep",
1923            Self::UnknownObjectType(..) => "unknown_object_type",
1924            Self::ObjectHeaderTooLong { .. } => "object_header_too_long",
1925            Self::Io(..) => "io",
1926            Self::LockHeld { .. } => "lock_held",
1927            Self::Zlib(..) => "zlib",
1928            Self::LooseHashMismatch { .. } => "loose_hash_mismatch",
1929            Self::LooseObjectWrite { .. } => "loose_object_write",
1930            Self::WorktreePathIo { .. } => "worktree_path_io",
1931            Self::GitDirPathIo { .. } => "git_dir_path_io",
1932            Self::ObjectStreamTruncated { .. } => "object_stream_truncated",
1933            Self::WorktreeFileChanged { .. } => "worktree_file_changed",
1934            Self::IndexError(..) => "index_error",
1935            Self::IndexUnmerged => "index_unmerged",
1936            Self::MaintenanceRunning { .. } => "maintenance_running",
1937            Self::UnmergedConflictMarkers { .. } => "unmerged_conflict_markers",
1938            Self::IndexPathPrefixConflict { .. } => "index_path_prefix_conflict",
1939            Self::IndexFsmonitorExtensionTruncated => "index_fsmonitor_extension_truncated",
1940            Self::IndexFsmonitorExtensionBadVersion(..) => "index_fsmonitor_extension_bad_version",
1941            Self::IndexFsmonitorExtensionEwahInvalid => "index_fsmonitor_extension_ewah_invalid",
1942            Self::CacheTreeCorrupt => "cache_tree_corrupt",
1943            Self::InvalidRef(..) => "invalid_ref",
1944            Self::PackedRefsUnexpectedLine { .. } => "packed_refs_unexpected_line",
1945            Self::PathError(..) => "path_error",
1946            Self::PathspecFromFile(..) => "pathspec_from_file",
1947            Self::InvalidPath(..) => "invalid_path",
1948            Self::InvalidPathComponent { .. } => "invalid_path_component",
1949            Self::Config(..) => "config",
1950            Self::Branch(..) => "branch",
1951            Self::RefLock(..) => "ref_lock",
1952            Self::RefStore(..) => "ref_store",
1953            Self::Reftable(..) => "reftable",
1954            Self::Filter(..) => "filter",
1955            Self::Apply(..) => "apply",
1956            Self::DiffPath(..) => "diff_path",
1957            Self::PathOutsideRepo(..) => "path_outside_repo",
1958            Self::Signing(..) => "signing",
1959            Self::Auth { .. } => "auth",
1960            Self::PushAtomicUnsupported => "push_atomic_unsupported",
1961            Self::PushOptionsUnsupported => "push_options_unsupported",
1962            Self::FetchUnshallowNotShallow => "fetch_unshallow_not_shallow",
1963            Self::FetchShallowUnsupported => "fetch_shallow_unsupported",
1964            Self::FetchDeepenRelativeUnsupported => "fetch_deepen_relative_unsupported",
1965            Self::BundleMissingPrerequisites => "bundle_missing_prerequisites",
1966            Self::ReceivedPackTempMissing { .. } => "received_pack_temp_missing",
1967            Self::InvalidAttrPathspec { .. } => "invalid_attr_pathspec",
1968            Self::PathspecNoMatch { .. } => "pathspec_no_match",
1969            Self::RestoreConflictWithStaged => "restore_conflict_with_staged",
1970            Self::RestoreConflictWithSource => "restore_conflict_with_source",
1971            Self::RestoreConflictMissingTheirs { .. } => "restore_conflict_missing_theirs",
1972            Self::RestoreConflictMissingOurs { .. } => "restore_conflict_missing_ours",
1973            Self::RestoreConflictNotUnmerged { .. } => "restore_conflict_not_unmerged",
1974            Self::RestorePathUnmerged { .. } => "restore_path_unmerged",
1975            Self::PathspecBeyondSymlink { .. } => "pathspec_beyond_symlink",
1976            Self::PartialCommitDuringMerge => "partial_commit_during_merge",
1977            Self::PathspecIgnored { .. } => "pathspec_ignored",
1978            Self::RemoveBlocked { .. } => "remove_blocked",
1979            Self::NotRemovingRecursively(..) => "not_removing_recursively",
1980            Self::RequiresWorkTree => "requires_work_tree",
1981            Self::EmptyCommitMessage => "empty_commit_message",
1982            Self::RemovePathsRequired => "remove_paths_required",
1983            Self::CommitPathsRequired => "commit_paths_required",
1984            Self::MoveSourceNotTracked { .. } => "move_source_not_tracked",
1985            Self::MoveDestinationExists { .. } => "move_destination_exists",
1986            Self::MoveIntoSubdirectory { .. } => "move_into_subdirectory",
1987            Self::MoveIntoSelf { .. } => "move_into_self",
1988            Self::MoveNothingToMove { .. } => "move_nothing_to_move",
1989            Self::MoveDuplicateDestination { .. } => "move_duplicate_destination",
1990            Self::MoveBadSource { .. } => "move_bad_source",
1991            Self::MoveMissingIndexEntry { .. } => "move_missing_index_entry",
1992            Self::RemoteUnreadable { .. } => "remote_unreadable",
1993            Self::Resumable(..) => "resumable",
1994            Self::FetchRefspec(..) => "fetch_refspec",
1995            Self::FetchNonFastForward { .. } => "fetch_non_fast_forward",
1996            Self::FetchIntoCheckedOutBranch { .. } => "fetch_into_checked_out_branch",
1997            Self::WouldOverwrite { .. } => "would_overwrite",
1998            Self::DirtyWorktree { .. } => "dirty_worktree",
1999            Self::InvalidIdentity(..) => "invalid_identity",
2000            Self::Cancelled => "cancelled",
2001            Self::Identity(..) => "identity",
2002            Self::Message(..) => "message",
2003            Self::NotFastForward => "not_fast_forward",
2004            Self::NothingToCommit => "nothing_to_commit",
2005            Self::UnrelatedHistories { .. } => "unrelated_histories",
2006            Self::ObjectFormatMismatch { .. } => "object_format_mismatch",
2007            Self::CheckoutRollbackIncomplete { .. } => "checkout_rollback_incomplete",
2008            Self::NothingStagedToCommit { .. } => "nothing_staged_to_commit",
2009            Self::DetachedHead => "detached_head",
2010            Self::UnbornHead => "unborn_head",
2011            Self::InvalidHead => "invalid_head",
2012            Self::MergeCommit { .. } => "merge_commit",
2013            Self::ReplaySourceAtHead => "replay_source_at_head",
2014            Self::SequencerUnknownCommand { .. } => "sequencer_unknown_command",
2015            Self::ReplayAbortHeadMoved => "replay_abort_head_moved",
2016            Self::ReplayContinueUnmerged => "replay_continue_unmerged",
2017            Self::NoReplayInProgress => "no_replay_in_progress",
2018            Self::OperationInProgress(..) => "operation_in_progress",
2019            Self::BranchCheckedOutInWorktree { .. } => "branch_checked_out_in_worktree",
2020            Self::BranchNotFound { .. } => "branch_not_found",
2021            Self::BranchListNamesDateSort => "branch_list_names_date_sort",
2022            Self::CannotDeleteCurrentBranch { .. } => "cannot_delete_current_branch",
2023            Self::BranchNotMerged { .. } => "branch_not_merged",
2024            Self::TagNotFound { .. } => "tag_not_found",
2025            Self::Tag(..) => "tag",
2026            Self::Worktree(..) => "worktree",
2027            Self::CorruptStash(..) => "corrupt_stash",
2028            Self::StashNotFound { .. } => "stash_not_found",
2029            Self::ReflogUnknownRef { .. } => "reflog_unknown_ref",
2030            Self::ReflogInvalidSelector { .. } => "reflog_invalid_selector",
2031            Self::ReflogInvalidIndex { .. } => "reflog_invalid_index",
2032            Self::ReflogIndexOutOfRange { .. } => "reflog_index_out_of_range",
2033            Self::StashNoInitialCommit => "stash_no_initial_commit",
2034            Self::StashBareRepository => "stash_bare_repository",
2035            Self::StashIndexConflict => "stash_index_conflict",
2036            Self::StashOptionConflict(..) => "stash_option_conflict",
2037            Self::HunkSelection(..) => "hunk_selection",
2038            Self::StashMissingHead => "stash_missing_head",
2039            Self::StashWouldOverwriteLocalChanges { .. } => "stash_would_overwrite_local_changes",
2040            Self::StashIndexWrite(..) => "stash_index_write",
2041            Self::StashSymlinkNotUtf8 => "stash_symlink_not_utf8",
2042            Self::StashCouldNotRestoreUntracked { .. } => "stash_could_not_restore_untracked",
2043            Self::AmendUnborn => "amend_unborn",
2044            Self::NoteAlreadyExists { .. } => "note_already_exists",
2045            Self::InvalidNotesRef(..) => "invalid_notes_ref",
2046            Self::NotesRefNotCommit { .. } => "notes_ref_not_commit",
2047            Self::NotesObjectKind { .. } => "notes_object_kind",
2048            Self::NotesMergeBothEmpty { .. } => "notes_merge_both_empty",
2049            Self::NotesMergeInProgress { .. } => "notes_merge_in_progress",
2050            Self::Midx(..) => "midx",
2051            Self::RevParse(..) => "rev_parse",
2052            Self::RevList(..) => "rev_list",
2053            Self::Hook(..) => "hook",
2054            Self::History(..) => "history",
2055            Self::UnsupportedObjectStore { .. } => "unsupported_object_store",
2056            Self::OdbObjectsDirMismatch { .. } => "odb_objects_dir_mismatch",
2057            Self::TreeSha256InvalidHex => "tree_sha256_invalid_hex",
2058            Self::TreeSha256InvalidRevision { .. } => "tree_sha256_invalid_revision",
2059            Self::TreeSha256MissingSubmodules(..) => "tree_sha256_missing_submodules",
2060            Self::TreeSha256RequiresSigning => "tree_sha256_requires_signing",
2061            Self::TagVerifyNotTag { .. } => "tag_verify_not_tag",
2062            Self::TagVerifyMissingObjectHeader => "tag_verify_missing_object_header",
2063            Self::TagVerifyDuplicateTreeSha256Header => "tag_verify_duplicate_tree_sha256_header",
2064            Self::TreeSha256Claim(..) => "tree_sha256_claim",
2065            Self::BlameIgnoreRevUnknown { .. } => "blame_ignore_rev_unknown",
2066            Self::BlameIgnoreRevsFileOpen { .. } => "blame_ignore_revs_file_open",
2067            Self::BlameIgnoreRevsFileInvalidRev { .. } => "blame_ignore_revs_file_invalid_rev",
2068        }
2069    }
2070
2071    /// Build [`Error::DirtyWorktree`], sorting and deduplicating `paths`.
2072    #[must_use]
2073    pub fn dirty_worktree(operation: WorktreeOperation, mut paths: Vec<String>) -> Self {
2074        paths.sort();
2075        paths.dedup();
2076        Self::DirtyWorktree { operation, paths }
2077    }
2078
2079    /// Build [`Error::WouldOverwrite`] for switch and similar tree transitions.
2080    #[must_use]
2081    pub fn would_overwrite(reason: OverwriteReason, mut paths: Vec<String>) -> Self {
2082        paths.sort();
2083        paths.dedup();
2084        let message = match reason {
2085            OverwriteReason::LocalChanges => {
2086                "you have uncommitted changes — commit them before switching".into()
2087            }
2088            OverwriteReason::Untracked => {
2089                let first = paths.first().map(String::as_str).unwrap_or("<path>");
2090                format!("untracked file '{first}' would be overwritten — move or remove it first")
2091            }
2092        };
2093        Self::WouldOverwrite {
2094            reason,
2095            paths,
2096            message,
2097        }
2098    }
2099
2100    /// Build [`Error::RemoteUnreadable`] for `url`.
2101    #[must_use]
2102    pub fn remote_unreadable(url: impl Into<String>, reason: RemoteUnreadableReason) -> Self {
2103        Self::RemoteUnreadable {
2104            url: Some(url.into()),
2105            reason,
2106        }
2107    }
2108
2109    /// Whether this is [`Error::RemoteUnreadable`]: the remote could not be read or written
2110    /// at all (as opposed to a rejected update or an empty remote).
2111    #[must_use]
2112    pub fn is_remote_unreadable(&self) -> bool {
2113        matches!(self, Self::RemoteUnreadable { .. })
2114    }
2115
2116    /// Whether this is [`Error::Cancelled`] or a nested history failure that was cancelled.
2117    #[must_use]
2118    pub fn is_cancelled(&self) -> bool {
2119        match self {
2120            Self::Cancelled => true,
2121            Self::History(e) => e.is_cancelled(),
2122            Self::CheckoutRollbackIncomplete { source, .. } => source.is_cancelled(),
2123            _ => false,
2124        }
2125    }
2126
2127    /// Build [`Error::InvalidPathComponent`].
2128    #[must_use]
2129    pub fn invalid_path_component(
2130        path: impl Into<String>,
2131        reason: crate::path_validate::PathComponentError,
2132    ) -> Self {
2133        Self::InvalidPathComponent {
2134            path: path.into(),
2135            reason,
2136        }
2137    }
2138
2139    /// When set, the CLI should prefix stderr with `fatal: ` for Git-compatible commands.
2140    #[must_use]
2141    pub fn wants_fatal_prefix(&self) -> bool {
2142        matches!(
2143            self,
2144            Error::Config(_)
2145                | Error::RefLock(_)
2146                | Error::Filter(_)
2147                | Error::PathOutsideRepo(_)
2148                | Error::DiffPath(_)
2149        )
2150    }
2151
2152    /// Format for Git-style stderr (adds `fatal:` / `error:` when appropriate).
2153    #[must_use]
2154    pub fn git_stderr_message(&self) -> String {
2155        match self {
2156            Error::Apply(ApplyError::CorruptPatch { .. }) => {
2157                crate::diagnostics::error_line(&self.to_string())
2158            }
2159            Error::Message(s) => s.clone(),
2160            Error::WouldOverwrite { message, .. } => message.clone(),
2161            e if e.wants_fatal_prefix() => crate::diagnostics::fatal_line(&e.to_string()),
2162            other => other.to_string(),
2163        }
2164    }
2165}
2166
2167impl From<crate::refs::store::RefStoreError> for Error {
2168    fn from(err: crate::refs::store::RefStoreError) -> Self {
2169        match err {
2170            crate::refs::store::RefStoreError::GitDirPathIo {
2171                path,
2172                operation,
2173                os_error,
2174            } => Error::GitDirPathIo {
2175                path,
2176                operation,
2177                source: crate::refs::store::RefStoreError::git_dir_io_source(os_error),
2178            },
2179            other => Error::RefStore(other),
2180        }
2181    }
2182}
2183
2184impl From<crate::lockfile::LockError> for Error {
2185    /// [`LockError::Held`](crate::lockfile::LockError::Held) becomes [`Error::LockHeld`] with the
2186    /// recorded [`LockHolder`](crate::lockfile::LockHolder), and I/O failures become [`Error::Io`].
2187    #[cold]
2188    fn from(err: crate::lockfile::LockError) -> Self {
2189        use crate::lockfile::{LockError, LockHolder};
2190        match err {
2191            LockError::Held { path } => Self::LockHeld {
2192                holder: LockHolder::read(&path),
2193                path,
2194            },
2195            LockError::Io { source, .. } => Self::Io(source),
2196        }
2197    }
2198}
2199
2200/// Convenience alias for `Result<T, Error>`.
2201pub type Result<T> = std::result::Result<T, Error>;
2202
2203/// Serializes as `{"kind": <Error::kind_name>, "message": <Display text>}`.
2204#[cfg(feature = "serde-models")]
2205impl serde::Serialize for Error {
2206    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
2207    where
2208        S: serde::Serializer,
2209    {
2210        use serde::ser::SerializeStruct;
2211
2212        let mut out = serializer.serialize_struct("Error", 2)?;
2213        out.serialize_field("kind", self.kind_name())?;
2214        out.serialize_field("message", &self.to_string())?;
2215        out.end()
2216    }
2217}