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
608/// Path lookup failures while formatting a diff.
609#[derive(Debug, Error, Clone, PartialEq, Eq)]
610#[non_exhaustive]
611pub enum DiffPathError {
612    /// The requested path is not part of the diff being formatted.
613    #[error("No such path '{path}' in the diff")]
614    NoSuchPathInDiff {
615        /// Path that was requested from the diff.
616        path: String,
617    },
618}
619
620/// `--pathspec-from-file` / `--pathspec-file-nul` parse failure.
621#[derive(Debug, Error, Clone, PartialEq, Eq)]
622#[non_exhaustive]
623pub enum PathspecFromFileParseError {
624    /// A NUL-separated entry is not valid UTF-8.
625    #[error("invalid UTF-8 in pathspec-from-file (NUL mode)")]
626    InvalidUtf8InNulRecord,
627    /// A NUL-terminated entry used C-style quoting (forbidden in NUL mode).
628    #[error("pathspec-from-file: line is not NUL terminated: {line}")]
629    QuotedLineInNulMode {
630        /// Offending entry text.
631        line: String,
632    },
633    /// A line-mode entry had invalid double-quote wrapping.
634    #[error("invalid C-style quoting: {line}")]
635    InvalidCStyleQuoting {
636        /// Raw line from the file.
637        line: String,
638    },
639    /// Backslash escape at end of a quoted line.
640    #[error("invalid escape at end of string")]
641    InvalidEscapeAtEnd,
642    /// Octal escape shorter than three digits.
643    #[error("truncated octal escape")]
644    TruncatedOctalEscape,
645    /// Octal escape bytes are not valid UTF-8.
646    #[error("invalid octal bytes")]
647    InvalidOctalBytes,
648    /// Octal digits do not form a valid byte value.
649    #[error("invalid octal escape value")]
650    InvalidOctalValue,
651    /// Unknown escape after backslash in a quoted line.
652    #[error("invalid escape sequence \\{escape}")]
653    InvalidEscapeSequence {
654        /// Character after the backslash.
655        escape: char,
656    },
657    /// Quoted line decoded to invalid UTF-8.
658    #[error("invalid UTF-8 in quoted pathspec")]
659    InvalidUtf8InQuotedLine,
660}
661
662/// Failure while resolving argv and `--pathspec-from-file` pathspecs against a work tree.
663#[derive(Debug, Error, Clone, PartialEq, Eq)]
664#[non_exhaustive]
665pub enum PathspecResolveError {
666    /// An argv pathspec was the empty string.
667    #[error("invalid path ''")]
668    EmptyPathspec,
669    /// Resolved path lies outside the repository work tree.
670    #[error("{elt}: '{path}' is outside repository at '{work_tree}'")]
671    OutsideRepository {
672        /// User-facing pathspec token (argv element).
673        elt: String,
674        /// Resolved absolute path outside the work tree.
675        path: String,
676        /// Canonical work tree root.
677        work_tree: String,
678    },
679    /// `--pathspec-from-file` bytes could not be parsed.
680    #[error(transparent)]
681    FromFile(#[from] PathspecFromFileParseError),
682    /// Command requires a working tree but the repository is bare.
683    #[error("this operation needs a working tree")]
684    NeedsWorkTree,
685}
686
687/// Why a `:(attr:...)` pathspec clause was rejected.
688#[derive(Debug, Error, Clone, PartialEq, Eq)]
689#[non_exhaustive]
690pub enum AttrPathspecError {
691    /// The clause names no attribute requirement (`:(attr:)`).
692    #[error("attr requirement list is empty")]
693    Empty,
694    /// More than one `attr:` clause appears in one pathspec.
695    #[error("a pathspec may contain at most one attr clause")]
696    MultipleAttrClauses,
697    /// A requirement names an attribute that is not valid (`-a=b`, `bad name`, `bad/name`).
698    #[error("'{0}' is not a usable attribute name")]
699    InvalidName(String),
700    /// A `\` escape ends the value or precedes whitespace.
701    #[error("attr value ends with a dangling escape")]
702    DanglingEscape,
703    /// A `\` escape precedes something other than `,`.
704    #[error("attr values may only escape ','")]
705    UnsupportedEscape,
706}
707
708/// Path outside the repository during pathspec or transport resolution.
709#[derive(Debug, Error, Clone, PartialEq, Eq)]
710#[non_exhaustive]
711pub enum PathOutsideRepoError {
712    /// A resolved path escapes the repository work tree or git directory.
713    #[error("{path}: '{subpath}' is outside repository at '{repo_root}'")]
714    OutsideRepository {
715        /// Repository-relative or filesystem path related to the failure.
716        path: String,
717        /// Path component that lies outside the repository root.
718        subpath: String,
719        /// Canonical repository root used for the outside-path check.
720        repo_root: String,
721    },
722}
723
724fn maintenance_running_message(holder: &crate::maintenance::GcLockHolder) -> String {
725    match (holder.pid, holder.host.as_deref()) {
726        (Some(pid), Some(host)) => {
727            format!("repository maintenance is already running (process {pid} on {host})")
728        }
729        (Some(pid), None) => format!("repository maintenance is already running (process {pid})"),
730        _ => "repository maintenance is already running".to_owned(),
731    }
732}
733
734fn restore_unmerged_message(paths: &[String]) -> String {
735    match paths {
736        [path] => format!("path '{path}' is unmerged"),
737        _ => format!(
738            "paths are unmerged: {}",
739            paths
740                .iter()
741                .map(|p| format!("'{p}'"))
742                .collect::<Vec<_>>()
743                .join(", ")
744        ),
745    }
746}
747
748/// The top-level error type for all grit-lib operations.
749///
750/// # Examples
751///
752/// ```
753/// use grit_lib::error::Error;
754///
755/// let err = Error::Message("repository not found".into());
756/// assert!(err.to_string().contains("repository not found"));
757/// ```
758#[derive(Debug, Error)]
759#[non_exhaustive]
760pub enum Error {
761    /// A repository could not be found or is structurally invalid.
762    #[error("not a git repository (or any of the parent directories): {0}")]
763    NotARepository(String),
764
765    /// A bare repository was found but access is forbidden by safe.bareRepository.
766    #[error("cannot use bare repository '{0}' (safe.bareRepository is 'explicit')")]
767    ForbiddenBareRepository(String),
768
769    /// The repository is owned by a different user (safe.directory).
770    #[error("detected dubious ownership in repository at '{0}'")]
771    DubiousOwnership(String),
772
773    /// Repository format version is not supported by this implementation.
774    #[error("unsupported repository format version '{0}'")]
775    UnsupportedRepositoryFormatVersion(u32),
776
777    /// Repository declares an unsupported extension.
778    #[error("unknown repository extension '{0}'")]
779    UnsupportedRepositoryExtension(String),
780
781    /// `extensions.refStorage` names an unsupported backend.
782    #[error("invalid value for 'extensions.refstorage': '{value}'")]
783    InvalidRefStorageFormat {
784        /// Raw `extensions.refStorage` value from the repository config.
785        value: String,
786    },
787
788    /// [`crate::repo::Repository::init_with_outcome`] refused to change object format on reinit.
789    #[error("cannot change object format in an existing repository")]
790    InitObjectFormatChange,
791
792    /// [`crate::repo::Repository::init_with_outcome`] with incompatible `--bare` and separate git dir.
793    #[error("cannot combine bare repository with separate git directory")]
794    InitBareWithSeparateGitDir,
795
796    /// A supplied object ID string was not valid hex or the wrong length.
797    #[error("invalid object id '{0}'")]
798    InvalidObjectId(String),
799
800    /// SHA-1 object hashing detected a chosen-prefix collision (sha1dc).
801    #[error("SHA-1 hash collision detected for object {oid}")]
802    HashCollision {
803        /// Plain SHA-1 digest for the colliding input (not safe to use as an object id).
804        oid: crate::objects::ObjectId,
805    },
806
807    /// The requested object does not exist in the object store.
808    #[error("object not found: {0}")]
809    ObjectNotFound(String),
810
811    /// A revision expression (branch, tag, object id, `x~n`, `ORIG_HEAD`, …) names nothing in
812    /// the repository. Holds the expression as the caller wrote it.
813    #[error("unknown revision '{0}'")]
814    RevisionNotFound(String),
815
816    /// A partial-clone promisor object is not present locally and no fetcher ran.
817    #[error("promised object not available locally: {0}")]
818    PromisedObjectMissing(crate::objects::ObjectId),
819
820    /// An object's stored data is corrupt or malformed.
821    #[error("corrupt object: {0}")]
822    CorruptObject(String),
823
824    /// A pack index (`.idx`) file's trailing checksum does not match its contents.
825    ///
826    /// Only reported by reads that verify the index trailer, such as
827    /// [`crate::pack::read_pack_index`]; the checksum of the `.pack` the index
828    /// describes is checked separately.
829    #[error("corrupt object: index checksum mismatch for {}: {mismatch}", .path.display())]
830    PackIndexChecksumMismatch {
831        /// The `.idx` file that failed verification.
832        path: std::path::PathBuf,
833        /// Expected and stored trailer digests.
834        mismatch: crate::hash::TrailerMismatch,
835    },
836
837    /// Indexing or unpacking a pack left deltas whose bases are neither in the pack nor
838    /// in the object database: a thin pack without its bases, or a ref-delta cycle.
839    #[error("corrupt object: {count} delta(s) could not be resolved")]
840    UnresolvedDeltas {
841        /// Number of deltas left unresolved.
842        count: u64,
843    },
844
845    /// [`crate::objects::Objects::write`] refused a payload that `git fsck` would reject;
846    /// nothing was written.
847    #[error("refusing to write invalid {kind} object: {}", .check.report_line())]
848    InvalidObject {
849        /// Kind the payload was meant to be stored as.
850        kind: crate::objects::ObjectKind,
851        /// The first fsck rule the payload breaks (`check.id` is Git's fsck message id).
852        check: crate::fsck_standalone::FsckError,
853    },
854
855    /// [`crate::objects::Objects::write_tree`] refused an entry.
856    #[error(transparent)]
857    InvalidTreeEntry(#[from] crate::objects::TreeEntryError),
858
859    /// A [`crate::ident::Signature`] cannot be written as an author, committer or tagger line.
860    #[error(transparent)]
861    InvalidSignature(#[from] crate::ident::SignatureError),
862
863    /// An object exists but has a different kind than the operation requires, such as a
864    /// tree or blob where a commit was expected.
865    #[error("object {oid} is a {actual}, not a {expected}")]
866    UnexpectedObjectKind {
867        /// The object that was read.
868        oid: crate::objects::ObjectId,
869        /// The kind the operation required.
870        expected: crate::objects::ObjectKind,
871        /// The kind the object actually has.
872        actual: crate::objects::ObjectKind,
873    },
874
875    /// A commit-graph write was given a commit whose parent is neither among the commits
876    /// being written nor in the base graph layers, so its parent edge cannot be encoded.
877    #[error("commit-graph cannot encode parent {parent} of commit {commit}: parent not in graph")]
878    CommitGraphParentMissing {
879        /// Commit whose parent list was being encoded.
880        commit: crate::objects::ObjectId,
881        /// Parent that is missing from the graph.
882        parent: crate::objects::ObjectId,
883    },
884
885    /// In-pack delta resolution exceeded the read-side chain depth limit (guards
886    /// against cyclic ref-delta chains and unbounded recursion; Git has no read limit).
887    #[error("delta chain too deep (limit {limit})")]
888    DeltaChainTooDeep {
889        /// Maximum delta chain depth allowed while reading packed objects.
890        limit: usize,
891    },
892
893    /// An unsupported or unknown object type was encountered.
894    #[error("unknown object type '{0}'")]
895    UnknownObjectType(String),
896
897    /// Loose object header type field exceeds Git's 32-byte limit.
898    #[error("header for {oid} too long, exceeds 32 bytes")]
899    ObjectHeaderTooLong {
900        /// Object id (hex) of the loose object with an oversized header.
901        oid: String,
902    },
903
904    /// An I/O error from the underlying filesystem.
905    #[error("I/O error: {0}")]
906    Io(#[from] std::io::Error),
907
908    /// A lock file (for example `.git/index.lock` or `refs/heads/main.lock`) already exists, so
909    /// another process is updating the same file or a crashed one left the lock behind.
910    ///
911    /// Operations that return this before changing anything leave the repository untouched.
912    /// The lock file belongs to whoever created it and is never removed. Retry once the other
913    /// process finishes, or delete `path` when no process owns it (a `holder` with
914    /// `running: false` means the lock is stale).
915    #[error("lock file {} already exists", path.display())]
916    LockHeld {
917        /// The lock file that could not be created.
918        path: std::path::PathBuf,
919        /// Owner recorded in the lock's pid sidecar (`core.lockfilePid`), when one was written.
920        holder: Option<crate::lockfile::LockHolder>,
921    },
922
923    /// A zlib compression or decompression failure.
924    #[error("zlib error: {0}")]
925    Zlib(String),
926
927    /// Loose object bytes hash to a different OID than the file path implies (`git fsck` / `read_loose_object`).
928    #[error("{real_oid}: hash-path mismatch, found at: {path}")]
929    LooseHashMismatch {
930        /// Repository-relative or filesystem path to the loose object file.
931        path: String,
932        /// Hex object id of the unpacked contents.
933        real_oid: String,
934    },
935
936    /// A prepared loose object could not be written during parallel index staging.
937    #[error("could not store {path}: {source}")]
938    LooseObjectWrite {
939        /// Index-relative path of the blob being stored.
940        path: String,
941        /// Underlying filesystem error from the object store.
942        source: std::io::Error,
943    },
944
945    /// Filesystem I/O failed on a repository-relative worktree path (checkout, staging, diff).
946    #[error("{}", crate::worktree::io_error::format_worktree_path_io_message(.path, *operation, .source))]
947    WorktreePathIo {
948        /// Index- or worktree-relative path grit was operating on.
949        path: String,
950        /// What the operation was trying to do when I/O failed.
951        operation: WorktreePathOperation,
952        /// Underlying OS error.
953        source: std::io::Error,
954    },
955
956    /// Filesystem I/O failed on a path under the repository git directory (objects, refs, logs, …).
957    #[error("{}", crate::git_dir::io_error::format_git_dir_io_message(.path, *operation, .source))]
958    GitDirPathIo {
959        /// Display path such as `.git/objects` or `.git/refs/heads/main`.
960        path: String,
961        /// What the operation was trying to do when I/O failed.
962        operation: GitDirPathOperation,
963        /// Underlying OS error.
964        source: std::io::Error,
965    },
966
967    /// A streamed object write or hash ended before the declared size was read.
968    #[error("object stream ended after {actual} of {expected} bytes")]
969    ObjectStreamTruncated {
970        /// Payload size the caller declared.
971        expected: u64,
972        /// Bytes the stream delivered before end of file.
973        actual: u64,
974    },
975
976    /// A worktree file hashed earlier in an operation had different content when stored.
977    #[error("{path} changed while it was being added")]
978    WorktreeFileChanged {
979        /// Index-relative path of the file.
980        path: String,
981    },
982
983    /// The index file is missing, truncated, or has a bad header.
984    #[error("index error: {0}")]
985    IndexError(String),
986
987    /// Stage-1/2/3 entries are present; a stage-0 tree cannot be built.
988    #[error("index contains unmerged entries")]
989    IndexUnmerged,
990
991    /// Another maintenance run holds the repository's `gc.pid` lock
992    /// ([`crate::maintenance::GcLock`]); nothing was changed.
993    #[error("{}", maintenance_running_message(holder))]
994    MaintenanceRunning {
995        /// Process recorded in `gc.pid`.
996        holder: crate::maintenance::GcLockHolder,
997    },
998
999    /// Explicit [`crate::porcelain::add::stage`] refused paths that still contain conflict markers.
1000    #[error("unmerged paths still have conflict markers")]
1001    UnmergedConflictMarkers {
1002        /// Repository-relative paths that were not staged because markers remain.
1003        paths: Vec<String>,
1004    },
1005
1006    /// A file path is a prefix of another index path at the same tree level.
1007    #[error(
1008        "index path prefix conflict between '{}' and '{}'",
1009        directory.display(),
1010        file.display()
1011    )]
1012    IndexPathPrefixConflict {
1013        /// Shorter path (treated as the ambiguous directory prefix).
1014        directory: std::path::PathBuf,
1015        /// Longer path that extends the prefix with an additional component.
1016        file: std::path::PathBuf,
1017    },
1018
1019    /// The `FSMN` index extension ends before its header, token, or EWAH payload.
1020    #[error("index fsmonitor extension payload is truncated")]
1021    IndexFsmonitorExtensionTruncated,
1022
1023    /// The `FSMN` extension header version is not supported.
1024    #[error("index fsmonitor extension has unsupported header version {0}")]
1025    IndexFsmonitorExtensionBadVersion(u32),
1026
1027    /// The EWAH bitmap embedded in `FSMN` could not be decoded.
1028    #[error("index fsmonitor extension EWAH bitmap is invalid")]
1029    IndexFsmonitorExtensionEwahInvalid,
1030
1031    /// The cache-tree extension references more entries than the index contains. Git emits this
1032    /// (verbatim, prefixed with `error: `) when a tree with duplicate path entries is read into the
1033    /// index (`t4058-diff-duplicates`).
1034    #[error("corrupted cache-tree has entries not present in index")]
1035    CacheTreeCorrupt,
1036
1037    /// A reference name or value is invalid.
1038    #[error("invalid ref: {0}")]
1039    InvalidRef(String),
1040
1041    /// A `packed-refs` line Git would reject when reading the ref database.
1042    #[error("unexpected line in {path}: {line}")]
1043    PackedRefsUnexpectedLine {
1044        /// Path to the `packed-refs` file being read.
1045        path: String,
1046        /// Offending line contents.
1047        line: String,
1048    },
1049
1050    /// A general path-related error (invalid UTF-8, out-of-bounds, etc.).
1051    #[error("path error: {0}")]
1052    PathError(String),
1053
1054    /// `--pathspec-from-file` parse failure.
1055    #[error(transparent)]
1056    PathspecFromFile(#[from] PathspecFromFileParseError),
1057
1058    /// A tree/index path component is forbidden (`.git`, HFS/NTFS aliases, etc.).
1059    #[error("invalid path '{0}'")]
1060    InvalidPath(String),
1061
1062    /// A repository-relative path failed [`crate::path_validate`] rules while populating the index or checkout.
1063    #[error("invalid path '{path}': {reason}")]
1064    InvalidPathComponent {
1065        /// Full repository-relative path that was rejected.
1066        path: String,
1067        /// Which component rule failed.
1068        reason: crate::path_validate::PathComponentError,
1069    },
1070
1071    /// A configuration file parsing or access error.
1072    #[error(transparent)]
1073    Config(#[from] ConfigError),
1074
1075    /// Branch create, delete, or upstream configuration failure.
1076    #[error(transparent)]
1077    Branch(#[from] crate::porcelain::branch_error::BranchError),
1078
1079    /// Reference lock failure while updating refs.
1080    #[error(transparent)]
1081    RefLock(#[from] RefLockError),
1082
1083    /// Reference store transaction or iteration failure.
1084    #[error(transparent)]
1085    RefStore(crate::refs::store::RefStoreError),
1086
1087    /// Reftable file, stack, or transaction failure.
1088    #[error(transparent)]
1089    Reftable(#[from] crate::reftable::ReftableError),
1090
1091    /// Filter or EOL conversion failure.
1092    #[error(transparent)]
1093    Filter(#[from] FilterError),
1094
1095    /// Patch application failure.
1096    #[error(transparent)]
1097    Apply(#[from] ApplyError),
1098
1099    /// Diff path selection failure.
1100    #[error(transparent)]
1101    DiffPath(#[from] DiffPathError),
1102
1103    /// Path resolves outside the repository.
1104    #[error(transparent)]
1105    PathOutsideRepo(#[from] PathOutsideRepoError),
1106
1107    /// A commit/tag signing or signature-verification error.
1108    #[error("{0}")]
1109    Signing(String),
1110
1111    /// HTTP authentication failed: the server required credentials (`401`) and
1112    /// either no credential provider was wired, the provider could not supply a
1113    /// usable username/password, the server demanded an unsupported auth scheme,
1114    /// or the supplied credentials were rejected.
1115    ///
1116    /// Distinct from [`Error::Message`] so embedders can detect an authentication
1117    /// failure (and e.g. fall back to an interactive/subprocess path) rather than
1118    /// string-matching, and so the failure surfaces typed instead of hanging.
1119    #[error("authentication failed for {url}: {reason}")]
1120    Auth {
1121        /// Repository URL with credentials redacted and smart-HTTP endpoint suffixes
1122        /// (`/info/refs?service=…`, `/git-upload-pack`, `/git-receive-pack`) removed.
1123        url: String,
1124        /// What went wrong.
1125        reason: AuthFailure,
1126    },
1127
1128    /// A push carried `--push-option` values but the remote `git-receive-pack`
1129    /// did not advertise the `push-options` capability, so the options cannot be
1130    /// transmitted.
1131    ///
1132    /// Distinct from [`Error::Message`] so embedders can detect this specific
1133    /// negotiation failure (and e.g. fall back to a subprocess push) rather than
1134    /// string-matching. The message matches Git's
1135    /// `fatal: the receiving end does not support push options`.
1136    #[error("the receiving end does not support push options")]
1137    PushOptionsUnsupported,
1138
1139    /// `git fetch --unshallow` on a repository that is not shallow.
1140    #[error("repository is not shallow")]
1141    FetchUnshallowNotShallow,
1142
1143    /// A deepen/shallow fetch was requested but the remote did not advertise the
1144    /// `shallow` capability.
1145    #[error("the remote does not support shallow fetches")]
1146    FetchShallowUnsupported,
1147
1148    /// A `--deepen` fetch was requested but the remote did not advertise
1149    /// `deepen-relative`.
1150    #[error("the remote does not support deepen-relative")]
1151    FetchDeepenRelativeUnsupported,
1152
1153    /// A bundle fetch or verify found prerequisite commits missing from history.
1154    #[error("repository lacks these prerequisite commits")]
1155    BundleMissingPrerequisites,
1156
1157    /// Streaming fetch/clone temp pack disappeared before index-pack (often real-time antivirus).
1158    #[error(
1159        "received pack file missing: {}. If this persists, check real-time antivirus or disk cleanup for quarantined pack temp files.",
1160        path.display()
1161    )]
1162    ReceivedPackTempMissing {
1163        /// Path to the temp pack that was expected on disk.
1164        path: std::path::PathBuf,
1165    },
1166
1167    /// A pathspec's `:(attr:...)` magic is malformed.
1168    #[error("invalid attr magic in pathspec '{spec}': {reason}")]
1169    InvalidAttrPathspec {
1170        /// The offending pathspec as given.
1171        spec: String,
1172        /// What is wrong with its attr clause.
1173        #[source]
1174        reason: AttrPathspecError,
1175    },
1176
1177    /// A pathspec did not match any file known to Git (index or HEAD tree).
1178    #[error("pathspec '{spec}' did not match any file(s) known to git")]
1179    PathspecNoMatch {
1180        /// User-supplied pathspec that matched nothing.
1181        spec: String,
1182    },
1183
1184    /// [`crate::porcelain::restore::restore_paths`] conflict options were combined with `--staged`.
1185    #[error("conflict restore options cannot be used with --staged")]
1186    RestoreConflictWithStaged,
1187
1188    /// [`crate::porcelain::restore::restore_paths`] conflict options were combined with `--source`.
1189    #[error("conflict restore options cannot be used with --source")]
1190    RestoreConflictWithSource,
1191
1192    /// A conflict side was missing from the index for the requested path.
1193    #[error("path '{path}' does not have their version")]
1194    RestoreConflictMissingTheirs {
1195        /// Repository-relative path.
1196        path: String,
1197    },
1198
1199    /// A conflict side was missing from the index for the requested path.
1200    #[error("path '{path}' does not have our version")]
1201    RestoreConflictMissingOurs {
1202        /// Repository-relative path.
1203        path: String,
1204    },
1205
1206    /// Three-way remerge needs stages 1, 2, and 3 in the index.
1207    #[error("path '{path}' is not unmerged")]
1208    RestoreConflictNotUnmerged {
1209        /// Repository-relative path.
1210        path: String,
1211    },
1212
1213    /// [`crate::porcelain::restore::restore_paths`] was asked to restore the worktree from the
1214    /// index for paths that still have conflict stages; pick a side or rebuild the markers instead.
1215    ///
1216    /// Nothing is written when this is returned.
1217    #[error("{}", restore_unmerged_message(paths))]
1218    RestorePathUnmerged {
1219        /// Matched repository-relative paths with conflict stages (sorted, deduplicated).
1220        paths: Vec<String>,
1221    },
1222
1223    /// A literal pathspec names a file under a symlink directory component.
1224    #[error("pathspec '{spec}' is beyond a symbolic link")]
1225    PathspecBeyondSymlink {
1226        /// User-supplied pathspec that crosses a symlink directory.
1227        spec: String,
1228    },
1229
1230    /// [`crate::porcelain::commit::CommitScope::Paths`] was used while a merge is in progress.
1231    #[error("cannot do a partial commit during a merge")]
1232    PartialCommitDuringMerge,
1233
1234    /// Explicit [`crate::porcelain::add::stage`] pathspecs name paths excluded by ignore rules.
1235    #[error("the following paths are ignored:\n{}", paths.join("\n"))]
1236    PathspecIgnored {
1237        /// Repository-relative paths (or user labels) that cannot be staged while ignored.
1238        paths: Vec<String>,
1239    },
1240
1241    /// [`crate::porcelain::paths::remove_paths`] refused paths whose removal would lose
1242    /// content, because [`crate::porcelain::paths::RemoveOptions::force`] was not set.
1243    #[error(
1244        "refusing to remove paths with unsaved content:\n{}",
1245        paths.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n")
1246    )]
1247    RemoveBlocked {
1248        /// Each refused path with the reason it was refused.
1249        paths: Vec<crate::porcelain::paths::BlockedRemoval>,
1250    },
1251
1252    /// A directory pathspec was used without `recursive` ([`crate::porcelain::paths::remove_paths`]).
1253    #[error("not removing '{0}' recursively without -r")]
1254    NotRemovingRecursively(String),
1255
1256    /// A porcelain path operation requires a checked-out work tree.
1257    #[error("this operation must be run in a work tree")]
1258    RequiresWorkTree,
1259
1260    /// A commit was requested with an empty message body after normalization.
1261    #[error("empty commit message")]
1262    EmptyCommitMessage,
1263
1264    /// [`crate::porcelain::paths::remove_paths`] was called with no pathspecs.
1265    #[error("you must specify path(s) to remove")]
1266    RemovePathsRequired,
1267
1268    /// [`crate::porcelain::paths::move_path`] / [`crate::porcelain::paths::move_paths`] source is not tracked.
1269    #[error("not under version control, source={src}")]
1270    MoveSourceNotTracked {
1271        /// Untracked or missing source path.
1272        src: String,
1273    },
1274
1275    /// Move refused because the destination already exists without `--force`.
1276    #[error("destination exists, source={src}, destination={destination}")]
1277    MoveDestinationExists {
1278        /// Source path in the move request.
1279        src: String,
1280        /// Existing destination path.
1281        destination: String,
1282    },
1283
1284    /// Move refused because the destination lies under the source directory.
1285    #[error("cannot move '{src}' to a subdirectory of itself, '{destination}'")]
1286    MoveIntoSubdirectory {
1287        /// Source directory path.
1288        src: String,
1289        /// Invalid destination under the source.
1290        destination: String,
1291    },
1292
1293    /// Move refused because source and destination are the same path.
1294    #[error("cannot move into itself, source={src}, destination={destination}")]
1295    MoveIntoSelf {
1296        /// Source path.
1297        src: String,
1298        /// Destination equal to the source.
1299        destination: String,
1300    },
1301
1302    /// Move refused because the source has no index entries.
1303    #[error("nothing to move, source={src}")]
1304    MoveNothingToMove {
1305        /// Source path with no index entries.
1306        src: String,
1307    },
1308
1309    /// Multi-source move would write more than one source to the same destination path.
1310    #[error("multiple sources for the same target, destination={destination}")]
1311    MoveDuplicateDestination {
1312        /// Destination path that would receive more than one source.
1313        destination: String,
1314    },
1315
1316    /// A worktree source path is missing or not a file, directory, or symlink.
1317    #[error("bad source, source={src}, destination={destination}")]
1318    MoveBadSource {
1319        /// Source path.
1320        src: String,
1321        /// Planned destination path.
1322        destination: String,
1323    },
1324
1325    /// Internal invariant: a planned rename references a missing index entry.
1326    #[error("internal error: missing index entry for '{path}'")]
1327    MoveMissingIndexEntry {
1328        /// Index path that should exist during a move.
1329        path: String,
1330    },
1331
1332    /// The remote could not be read or written: a missing repository, a failed connection, an
1333    /// HTTP error status, or no Git handshake.
1334    ///
1335    /// Use [`Error::is_remote_unreadable`] to test for it through wrapper errors.
1336    ///
1337    /// Not used for cancellation; see [`Error::is_cancelled`].
1338    #[error("{}", remote_unreadable_message(url.as_deref(), reason))]
1339    RemoteUnreadable {
1340        /// Remote URL or resolved local path, with credentials redacted. `None` when the
1341        /// failing layer only had a connection, not the URL it was opened from.
1342        url: Option<String>,
1343        /// What went wrong.
1344        reason: RemoteUnreadableReason,
1345    },
1346
1347    /// Explicit fetch refspec resolution failed (missing or ambiguous source).
1348    #[error("{0}")]
1349    FetchRefspec(#[from] crate::fetch_request::FetchRefspecError),
1350
1351    /// Fetch refused to overwrite a local branch with a non-fast-forward update.
1352    #[error("non-fast-forward update for {local_ref}")]
1353    FetchNonFastForward {
1354        /// Local ref that would have been overwritten.
1355        local_ref: String,
1356    },
1357
1358    /// Fetch refused to update the branch checked out in this or a linked worktree.
1359    #[error("cannot fetch into checked-out branch '{branch}'")]
1360    FetchIntoCheckedOutBranch {
1361        /// Short branch name under `refs/heads/`.
1362        branch: String,
1363    },
1364
1365    /// Checkout or switch would overwrite local or untracked worktree content.
1366    #[error("{message}")]
1367    WouldOverwrite {
1368        /// Whether tracked local edits or untracked paths block the operation.
1369        reason: OverwriteReason,
1370        /// Repository-relative paths that would be overwritten (sorted, deduplicated).
1371        paths: Vec<String>,
1372        /// Human-readable summary (depends on [`OverwriteReason`]).
1373        message: String,
1374    },
1375
1376    /// Staged or unstaged changes block an operation that needs a clean worktree.
1377    #[error("you have uncommitted changes — commit them before {operation}")]
1378    DirtyWorktree {
1379        /// The operation that was refused.
1380        operation: WorktreeOperation,
1381        /// Repository-relative paths with staged or unstaged changes (sorted, deduplicated;
1382        /// empty when the caller only knew that something changed).
1383        paths: Vec<String>,
1384    },
1385
1386    /// Author or committer on [`crate::porcelain::commit::CommitRequest`] is invalid.
1387    #[error(transparent)]
1388    InvalidIdentity(#[from] crate::ident::CommitIdentityError),
1389
1390    /// A long-running library operation stopped at a cancellation checkpoint.
1391    ///
1392    /// Use [`Error::is_cancelled`] to test for it through wrapper errors
1393    /// ([`crate::remote::RemoteError::is_cancelled`], [`crate::remote::clone::CloneError::is_cancelled`], …).
1394    #[error("operation cancelled")]
1395    Cancelled,
1396
1397    /// No usable author or committer identity is configured
1398    /// ([`crate::repo::Repository::identity`]).
1399    #[error(transparent)]
1400    Identity(#[from] crate::config::ident_resolve::IdentityError),
1401
1402    /// User-facing message that should be printed verbatim (no extra prefix).
1403    ///
1404    /// Used for revision errors that must match Git's `fatal:` lines exactly.
1405    #[error("{0}")]
1406    Message(String),
1407
1408    /// A merge was requested with fast-forward only, but the histories diverged.
1409    #[error("not possible to fast-forward; refusing to merge")]
1410    NotFastForward,
1411
1412    /// The index tree matches the parent commit tree and [`allow_empty`](crate::porcelain::commit::CommitRequest::allow_empty) is false.
1413    #[error("nothing to commit")]
1414    NothingToCommit,
1415
1416    /// No staged changes are available for a [`CommitScope::Staged`](crate::porcelain::commit::CommitScope::Staged)
1417    /// commit, but the work tree has unstaged or untracked paths.
1418    #[error("nothing staged to commit")]
1419    NothingStagedToCommit {
1420        /// Tracked paths that differ between the index and the work tree.
1421        unstaged: usize,
1422        /// Untracked paths under the work tree.
1423        untracked: usize,
1424    },
1425
1426    /// [`create_commit`](crate::porcelain::commit::create_commit) requires a branch checkout.
1427    #[error("HEAD is detached")]
1428    DetachedHead,
1429
1430    /// `HEAD` is on a branch with no commits yet, but the operation needs a commit
1431    /// ([`Repository::head_commit`](crate::repo::Repository::head_commit),
1432    /// [`replay_commit`](crate::porcelain::replay::replay_commit)).
1433    #[error("no commits yet on this branch")]
1434    UnbornHead,
1435
1436    /// `HEAD` could not be resolved to a branch, detached commit, or unborn branch.
1437    #[error("HEAD is in an unknown state")]
1438    InvalidHead,
1439
1440    /// [`replay_commit`](crate::porcelain::replay::replay_commit) does not replay merge commits
1441    /// until callers can select a mainline parent explicitly.
1442    #[error("merge commit {oid}")]
1443    MergeCommit {
1444        /// Object id of the merge commit that cannot be replayed yet.
1445        oid: crate::objects::ObjectId,
1446    },
1447
1448    /// Cherry-pick source is already checked out as `HEAD`.
1449    #[error("commit already at HEAD")]
1450    ReplaySourceAtHead,
1451
1452    /// Sequencer todo lists a command grit does not implement yet.
1453    #[error("unknown sequencer command '{command}'")]
1454    SequencerUnknownCommand {
1455        /// Verb read from `.git/sequencer/todo`.
1456        command: String,
1457    },
1458
1459    /// [`abort_replay_sequence`](crate::porcelain::replay_sequence::abort_replay_sequence) refuses when `HEAD` moved since the replay started.
1460    #[error("cannot abort replay: HEAD moved since replay started")]
1461    ReplayAbortHeadMoved,
1462
1463    /// [`continue_replay_sequence`](crate::porcelain::replay_sequence::continue_replay_sequence) refuses while unmerged index entries remain.
1464    #[error("cannot continue replay: unmerged index entries remain")]
1465    ReplayContinueUnmerged,
1466
1467    /// No cherry-pick or revert sequence is in progress.
1468    #[error("no cherry-pick or revert in progress")]
1469    NoReplayInProgress,
1470
1471    /// The command would move `HEAD` while a paused operation still depends on it
1472    /// (see [`operation_blocking_switch`](crate::state::operation_blocking_switch)).
1473    #[error("{} in progress", .0.description())]
1474    OperationInProgress(crate::state::InProgressOperation),
1475
1476    /// A local branch is checked out as `HEAD` in a linked or main worktree.
1477    #[error("branch '{branch}' is already checked out in '{worktree_path}'")]
1478    BranchCheckedOutInWorktree {
1479        /// Short branch name (e.g. `main`).
1480        branch: String,
1481        /// Path to the worktree that has the branch checked out.
1482        worktree_path: String,
1483    },
1484
1485    /// `refs/heads/<branch>` does not exist.
1486    #[error("no branch named '{branch}'")]
1487    BranchNotFound {
1488        /// Short branch name (e.g. `topic`).
1489        branch: String,
1490    },
1491
1492    /// [`list_branches`](crate::branch_tracking::list_branches) with [`BranchDetail::Names`](crate::branch_tracking::BranchDetail::Names) cannot use date sort.
1493    #[error("branch list with Names detail cannot use date sort (date sort reads commit objects)")]
1494    BranchListNamesDateSort,
1495
1496    /// [`delete_branch`](crate::porcelain::branch::delete_branch) refuses to delete the branch
1497    /// `HEAD` points at.
1498    #[error("cannot delete '{branch}' — it is the current branch")]
1499    CannotDeleteCurrentBranch {
1500        /// Short branch name (e.g. `main`).
1501        branch: String,
1502    },
1503
1504    /// [`delete_branch`](crate::porcelain::branch::delete_branch) without `force` found commits
1505    /// on the branch that are not reachable from its upstream (or from `HEAD` when it has none).
1506    #[error("the branch '{branch}' is not fully merged")]
1507    BranchNotMerged {
1508        /// Short branch name (e.g. `topic`).
1509        branch: String,
1510    },
1511
1512    /// `refs/tags/<tag>` does not exist.
1513    #[error("no tag named '{tag}'")]
1514    TagNotFound {
1515        /// Short tag name (e.g. `v1.0`).
1516        tag: String,
1517    },
1518
1519    /// Tag create/delete validation failure ([`crate::porcelain::tag::TagError`]).
1520    #[error(transparent)]
1521    Tag(#[from] crate::porcelain::tag::TagError),
1522
1523    /// Linked worktree add/remove/move/lock/repair failure ([`crate::worktree::WorktreeError`]).
1524    #[error(transparent)]
1525    Worktree(#[from] crate::worktree::WorktreeError),
1526
1527    /// Stash commit object has fewer parents or an invalid shape for apply/show.
1528    #[error("corrupt stash commit: {0}")]
1529    CorruptStash(&'static str),
1530
1531    /// `stash@{{n}}` is out of range for the current stash reflog.
1532    #[error("stash entry stash@{{{n}}} not found")]
1533    StashNotFound {
1534        /// Stash reflog index (`stash@{{n}}`).
1535        n: usize,
1536    },
1537
1538    /// Ref does not exist and has no reflog entries.
1539    #[error("unknown ref '{refname}'")]
1540    ReflogUnknownRef {
1541        /// User-supplied or DWIM ref name.
1542        refname: String,
1543    },
1544
1545    /// A reflog selector is missing its reference or `@{n}` delimiters.
1546    #[error("expected ref@{{n}} form, got '{spec}'")]
1547    ReflogInvalidSelector {
1548        /// Selector after trimming surrounding whitespace.
1549        spec: String,
1550    },
1551
1552    /// A reflog selector index is not a nonnegative integer that fits in `usize`.
1553    #[error("invalid reflog index in '{spec}'")]
1554    ReflogInvalidIndex {
1555        /// Selector after trimming surrounding whitespace.
1556        spec: String,
1557        /// Integer parsing failure, including overflow.
1558        #[source]
1559        source: std::num::ParseIntError,
1560    },
1561
1562    /// `ref@{{n}}` is out of range for the current reflog.
1563    #[error("reflog entry {refname}@{{{index}}} not found")]
1564    ReflogIndexOutOfRange {
1565        /// Ref whose reflog was read.
1566        refname: String,
1567        /// Requested newest-first index.
1568        index: usize,
1569    },
1570
1571    /// Pushing or creating a stash ([`crate::repo_handles::RepositoryStash::push`]) requires at least one commit.
1572    #[error("cannot stash without a commit")]
1573    StashNoInitialCommit,
1574
1575    /// Stash create/apply/pop requires a working tree (not a bare repository).
1576    #[error("cannot stash in a bare repository")]
1577    StashBareRepository,
1578
1579    /// [`crate::repo_handles::RepositoryStash::apply`] with `restore_index` could
1580    /// not merge the current index with the stashed index parent.
1581    #[error("conflicts in index while applying stash with --index")]
1582    StashIndexConflict,
1583
1584    /// Incompatible [`StashPushOptions`](crate::porcelain::stash::StashPushOptions) flags.
1585    #[error("{0}")]
1586    StashOptionConflict(String),
1587
1588    /// Partial [`crate::repo_handles::RepositoryStash::push`] hunk selection failed.
1589    #[error(transparent)]
1590    HunkSelection(#[from] crate::porcelain::hunk_select::HunkSelectError),
1591
1592    /// HEAD does not resolve while applying a stash entry.
1593    #[error("missing HEAD while applying stash")]
1594    StashMissingHead,
1595
1596    /// Applying a stash would overwrite locally modified worktree content.
1597    #[error(
1598        "your local changes to the following files would be overwritten by stash apply: {paths}"
1599    )]
1600    StashWouldOverwriteLocalChanges {
1601        /// Space-separated list of paths that would be overwritten.
1602        paths: String,
1603    },
1604
1605    /// The index could not be written after a successful stash apply.
1606    #[error("writing index after stash apply: {0}")]
1607    StashIndexWrite(String),
1608
1609    /// A symlink blob in a stash commit is not valid UTF-8.
1610    #[error("symlink target is not UTF-8")]
1611    StashSymlinkNotUtf8,
1612
1613    /// Applying a stash with an untracked-files parent would replace an existing worktree path.
1614    #[error("stash untracked restore blocked: '{path}' already exists on disk")]
1615    StashCouldNotRestoreUntracked {
1616        /// Worktree path that already exists and blocks untracked restore.
1617        path: String,
1618    },
1619
1620    /// [`CommitRequest::amend`](crate::porcelain::commit::CommitRequest::amend) was set on an unborn branch.
1621    #[error("cannot amend: no commits on this branch yet")]
1622    AmendUnborn,
1623
1624    /// [`RepositoryNotes::add`](crate::repo_handles::RepositoryNotes::add) was called without
1625    /// `force` when a note already exists on the object.
1626    #[error("note for object {object} already exists")]
1627    NoteAlreadyExists {
1628        /// Annotated object that already has a note.
1629        object: crate::objects::ObjectId,
1630    },
1631
1632    /// A configured or environment notes ref is not under `refs/notes/`.
1633    #[error("notes ref '{0}' must start with 'refs/notes/'")]
1634    InvalidNotesRef(String),
1635
1636    /// A notes ref resolves to an object that cannot hold notes (notes reads accept a commit or
1637    /// a tree; notes merges require a commit).
1638    #[error("notes ref '{notes_ref}' points to a {found}, not a notes commit")]
1639    NotesRefNotCommit {
1640        /// Notes ref (or revision) that was read.
1641        notes_ref: String,
1642        /// Kind of object the ref resolved to.
1643        found: crate::objects::ObjectKind,
1644    },
1645
1646    /// An object reached while reading a notes tree has the wrong kind (for example a note
1647    /// entry that is not a blob).
1648    #[error("notes object {oid} is a {found}, expected a {expected}")]
1649    NotesObjectKind {
1650        /// Object that was read.
1651        oid: crate::objects::ObjectId,
1652        /// Kind the notes layout requires at this position.
1653        expected: crate::objects::ObjectKind,
1654        /// Kind actually stored.
1655        found: crate::objects::ObjectKind,
1656    },
1657
1658    /// [`notes_merge_inner`](crate::notes::notes_merge_inner) was asked to merge two notes refs
1659    /// that both have no notes commit.
1660    #[error("cannot merge notes: neither '{local}' nor '{remote}' has any notes")]
1661    NotesMergeBothEmpty {
1662        /// Local notes ref (merge destination).
1663        local: String,
1664        /// Remote notes ref (merge source).
1665        remote: String,
1666    },
1667
1668    /// A manual notes merge was started while an earlier one still has conflict files in the
1669    /// notes merge worktree.
1670    #[error("unfinished notes merge: conflict files remain in {}", path.display())]
1671    NotesMergeInProgress {
1672        /// The notes merge worktree directory.
1673        path: std::path::PathBuf,
1674    },
1675
1676    /// Multi-pack-index load or write failure.
1677    #[error(transparent)]
1678    Midx(#[from] MidxError),
1679
1680    /// Revision parsing failed ([`crate::rev_parse_error::RevParseError`]).
1681    #[error(transparent)]
1682    RevParse(#[from] crate::rev_parse_error::RevParseError),
1683
1684    /// Revision walking / `rev-list` option parsing failed ([`crate::rev_list_error::RevListError`]).
1685    #[error(transparent)]
1686    RevList(#[from] crate::rev_list_error::RevListError),
1687
1688    /// A Git hook subprocess failed or could not be started.
1689    #[error(transparent)]
1690    Hook(#[from] crate::hooks::HookError),
1691
1692    /// History rewrite planning failed ([`crate::history::HistoryError`]).
1693    #[error(transparent)]
1694    History(#[from] crate::history::HistoryError),
1695
1696    /// The operation needs the files-backed `objects/` directory, but the repository's
1697    /// [`Odb`](crate::odb::Odb) uses a custom [`OdbBuilder::primary`](crate::odb::OdbBuilder::primary)
1698    /// store. Nothing was written; use a files-backed repository for this operation.
1699    #[error("{operation} requires the files-backed object store; it is not supported with a custom primary")]
1700    UnsupportedObjectStore {
1701        /// Operation that was refused (for example `"fetch"`, `"push"`, `"gc"`).
1702        operation: &'static str,
1703    },
1704
1705    /// [`RepositoryBuilder::open`](crate::repo::RepositoryBuilder::open) was given an
1706    /// [`OdbBuilder`](crate::odb::OdbBuilder) rooted somewhere other than the repository's
1707    /// `objects/` directory.
1708    #[error(
1709        "object database {} is not the repository's objects directory {}",
1710        builder.display(),
1711        repository.display()
1712    )]
1713    OdbObjectsDirMismatch {
1714        /// The builder's objects directory, canonicalized when it exists.
1715        builder: std::path::PathBuf,
1716        /// The repository's canonical objects directory.
1717        repository: std::path::PathBuf,
1718    },
1719
1720    /// Parsing a [`crate::tree_sha256::TreeSha256`] from hex failed (expected 64 hex digits).
1721    #[error("invalid tree-sha256 hex")]
1722    TreeSha256InvalidHex,
1723
1724    /// A revision passed to [`crate::tree_sha256::tree_sha256`] did not peel to a commit or tree.
1725    #[error("tree-sha256: revision resolves to {found:?}, not a commit or tree")]
1726    TreeSha256InvalidRevision {
1727        /// Object kind after peeling annotated tags.
1728        found: crate::objects::ObjectKind,
1729    },
1730
1731    /// One or more submodules required for [`crate::tree_sha256::tree_sha256`] are missing.
1732    #[error("tree-sha256: missing submodules")]
1733    TreeSha256MissingSubmodules(Vec<crate::tree_sha256::MissingSubmodule>),
1734
1735    /// `--tree-sha256` was requested with signing explicitly disabled.
1736    #[error("tree-sha256 requires a signed tag")]
1737    TreeSha256RequiresSigning,
1738
1739    /// [`crate::tree_sha256::verify_tag`] was given an object that is not an annotated tag.
1740    #[error("verify-tag: object {oid} is a {found:?}, not an annotated tag")]
1741    TagVerifyNotTag {
1742        /// Object id passed to verification.
1743        oid: crate::objects::ObjectId,
1744        /// Kind stored in the ODB.
1745        found: crate::objects::ObjectKind,
1746    },
1747
1748    /// Annotated tag header block has no `object` line.
1749    #[error("verify-tag: tag has no object header")]
1750    TagVerifyMissingObjectHeader,
1751
1752    /// Annotated tag carries more than one `tree-sha256` header line.
1753    #[error("verify-tag: tag has more than one tree-sha256 header")]
1754    TagVerifyDuplicateTreeSha256Header,
1755
1756    /// Annotated tag tree-sha256 header parsing failed.
1757    #[error(transparent)]
1758    TreeSha256Claim(#[from] crate::tree_sha256::TreeSha256ClaimError),
1759
1760    /// `grit blame --ignore-rev` did not resolve to a commit.
1761    #[error("cannot find revision {rev} to ignore")]
1762    BlameIgnoreRevUnknown {
1763        /// Revision expression from `--ignore-rev`.
1764        rev: String,
1765    },
1766
1767    /// `--ignore-revs-file` could not be read.
1768    #[error("could not open object name list: {path}")]
1769    BlameIgnoreRevsFileOpen {
1770        /// Path argument (before expansion).
1771        path: String,
1772        /// Underlying open error.
1773        #[source]
1774        source: std::io::Error,
1775    },
1776
1777    /// A line in an ignore-revs file is not a full-length object id.
1778    #[error("invalid object name: {rev}")]
1779    BlameIgnoreRevsFileInvalidRev {
1780        /// Path argument for the file.
1781        path: String,
1782        /// Offending line text after comment/trim handling.
1783        rev: String,
1784    },
1785}
1786
1787impl Error {
1788    /// Build [`Error::DirtyWorktree`], sorting and deduplicating `paths`.
1789    #[must_use]
1790    pub fn dirty_worktree(operation: WorktreeOperation, mut paths: Vec<String>) -> Self {
1791        paths.sort();
1792        paths.dedup();
1793        Self::DirtyWorktree { operation, paths }
1794    }
1795
1796    /// Build [`Error::WouldOverwrite`] for switch and similar tree transitions.
1797    #[must_use]
1798    pub fn would_overwrite(reason: OverwriteReason, mut paths: Vec<String>) -> Self {
1799        paths.sort();
1800        paths.dedup();
1801        let message = match reason {
1802            OverwriteReason::LocalChanges => {
1803                "you have uncommitted changes — commit them before switching".into()
1804            }
1805            OverwriteReason::Untracked => {
1806                let first = paths.first().map(String::as_str).unwrap_or("<path>");
1807                format!("untracked file '{first}' would be overwritten — move or remove it first")
1808            }
1809        };
1810        Self::WouldOverwrite {
1811            reason,
1812            paths,
1813            message,
1814        }
1815    }
1816
1817    /// Build [`Error::RemoteUnreadable`] for `url`.
1818    #[must_use]
1819    pub fn remote_unreadable(url: impl Into<String>, reason: RemoteUnreadableReason) -> Self {
1820        Self::RemoteUnreadable {
1821            url: Some(url.into()),
1822            reason,
1823        }
1824    }
1825
1826    /// Whether this is [`Error::RemoteUnreadable`]: the remote could not be read or written
1827    /// at all (as opposed to a rejected update or an empty remote).
1828    #[must_use]
1829    pub fn is_remote_unreadable(&self) -> bool {
1830        matches!(self, Self::RemoteUnreadable { .. })
1831    }
1832
1833    /// Whether this is [`Error::Cancelled`] or a nested history failure that was cancelled.
1834    #[must_use]
1835    pub fn is_cancelled(&self) -> bool {
1836        match self {
1837            Self::Cancelled => true,
1838            Self::History(e) => e.is_cancelled(),
1839            _ => false,
1840        }
1841    }
1842
1843    /// Build [`Error::InvalidPathComponent`].
1844    #[must_use]
1845    pub fn invalid_path_component(
1846        path: impl Into<String>,
1847        reason: crate::path_validate::PathComponentError,
1848    ) -> Self {
1849        Self::InvalidPathComponent {
1850            path: path.into(),
1851            reason,
1852        }
1853    }
1854
1855    /// When set, the CLI should prefix stderr with `fatal: ` for Git-compatible commands.
1856    #[must_use]
1857    pub fn wants_fatal_prefix(&self) -> bool {
1858        matches!(
1859            self,
1860            Error::Config(_)
1861                | Error::RefLock(_)
1862                | Error::Filter(_)
1863                | Error::PathOutsideRepo(_)
1864                | Error::DiffPath(_)
1865        )
1866    }
1867
1868    /// Format for Git-style stderr (adds `fatal:` / `error:` when appropriate).
1869    #[must_use]
1870    pub fn git_stderr_message(&self) -> String {
1871        match self {
1872            Error::Apply(ApplyError::CorruptPatch { .. }) => {
1873                crate::diagnostics::error_line(&self.to_string())
1874            }
1875            Error::Message(s) => s.clone(),
1876            Error::WouldOverwrite { message, .. } => message.clone(),
1877            e if e.wants_fatal_prefix() => crate::diagnostics::fatal_line(&e.to_string()),
1878            other => other.to_string(),
1879        }
1880    }
1881}
1882
1883impl From<crate::refs::store::RefStoreError> for Error {
1884    fn from(err: crate::refs::store::RefStoreError) -> Self {
1885        match err {
1886            crate::refs::store::RefStoreError::GitDirPathIo {
1887                path,
1888                operation,
1889                os_error,
1890            } => Error::GitDirPathIo {
1891                path,
1892                operation,
1893                source: crate::refs::store::RefStoreError::git_dir_io_source(os_error),
1894            },
1895            other => Error::RefStore(other),
1896        }
1897    }
1898}
1899
1900impl From<crate::lockfile::LockError> for Error {
1901    /// [`LockError::Held`](crate::lockfile::LockError::Held) becomes [`Error::LockHeld`] with the
1902    /// recorded [`LockHolder`](crate::lockfile::LockHolder), and I/O failures become [`Error::Io`].
1903    #[cold]
1904    fn from(err: crate::lockfile::LockError) -> Self {
1905        use crate::lockfile::{LockError, LockHolder};
1906        match err {
1907            LockError::Held { path } => Self::LockHeld {
1908                holder: LockHolder::read(&path),
1909                path,
1910            },
1911            LockError::Io { source, .. } => Self::Io(source),
1912        }
1913    }
1914}
1915
1916/// Convenience alias for `Result<T, Error>`.
1917pub type Result<T> = std::result::Result<T, Error>;