//! Typed errors for diff state operations.//!//! [`DiffStateError`] is the single error type used across every diff-state operation://! - per-file invalidation//! - full diff load//! - metadata load//! - remote-daemon snapshot/error responses//! The same pool of git / filesystem failures can surface in any of these operations,//! so a single classifier keeps the code DRY and ensures every site reports failures the same way.//!//! A [`DiffStateError`] pairs a sanitized [`DiffStateErrorKind`] with the raw//! underlying error, but only the sanitized half is ever emitted off-device://! - [`std::fmt::Display`] renders only the sanitized `kind`, so passing this//! through [`warp_errors::report_error!`] or code-review telemetry keeps logs,//! Sentry, and analytics free of repo paths, refs, command output, or//! secrets. The raw cause is never exposed via `Display` or `source`.//!//! For [`DiffStateErrorKind::Unknown`] the raw cause is additionally consulted//! via [`AnyhowErrorExt::is_actionable`] so registered non-actionable causes//! (transient I/O, network, etc.) auto-demote it to a warning instead of a//! Sentry capture.//!//! Use the operation tag [`super::DiffOperation`] alongside this error in telemetry to distinguish where a given failure originated. use warp_core::sync_queue::IsTransientError;use warp_errors::{AnyhowErrorExt, ErrorExt}; /// Sanitized classification of a [`DiffStateError`]. Every variant has a/// fixed, PII-free [`std::fmt::Display`] string that is safe to send to logs/// and Sentry.#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]pub(crate) enum DiffStateErrorKind { // ── Git environment / repository state ────────────────────────────── #[error("git rejected repository ownership")] GitRejectedRepositoryOwnership, #[error("git is unavailable")] GitUnavailable, #[error("git lfs is unavailable")] GitLfsUnavailable, #[error("xcode license is not accepted")] XcodeLicenseNotAccepted, #[error("invalid empty pathspec")] InvalidEmptyPathspec, #[error("path is outside repository")] PathOutsideRepository, #[error("path is not a git repository")] NotGitRepository, #[error("repository is not a work tree")] NotWorkTree, #[error("repository resource is not accessible")] RepositoryPathNotAccessible, #[error("path is not valid UTF-8")] NonUtf8Path, #[error("git revision is unavailable")] GitRevisionUnavailable, #[error("git head tree is invalid")] GitHeadTreeInvalid, #[error("git status output is invalid")] InvalidGitStatusOutput, #[error("repository path is invalid")] RepositoryPathInvalid, // ── Remote daemon application-level outcomes ──────────────────────── /// The remote daemon reported `DiffState::Loaded` but no `GitDiffData` /// accompanied it. Only constructed by `RemoteDiffStateModel`. #[error("server returned empty diff data")] EmptyDiffData, // ── Unclassified ──────────────────────────────────────────────────── /// Unrecognized error. Add a dedicated variant once a new pattern is /// identified from the raw text recorded in telemetry. #[error("unknown diff state error")] Unknown,} impl DiffStateErrorKind { fn classify(message: &str) -> Option<Self> { if message.contains("detected dubious ownership in repository") { Some(Self::GitRejectedRepositoryOwnership) } else if message.contains("No such file or directory") || message.contains("program not found") || message.contains("No developer tools were found") { Some(Self::GitUnavailable) } else if message.contains("git-lfs: command not found") { Some(Self::GitLfsUnavailable) } else if message.contains("Xcode license agreements") { Some(Self::XcodeLicenseNotAccepted) } else if message.contains("empty string is not a valid pathspec") { Some(Self::InvalidEmptyPathspec) } else if message.contains("outside repository") { Some(Self::PathOutsideRepository) } else if message.contains("not a git repository") { Some(Self::NotGitRepository) } else if message.contains("this operation must be run in a work tree") { Some(Self::NotWorkTree) } else if message.contains("Operation not permitted") || message.contains("Permission denied") { Some(Self::RepositoryPathNotAccessible) } else if message.contains("non-UTF-8 path") { Some(Self::NonUtf8Path) } else if message.contains("bad revision") || message.contains("unknown revision") { Some(Self::GitRevisionUnavailable) } else if message.contains("bad tree object HEAD") { Some(Self::GitHeadTreeInvalid) } else if message.contains("os error 267") { Some(Self::RepositoryPathInvalid) } else if message.contains("Invalid status code") { Some(Self::InvalidGitStatusOutput) } else { None } }} /// A diff-state failure: a sanitized [`DiffStateErrorKind`] paired with the/// raw underlying error. See the module docs for how the two halves are/// routed to telemetry vs. logs / Sentry.#[derive(Debug, thiserror::Error)]#[error("{kind}")]pub(crate) struct DiffStateError { kind: DiffStateErrorKind, /// Raw underlying error. Consulted only for [`DiffStateErrorKind::Unknown`] /// actionability and never exposed via `Display`, `source`, or telemetry, /// so logs, Sentry, and analytics only ever see the sanitized `kind`. cause: anyhow::Error,} impl DiffStateError { /// Build a `DiffStateError` from a plain error message string. Used when /// the source error has already been flattened to a `String` (e.g. by /// `DiffsWithBaseContent::changes`, or by the remote daemon over the /// wire). pub(crate) fn from_message(message: &str) -> Self { Self { kind: DiffStateErrorKind::classify(message).unwrap_or(DiffStateErrorKind::Unknown), cause: anyhow::anyhow!("{message}"), } } /// Build the [`DiffStateErrorKind::EmptyDiffData`] error, reported when the /// remote daemon claims `DiffState::Loaded` but sends no diff data. pub(crate) fn empty_diff_data() -> Self { let kind = DiffStateErrorKind::EmptyDiffData; let cause = anyhow::anyhow!("{kind}"); Self { kind, cause } } /// Logs the raw underlying error locally, then reports the sanitized /// [`DiffStateError`] through the normal reporting path. pub(crate) fn report_and_log(&self) { let cause = &self.cause; log::warn!("Diff state error: {cause:#}"); warp_errors::report_error!(self); }} impl From<anyhow::Error> for DiffStateError { fn from(cause: anyhow::Error) -> Self { let kind = DiffStateErrorKind::classify(&format!("{cause:#}")) .unwrap_or(DiffStateErrorKind::Unknown); Self { kind, cause } }} impl ErrorExt for DiffStateError { fn is_actionable(&self) -> bool { match self.kind { // Caller / engineering bugs — surface to Sentry at error level. DiffStateErrorKind::InvalidEmptyPathspec | DiffStateErrorKind::InvalidGitStatusOutput | DiffStateErrorKind::EmptyDiffData => true, // Unknown errors defer to the anyhow chain so registered // transient/non-actionable causes (network, transient I/O, etc.) // log at warn level instead of paging us via Sentry. DiffStateErrorKind::Unknown => self.cause.is_actionable(), // User environment failures — not our bug; log as warning. DiffStateErrorKind::GitRejectedRepositoryOwnership | DiffStateErrorKind::GitUnavailable | DiffStateErrorKind::GitLfsUnavailable | DiffStateErrorKind::XcodeLicenseNotAccepted | DiffStateErrorKind::PathOutsideRepository | DiffStateErrorKind::NotGitRepository | DiffStateErrorKind::NotWorkTree | DiffStateErrorKind::RepositoryPathNotAccessible | DiffStateErrorKind::NonUtf8Path | DiffStateErrorKind::GitRevisionUnavailable | DiffStateErrorKind::GitHeadTreeInvalid | DiffStateErrorKind::RepositoryPathInvalid => false, } }}warp_errors::register_error!(DiffStateError); impl IsTransientError for DiffStateError { fn is_transient(&self) -> bool { match self.kind { // Repo / filesystem state can briefly churn while the queue is // processing invalidations, so these are worth the sync queue's // short retry budget. DiffStateErrorKind::RepositoryPathNotAccessible | DiffStateErrorKind::GitRevisionUnavailable | DiffStateErrorKind::GitHeadTreeInvalid | DiffStateErrorKind::EmptyDiffData | DiffStateErrorKind::Unknown => true, // Caller bugs, invalid inputs, missing tools, and user-actionable // environment setup issues won't resolve by retrying the same // operation a few seconds later. DiffStateErrorKind::GitRejectedRepositoryOwnership | DiffStateErrorKind::GitUnavailable | DiffStateErrorKind::GitLfsUnavailable | DiffStateErrorKind::XcodeLicenseNotAccepted | DiffStateErrorKind::InvalidEmptyPathspec | DiffStateErrorKind::PathOutsideRepository | DiffStateErrorKind::NotGitRepository | DiffStateErrorKind::NotWorkTree | DiffStateErrorKind::NonUtf8Path | DiffStateErrorKind::RepositoryPathInvalid | DiffStateErrorKind::InvalidGitStatusOutput => false, } }}
//! Typed errors for diff state operations.
//!
//! [`DiffStateError`] is the single error type used across every diff-state operation:
//! - per-file invalidation
//! - full diff load
//! - metadata load
//! - remote-daemon snapshot/error responses
//! The same pool of git / filesystem failures can surface in any of these operations,
//! so a single classifier keeps the code DRY and ensures every site reports failures the same way.
//!
//! A [`DiffStateError`] pairs a sanitized [`DiffStateErrorKind`] with the raw
//! underlying error, but only the sanitized half is ever emitted off-device:
//! - [`std::fmt::Display`] renders only the sanitized `kind`, so passing this
//! through [`warp_errors::report_error!`] or code-review telemetry keeps logs,
//! Sentry, and analytics free of repo paths, refs, command output, or
//! secrets. The raw cause is never exposed via `Display` or `source`.
//!
//! For [`DiffStateErrorKind::Unknown`] the raw cause is additionally consulted
//! via [`AnyhowErrorExt::is_actionable`] so registered non-actionable causes
//! (transient I/O, network, etc.) auto-demote it to a warning instead of a
//! Sentry capture.
//!
//! Use the operation tag [`super::DiffOperation`] alongside this error in telemetry to distinguish where a given failure originated.
use warp_core::sync_queue::IsTransientError;
use warp_errors::{AnyhowErrorExt, ErrorExt};
/// Sanitized classification of a [`DiffStateError`]. Every variant has a
/// fixed, PII-free [`std::fmt::Display`] string that is safe to send to logs
/// and Sentry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub(crate) enum DiffStateErrorKind {
// ── Git environment / repository state ──────────────────────────────
#[error("git rejected repository ownership")]
GitRejectedRepositoryOwnership,
#[error("git is unavailable")]
GitUnavailable,
#[error("git lfs is unavailable")]
GitLfsUnavailable,
#[error("xcode license is not accepted")]
XcodeLicenseNotAccepted,
#[error("invalid empty pathspec")]
InvalidEmptyPathspec,
#[error("path is outside repository")]
PathOutsideRepository,
#[error("path is not a git repository")]
NotGitRepository,
#[error("repository is not a work tree")]
NotWorkTree,
#[error("repository resource is not accessible")]
RepositoryPathNotAccessible,
#[error("path is not valid UTF-8")]
NonUtf8Path,
#[error("git revision is unavailable")]
GitRevisionUnavailable,
#[error("git head tree is invalid")]
GitHeadTreeInvalid,
#[error("git status output is invalid")]
InvalidGitStatusOutput,
#[error("repository path is invalid")]
RepositoryPathInvalid,
// ── Remote daemon application-level outcomes ────────────────────────
/// The remote daemon reported `DiffState::Loaded` but no `GitDiffData`
/// accompanied it. Only constructed by `RemoteDiffStateModel`.
#[error("server returned empty diff data")]
EmptyDiffData,
// ── Unclassified ────────────────────────────────────────────────────
/// Unrecognized error. Add a dedicated variant once a new pattern is
/// identified from the raw text recorded in telemetry.
#[error("unknown diff state error")]
Unknown,
}
impl DiffStateErrorKind {
fn classify(message: &str) -> Option<Self> {
if message.contains("detected dubious ownership in repository") {
Some(Self::GitRejectedRepositoryOwnership)
} else if message.contains("No such file or directory")
|| message.contains("program not found")
|| message.contains("No developer tools were found")
{
Some(Self::GitUnavailable)
} else if message.contains("git-lfs: command not found") {
Some(Self::GitLfsUnavailable)
} else if message.contains("Xcode license agreements") {
Some(Self::XcodeLicenseNotAccepted)
} else if message.contains("empty string is not a valid pathspec") {
Some(Self::InvalidEmptyPathspec)
} else if message.contains("outside repository") {
Some(Self::PathOutsideRepository)
} else if message.contains("not a git repository") {
Some(Self::NotGitRepository)
} else if message.contains("this operation must be run in a work tree") {
Some(Self::NotWorkTree)
} else if message.contains("Operation not permitted")
|| message.contains("Permission denied")
{
Some(Self::RepositoryPathNotAccessible)
} else if message.contains("non-UTF-8 path") {
Some(Self::NonUtf8Path)
} else if message.contains("bad revision") || message.contains("unknown revision") {
Some(Self::GitRevisionUnavailable)
} else if message.contains("bad tree object HEAD") {
Some(Self::GitHeadTreeInvalid)
} else if message.contains("os error 267") {
Some(Self::RepositoryPathInvalid)
} else if message.contains("Invalid status code") {
Some(Self::InvalidGitStatusOutput)
} else {
None
}
}
}
/// A diff-state failure: a sanitized [`DiffStateErrorKind`] paired with the
/// raw underlying error. See the module docs for how the two halves are
/// routed to telemetry vs. logs / Sentry.
#[derive(Debug, thiserror::Error)]
#[error("{kind}")]
pub(crate) struct DiffStateError {
kind: DiffStateErrorKind,
/// Raw underlying error. Consulted only for [`DiffStateErrorKind::Unknown`]
/// actionability and never exposed via `Display`, `source`, or telemetry,
/// so logs, Sentry, and analytics only ever see the sanitized `kind`.
cause: anyhow::Error,
}
impl DiffStateError {
/// Build a `DiffStateError` from a plain error message string. Used when
/// the source error has already been flattened to a `String` (e.g. by
/// `DiffsWithBaseContent::changes`, or by the remote daemon over the
/// wire).
pub(crate) fn from_message(message: &str) -> Self {
Self {
kind: DiffStateErrorKind::classify(message).unwrap_or(DiffStateErrorKind::Unknown),
cause: anyhow::anyhow!("{message}"),
}
}
/// Build the [`DiffStateErrorKind::EmptyDiffData`] error, reported when the
/// remote daemon claims `DiffState::Loaded` but sends no diff data.
pub(crate) fn empty_diff_data() -> Self {
let kind = DiffStateErrorKind::EmptyDiffData;
let cause = anyhow::anyhow!("{kind}");
Self { kind, cause }
}
/// Logs the raw underlying error locally, then reports the sanitized
/// [`DiffStateError`] through the normal reporting path.
pub(crate) fn report_and_log(&self) {
let cause = &self.cause;
log::warn!("Diff state error: {cause:#}");
warp_errors::report_error!(self);
}
}
impl From<anyhow::Error> for DiffStateError {
fn from(cause: anyhow::Error) -> Self {
let kind = DiffStateErrorKind::classify(&format!("{cause:#}"))
.unwrap_or(DiffStateErrorKind::Unknown);
Self { kind, cause }
}
}
impl ErrorExt for DiffStateError {
fn is_actionable(&self) -> bool {
match self.kind {
// Caller / engineering bugs — surface to Sentry at error level.
DiffStateErrorKind::InvalidEmptyPathspec
| DiffStateErrorKind::InvalidGitStatusOutput
| DiffStateErrorKind::EmptyDiffData => true,
// Unknown errors defer to the anyhow chain so registered
// transient/non-actionable causes (network, transient I/O, etc.)
// log at warn level instead of paging us via Sentry.
DiffStateErrorKind::Unknown => self.cause.is_actionable(),
// User environment failures — not our bug; log as warning.
DiffStateErrorKind::GitRejectedRepositoryOwnership
| DiffStateErrorKind::GitUnavailable
| DiffStateErrorKind::GitLfsUnavailable
| DiffStateErrorKind::XcodeLicenseNotAccepted
| DiffStateErrorKind::PathOutsideRepository
| DiffStateErrorKind::NotGitRepository
| DiffStateErrorKind::NotWorkTree
| DiffStateErrorKind::RepositoryPathNotAccessible
| DiffStateErrorKind::NonUtf8Path
| DiffStateErrorKind::GitRevisionUnavailable
| DiffStateErrorKind::GitHeadTreeInvalid
| DiffStateErrorKind::RepositoryPathInvalid => false,
}
}
}
warp_errors::register_error!(DiffStateError);
impl IsTransientError for DiffStateError {
fn is_transient(&self) -> bool {
match self.kind {
// Repo / filesystem state can briefly churn while the queue is
// processing invalidations, so these are worth the sync queue's
// short retry budget.
DiffStateErrorKind::RepositoryPathNotAccessible
| DiffStateErrorKind::GitRevisionUnavailable
| DiffStateErrorKind::GitHeadTreeInvalid
| DiffStateErrorKind::EmptyDiffData
| DiffStateErrorKind::Unknown => true,
// Caller bugs, invalid inputs, missing tools, and user-actionable
// environment setup issues won't resolve by retrying the same
// operation a few seconds later.
DiffStateErrorKind::GitRejectedRepositoryOwnership
| DiffStateErrorKind::GitUnavailable
| DiffStateErrorKind::GitLfsUnavailable
| DiffStateErrorKind::XcodeLicenseNotAccepted
| DiffStateErrorKind::InvalidEmptyPathspec
| DiffStateErrorKind::PathOutsideRepository
| DiffStateErrorKind::NotGitRepository
| DiffStateErrorKind::NotWorkTree
| DiffStateErrorKind::NonUtf8Path
| DiffStateErrorKind::RepositoryPathInvalid
| DiffStateErrorKind::InvalidGitStatusOutput => false,
}
}
}