//! Embedded Git repository management and pass-compatible automatic commits.
//!
//! Repositories are always opened in isolated mode: only repository-local
//! configuration is read, environment variables and global configuration are
//! ignored, and no filter, credential helper, or transport program is run.

use std::{
    collections::{BTreeMap, BTreeSet},
    error::Error,
    fmt, fs,
    path::{Component, Path, PathBuf},
    sync::{
        Arc,
        atomic::{AtomicBool, Ordering},
    },
};

use flate2::{Compression, write::ZlibEncoder};
use gix::{
    bstr::{BString, ByteSlice},
    objs::tree::EntryKind,
};
use sha1::{Digest as _, Sha1};
use zeroize::Zeroize as _;

use crate::{
    config::{ApplicationId, GitRemote, ServerId},
    crypto::{KeyHandle, KeyStore, SecretProvider},
    mutation::{TreeCommit, TreeCommitError, TreeCommitter},
    recipient::{PolicyCommit, PolicyCommitError, PolicyCommitter},
    repository::{EncryptedEntry, Repository, SecretBytes},
    write::{EntryCommit, EntryCommitError, EntryCommitter, NoGitEntryCommitter},
};

const DEFAULT_BRANCH: &str = "main";
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitIdentity {
    name: String,
    email: String,
}

impl GitIdentity {
    pub fn ironstorage() -> Self {
        Self::new("IronStorage", "ironstorage@localhost")
            .expect("the built-in Git identity is valid")
    }

    pub fn new(name: impl Into<String>, email: impl Into<String>) -> Result<Self, GitError> {
        let name = name.into();
        let email = email.into();
        if name.trim().is_empty()
            || email.trim().is_empty()
            || name.contains(['\n', '\r', '<', '>'])
            || email.contains(['\n', '\r', '<', '>'])
        {
            return Err(GitError::InvalidIdentity);
        }
        Ok(Self { name, email })
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn email(&self) -> &str {
        &self.email
    }

    fn signature(&self) -> gix::actor::Signature {
        gix::actor::Signature {
            name: self.name.clone().into(),
            email: self.email.clone().into(),
            time: gix::date::Time::now_local_or_utc(),
        }
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitChangeKind {
    Added,
    Modified,
    Deleted,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitChange {
    path: PathBuf,
    kind: GitChangeKind,
}

impl GitChange {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn kind(&self) -> GitChangeKind {
        self.kind
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GitStatus {
    staged: Vec<GitChange>,
    unstaged: Vec<GitChange>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitConflictKind {
    Content,
    AddAdd,
    ModifyDelete,
    Structural,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitConflict {
    path: PathBuf,
    kind: GitConflictKind,
}

impl GitConflict {
    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn kind(&self) -> GitConflictKind {
        self.kind
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitConflictChoice {
    Local,
    Remote,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitConflictResolution {
    path: PathBuf,
    choice: GitConflictChoice,
}

impl GitConflictResolution {
    pub fn new(path: PathBuf, choice: GitConflictChoice) -> Self {
        Self { path, choice }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitRemoteStatus {
    name: String,
    url: String,
    ahead: usize,
    behind: usize,
}

impl GitRemoteStatus {
    pub fn name(&self) -> &str {
        &self.name
    }
    pub fn url(&self) -> &str {
        &self.url
    }
    pub fn ahead(&self) -> usize {
        self.ahead
    }
    pub fn behind(&self) -> usize {
        self.behind
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitSnapshot {
    root: PathBuf,
    branch: String,
    status: GitStatus,
    remote: Option<GitRemoteStatus>,
    recent: Vec<GitLogEntry>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitCommitActivity {
    commit: GitLogEntry,
    changes: Vec<GitChange>,
}

impl GitCommitActivity {
    pub fn commit(&self) -> &GitLogEntry {
        &self.commit
    }

    pub fn changes(&self) -> &[GitChange] {
        &self.changes
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDivergence {
    branch: String,
    status: GitStatus,
    remote: GitRemoteStatus,
    incoming: Vec<GitCommitActivity>,
    outgoing: Vec<GitCommitActivity>,
}

impl GitDivergence {
    pub fn branch(&self) -> &str {
        &self.branch
    }

    pub fn status(&self) -> &GitStatus {
        &self.status
    }

    pub fn remote(&self) -> &GitRemoteStatus {
        &self.remote
    }

    pub fn incoming(&self) -> &[GitCommitActivity] {
        &self.incoming
    }

    pub fn outgoing(&self) -> &[GitCommitActivity] {
        &self.outgoing
    }
}

impl GitSnapshot {
    pub fn root(&self) -> &Path {
        &self.root
    }
    pub fn branch(&self) -> &str {
        &self.branch
    }
    pub fn status(&self) -> &GitStatus {
        &self.status
    }
    pub fn remote(&self) -> Option<&GitRemoteStatus> {
        self.remote.as_ref()
    }
    pub fn recent(&self) -> &[GitLogEntry] {
        &self.recent
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GitProgressPhase {
    Validating,
    Authenticating,
    Receiving,
    Integrating,
    Sending,
    Refreshing,
}

#[derive(Clone)]
pub struct GitOperationControl {
    cancelled: Arc<AtomicBool>,
    progress: Arc<dyn Fn(GitProgressPhase) + Send + Sync>,
}

impl Default for GitOperationControl {
    fn default() -> Self {
        Self::new(|_| {})
    }
}

impl GitOperationControl {
    pub fn new(progress: impl Fn(GitProgressPhase) + Send + Sync + 'static) -> Self {
        Self {
            cancelled: Arc::new(AtomicBool::new(false)),
            progress: Arc::new(progress),
        }
    }

    pub fn cancel(&self) {
        self.cancelled.store(true, Ordering::Release);
    }

    pub fn is_cancelled(&self) -> bool {
        self.cancelled.load(Ordering::Acquire)
    }

    fn checkpoint(&self, phase: GitProgressPhase) -> Result<(), GitError> {
        if self.is_cancelled() {
            return Err(GitError::Cancelled);
        }
        (self.progress)(phase);
        if self.is_cancelled() {
            Err(GitError::Cancelled)
        } else {
            Ok(())
        }
    }

    pub fn report(&self, phase: GitProgressPhase) -> Result<(), GitError> {
        self.checkpoint(phase)
    }
}

impl GitStatus {
    pub fn staged(&self) -> &[GitChange] {
        &self.staged
    }

    pub fn unstaged(&self) -> &[GitChange] {
        &self.unstaged
    }

    pub fn is_clean(&self) -> bool {
        self.staged.is_empty() && self.unstaged.is_empty()
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitLogEntry {
    id: String,
    parents: Vec<String>,
    author_name: String,
    author_email: String,
    message: String,
    timestamp: i64,
}

impl GitLogEntry {
    pub fn id(&self) -> &str {
        &self.id
    }
    pub fn parents(&self) -> &[String] {
        &self.parents
    }
    pub fn author_name(&self) -> &str {
        &self.author_name
    }
    pub fn author_email(&self) -> &str {
        &self.author_email
    }
    pub fn message(&self) -> &str {
        &self.message
    }
    pub fn timestamp(&self) -> i64 {
        self.timestamp
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GitDiffEntry {
    path: PathBuf,
    old: Option<Vec<u8>>,
    new: Option<Vec<u8>>,
}

impl GitDiffEntry {
    pub fn path(&self) -> &Path {
        &self.path
    }
    pub fn old(&self) -> Option<&[u8]> {
        self.old.as_deref()
    }
    pub fn current(&self) -> Option<&[u8]> {
        self.new.as_deref()
    }
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub enum GitError {
    NotRepository,
    AlreadyRepository,
    BareRepository,
    InvalidIdentity,
    InvalidPath {
        path: PathBuf,
    },
    UnsafeWorktreeObject {
        path: PathBuf,
    },
    FileTooLarge {
        path: PathBuf,
    },
    NoChanges,
    UnbornHead,
    DirtyWorktree,
    InvalidRemoteName,
    ForbiddenRemoteUrl,
    RemoteNotFound {
        name: String,
    },
    CredentialsUnavailable,
    CredentialAccessDenied,
    CredentialCancelled,
    AuthenticationFailed,
    NetworkUnavailable,
    TlsFailed,
    Cancelled,
    NonFastForward,
    MergeConflicts {
        conflicts: Vec<GitConflict>,
    },
    InvalidConflictResolution {
        path: PathBuf,
    },
    InvalidRepository(String),
    Io {
        operation: &'static str,
        path: PathBuf,
    },
}

impl fmt::Display for GitError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NotRepository => formatter.write_str("no Git repository was found"),
            Self::AlreadyRepository => formatter.write_str("a Git repository already exists here"),
            Self::BareRepository => {
                formatter.write_str("a bare Git repository has no password-store worktree")
            }
            Self::InvalidIdentity => formatter.write_str("the Git author identity is invalid"),
            Self::InvalidPath { path } => write!(formatter, "invalid Git path: {}", path.display()),
            Self::UnsafeWorktreeObject { path } => {
                write!(formatter, "unsafe worktree object: {}", path.display())
            }
            Self::FileTooLarge { path } => {
                write!(formatter, "worktree file is too large: {}", path.display())
            }
            Self::NoChanges => formatter.write_str("there are no staged changes to commit"),
            Self::UnbornHead => formatter.write_str("the repository has no commits"),
            Self::DirtyWorktree => formatter.write_str("the worktree has uncommitted changes"),
            Self::InvalidRemoteName => formatter.write_str("the Git remote name is invalid"),
            Self::ForbiddenRemoteUrl => {
                formatter.write_str("Git remotes must use credential-free HTTPS URLs")
            }
            Self::RemoteNotFound { name } => write!(formatter, "Git remote not found: {name}"),
            Self::CredentialsUnavailable => {
                formatter.write_str("HTTPS Git credentials are unavailable")
            }
            Self::CredentialAccessDenied => {
                formatter.write_str("access to HTTPS Git credentials was denied")
            }
            Self::CredentialCancelled => {
                formatter.write_str("HTTPS Git credential authentication was cancelled")
            }
            Self::AuthenticationFailed => formatter.write_str("HTTPS Git authentication failed"),
            Self::NetworkUnavailable => formatter.write_str("the HTTPS Git remote is unavailable"),
            Self::TlsFailed => formatter.write_str("TLS validation for the Git remote failed"),
            Self::Cancelled => formatter.write_str("the Git operation was cancelled"),
            Self::NonFastForward => formatter.write_str("the remote update is not a fast-forward"),
            Self::MergeConflicts { conflicts } => write!(
                formatter,
                "the merge has conflicts in {} path(s)",
                conflicts.len()
            ),
            Self::InvalidConflictResolution { path } => write!(
                formatter,
                "no supported merge conflict exists at {}",
                path.display()
            ),
            Self::InvalidRepository(message) => {
                write!(formatter, "invalid Git repository: {message}")
            }
            Self::Io { operation, path } => {
                write!(formatter, "could not {operation} {}", path.display())
            }
        }
    }
}

impl Error for GitError {}

pub struct GitRepository {
    root: PathBuf,
    repository: gix::Repository,
    identity: GitIdentity,
}

/// Storage-owned selection of pass-compatible automatic Git commits.
pub enum AutomaticEntryCommitter {
    Git(Box<GitRepository>),
    None(NoGitEntryCommitter),
}

/// Storage-owned selection of pass-compatible automatic Git commits for a
/// recipient policy change.
pub enum AutomaticPolicyCommitter {
    Git(Box<GitRepository>),
    None(crate::recipient::NoGitCommitter),
}

/// Storage-owned selection of pass-compatible automatic Git commits for a
/// remove, move, or copy transaction.
pub enum AutomaticTreeCommitter {
    Git(Box<GitRepository>),
    None(crate::mutation::NoGitTreeCommitter),
}

impl AutomaticTreeCommitter {
    pub fn for_source(
        repository: &Repository,
        source: &str,
        identity: GitIdentity,
    ) -> Result<Self, GitError> {
        match GitRepository::open_innermost(repository, Path::new(source), identity) {
            Ok(git) => Ok(Self::Git(Box::new(git))),
            Err(GitError::NotRepository) => Ok(Self::None(crate::mutation::NoGitTreeCommitter)),
            Err(error) => Err(error),
        }
    }
}

impl TreeCommitter for AutomaticTreeCommitter {
    fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> {
        match self {
            Self::Git(git) => TreeCommitter::commit(git.as_mut(), change),
            Self::None(committer) => committer.commit(change),
        }
    }
}

impl AutomaticPolicyCommitter {
    pub fn for_directory(
        repository: &Repository,
        directory: &str,
        identity: GitIdentity,
    ) -> Result<Self, GitError> {
        match GitRepository::open_innermost(repository, Path::new(directory), identity) {
            Ok(git) => Ok(Self::Git(Box::new(git))),
            Err(GitError::NotRepository) => Ok(Self::None(crate::recipient::NoGitCommitter)),
            Err(error) => Err(error),
        }
    }
}

impl PolicyCommitter for AutomaticPolicyCommitter {
    fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
        match self {
            Self::Git(git) => PolicyCommitter::commit(git.as_mut(), change),
            Self::None(committer) => committer.commit(change),
        }
    }
}

impl AutomaticEntryCommitter {
    pub fn for_entry(
        repository: &Repository,
        entry: &str,
        identity: GitIdentity,
    ) -> Result<Self, GitError> {
        match GitRepository::open_innermost(repository, Path::new(entry), identity) {
            Ok(git) => Ok(Self::Git(Box::new(git))),
            Err(GitError::NotRepository) => Ok(Self::None(NoGitEntryCommitter)),
            Err(error) => Err(error),
        }
    }
}

impl EntryCommitter for AutomaticEntryCommitter {
    fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
        match self {
            Self::Git(git) => EntryCommitter::commit(git.as_mut(), change),
            Self::None(committer) => committer.commit(change),
        }
    }
}

pub struct GitCredential {
    username: String,
    password: crate::repository::SecretBytes,
}

impl GitCredential {
    pub fn new(username: impl Into<String>, mut password: Vec<u8>) -> Result<Self, GitError> {
        let username = username.into();
        if username.is_empty()
            || username.contains(['\n', '\r', '\0'])
            || password.contains(&b'\n')
            || password.contains(&b'\r')
            || password.contains(&0)
        {
            password.zeroize();
            return Err(GitError::CredentialsUnavailable);
        }
        Ok(Self {
            username,
            password: crate::repository::SecretBytes::new(password),
        })
    }

    pub fn username(&self) -> &str {
        &self.username
    }
    pub fn password(&self) -> &[u8] {
        self.password.expose()
    }
}

impl fmt::Debug for GitCredential {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str("GitCredential([REDACTED])")
    }
}

pub trait GitCredentialProvider {
    fn credential(
        &self,
        server: &ServerId,
        application: &ApplicationId,
    ) -> Result<GitCredential, GitError>;
}

pub trait GitSmartHttpTransport {
    fn advertise_receive_pack(
        &self,
        url: &url::Url,
        credential: &GitCredential,
    ) -> Result<Vec<u8>, GitError>;
    fn receive_pack(
        &self,
        url: &url::Url,
        credential: &GitCredential,
        request: Vec<u8>,
    ) -> Result<Vec<u8>, GitError>;

    fn advertise_receive_pack_controlled(
        &self,
        url: &url::Url,
        credential: &GitCredential,
        control: &GitOperationControl,
    ) -> Result<Vec<u8>, GitError> {
        control.checkpoint(GitProgressPhase::Receiving)?;
        let result = self.advertise_receive_pack(url, credential)?;
        control.checkpoint(GitProgressPhase::Receiving)?;
        Ok(result)
    }

    fn receive_pack_controlled(
        &self,
        url: &url::Url,
        credential: &GitCredential,
        request: Vec<u8>,
        control: &GitOperationControl,
    ) -> Result<Vec<u8>, GitError> {
        control.checkpoint(GitProgressPhase::Sending)?;
        let result = self.receive_pack(url, credential, request)?;
        control.checkpoint(GitProgressPhase::Sending)?;
        Ok(result)
    }
}

pub trait GitFetchTransport {
    fn fetch(
        &self,
        repository: &GitRepository,
        configured: &GitRemote,
        credential: &GitCredential,
    ) -> Result<bool, GitError>;

    fn fetch_controlled(
        &self,
        repository: &GitRepository,
        configured: &GitRemote,
        credential: &GitCredential,
        control: &GitOperationControl,
    ) -> Result<bool, GitError> {
        control.checkpoint(GitProgressPhase::Receiving)?;
        let result = self.fetch(repository, configured, credential)?;
        control.checkpoint(GitProgressPhase::Receiving)?;
        Ok(result)
    }
}

#[derive(Default)]
pub struct EmbeddedFetchTransport;

impl GitFetchTransport for EmbeddedFetchTransport {
    fn fetch(
        &self,
        repository: &GitRepository,
        configured: &GitRemote,
        credential: &GitCredential,
    ) -> Result<bool, GitError> {
        repository.fetch_embedded(configured, credential)
    }

    fn fetch_controlled(
        &self,
        repository: &GitRepository,
        configured: &GitRemote,
        credential: &GitCredential,
        control: &GitOperationControl,
    ) -> Result<bool, GitError> {
        repository.fetch_embedded_controlled(configured, credential, control)
    }
}

#[derive(Default)]
pub struct ReqwestGitTransport;

impl ReqwestGitTransport {
    fn client() -> Result<reqwest::blocking::Client, GitError> {
        reqwest::blocking::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .map_err(invalid)
    }

    fn endpoint(url: &url::Url, suffix: &str) -> Result<url::Url, GitError> {
        let mut endpoint = url.clone();
        let path = endpoint.path().trim_end_matches('/');
        endpoint.set_path(&format!("{path}/{suffix}"));
        Ok(endpoint)
    }

    fn authenticated(
        request: reqwest::blocking::RequestBuilder,
        credential: &GitCredential,
    ) -> Result<reqwest::blocking::RequestBuilder, GitError> {
        let password = std::str::from_utf8(credential.password())
            .map_err(|_| GitError::CredentialsUnavailable)?;
        Ok(request.basic_auth(credential.username(), Some(password)))
    }

    fn response(response: reqwest::blocking::Response) -> Result<Vec<u8>, GitError> {
        let status = response.status();
        if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
            return Err(GitError::AuthenticationFailed);
        }
        if !status.is_success() {
            return Err(GitError::InvalidRepository(format!(
                "Git HTTP server returned {status}"
            )));
        }
        response
            .bytes()
            .map(|bytes| bytes.to_vec())
            .map_err(map_reqwest_error)
    }
}

impl GitSmartHttpTransport for ReqwestGitTransport {
    fn advertise_receive_pack(
        &self,
        url: &url::Url,
        credential: &GitCredential,
    ) -> Result<Vec<u8>, GitError> {
        let mut endpoint = Self::endpoint(url, "info/refs")?;
        endpoint
            .query_pairs_mut()
            .append_pair("service", "git-receive-pack");
        let request = Self::client()?
            .get(endpoint)
            .header("Accept", "application/x-git-receive-pack-advertisement");
        Self::response(
            Self::authenticated(request, credential)?
                .send()
                .map_err(map_reqwest_error)?,
        )
    }

    fn receive_pack(
        &self,
        url: &url::Url,
        credential: &GitCredential,
        request: Vec<u8>,
    ) -> Result<Vec<u8>, GitError> {
        let endpoint = Self::endpoint(url, "git-receive-pack")?;
        let request = Self::client()?
            .post(endpoint)
            .header("Content-Type", "application/x-git-receive-pack-request")
            .header("Accept", "application/x-git-receive-pack-result")
            .body(request);
        Self::response(
            Self::authenticated(request, credential)?
                .send()
                .map_err(map_reqwest_error)?,
        )
    }
}

fn map_reqwest_error(error: reqwest::Error) -> GitError {
    if error.is_builder() {
        return invalid(error);
    }
    let message = error.to_string().to_ascii_lowercase();
    if message.contains("certificate") || message.contains("tls") {
        GitError::TlsFailed
    } else {
        GitError::NetworkUnavailable
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FetchOutcome {
    remote: String,
    received_pack: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PullOutcome {
    UpToDate,
    FastForward,
    Merged,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PushOutcome {
    remote: String,
    branch: String,
    old: Option<String>,
    new: String,
}

impl PushOutcome {
    pub fn remote(&self) -> &str {
        &self.remote
    }
    pub fn branch(&self) -> &str {
        &self.branch
    }
    pub fn old(&self) -> Option<&str> {
        self.old.as_deref()
    }
    pub fn new_id(&self) -> &str {
        &self.new
    }
}

impl FetchOutcome {
    pub fn remote(&self) -> &str {
        &self.remote
    }
    pub fn received_pack(&self) -> bool {
        self.received_pack
    }
}

impl GitRepository {
    pub fn discover_remote_branches(
        parent: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        control: &GitOperationControl,
    ) -> Result<Vec<String>, GitError> {
        Self::discover_remote_branches_with_transport(
            parent,
            identity,
            configured,
            credentials,
            &EmbeddedFetchTransport,
            control,
        )
    }

    pub fn discover_remote_branches_with_transport(
        parent: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
        control: &GitOperationControl,
    ) -> Result<Vec<String>, GitError> {
        control.report(GitProgressPhase::Validating)?;
        validate_https_remote(configured.url().as_str())?;
        ensure_clone_parent(parent)?;
        let temporary = private_temporary_directory(parent, "probe")?;
        let result = (|| {
            let store = Repository::open(&temporary).map_err(invalid)?;
            gix::ThreadSafeRepository::init_opts(
                store.root_path(),
                gix::create::Kind::WithWorktree,
                gix::create::Options::default(),
                isolated_options(),
            )
            .map_err(invalid)?;
            let mut repository = Self::open(&store, identity)?;
            repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
            repository.fetch_with_transport_controlled(
                configured,
                credentials,
                transport,
                control,
            )?;
            repository.remote_branch_names(configured.name().as_str())
        })();
        let _ = fs::remove_dir_all(&temporary);
        result
    }

    pub fn clone_into(
        destination: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
    ) -> Result<Self, GitError> {
        Self::clone_into_with_transport_controlled(
            destination,
            identity,
            configured,
            None,
            credentials,
            &EmbeddedFetchTransport,
            &GitOperationControl::default(),
        )
    }

    pub fn clone_into_controlled(
        destination: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        branch: &str,
        credentials: &impl GitCredentialProvider,
        control: &GitOperationControl,
    ) -> Result<Self, GitError> {
        Self::clone_into_with_transport_controlled(
            destination,
            identity,
            configured,
            Some(branch),
            credentials,
            &EmbeddedFetchTransport,
            control,
        )
    }

    pub fn clone_into_with_transport(
        destination: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
    ) -> Result<Self, GitError> {
        Self::clone_into_with_transport_controlled(
            destination,
            identity,
            configured,
            None,
            credentials,
            transport,
            &GitOperationControl::default(),
        )
    }

    pub fn clone_into_with_transport_controlled(
        destination: &Path,
        identity: GitIdentity,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
        control: &GitOperationControl,
    ) -> Result<Self, GitError> {
        control.report(GitProgressPhase::Validating)?;
        validate_https_remote(configured.url().as_str())?;
        if let Some(branch) = branch {
            validate_remote_name(branch)?;
        }
        let parent = destination.parent().ok_or_else(|| GitError::InvalidPath {
            path: destination.to_owned(),
        })?;
        ensure_clone_parent(parent)?;
        if let Ok(metadata) = fs::symlink_metadata(destination) {
            if !metadata.is_dir() || metadata.file_type().is_symlink() {
                return Err(GitError::UnsafeWorktreeObject {
                    path: destination.to_owned(),
                });
            }
            if fs::read_dir(destination)
                .map_err(|_| io("read clone destination", destination))?
                .next()
                .is_some()
            {
                return Err(GitError::DirtyWorktree);
            }
        }
        let temporary = private_temporary_directory(parent, "clone")?;
        let cloned = (|| {
            let store = Repository::open(&temporary).map_err(invalid)?;
            gix::ThreadSafeRepository::init_opts(
                store.root_path(),
                gix::create::Kind::WithWorktree,
                gix::create::Options::default(),
                isolated_options(),
            )
            .map_err(invalid)?;
            let mut repository = Self::open(&store, identity.clone())?;
            repository.add_remote(configured.name().as_str(), configured.url().as_str())?;
            repository.pull_with_transport_controlled(
                configured,
                branch,
                credentials,
                transport,
                control,
            )?;
            control.report(GitProgressPhase::Refreshing)?;
            Ok(())
        })();
        if let Err(error) = cloned {
            let _ = fs::remove_dir_all(&temporary);
            return Err(error);
        }
        if destination.exists() && fs::remove_dir(destination).is_err() {
            let _ = fs::remove_dir_all(&temporary);
            return Err(io("prepare clone destination", destination));
        }
        if fs::rename(&temporary, destination).is_err() {
            let _ = fs::remove_dir_all(&temporary);
            return Err(GitError::Io {
                operation: "install clone at",
                path: destination.to_owned(),
            });
        }
        Self::open_at(destination.to_owned(), identity)
    }

    fn remote_branch_names(&self, remote: &str) -> Result<Vec<String>, GitError> {
        validate_remote_name(remote)?;
        let prefix = format!("refs/remotes/{remote}/");
        let mut branches = self
            .repository
            .references()
            .map_err(invalid)?
            .remote_branches()
            .map_err(invalid)?
            .filter_map(|reference| {
                let reference = match reference {
                    Ok(reference) => reference,
                    Err(error) => return Some(Err(invalid(error))),
                };
                let name = match reference.name().as_bstr().to_str() {
                    Ok(name) => name,
                    Err(error) => return Some(Err(invalid(error))),
                };
                name.strip_prefix(&prefix)
                    .filter(|name| *name != "HEAD")
                    .map(|name| {
                        validate_remote_name(name)?;
                        Ok(name.to_owned())
                    })
            })
            .collect::<Result<Vec<_>, GitError>>()?;
        branches.sort();
        branches.dedup();
        if branches.is_empty() {
            return Err(GitError::InvalidRepository(
                "the remote has no branches".to_owned(),
            ));
        }
        Ok(branches)
    }

    pub fn init(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
        let root = store.root_path().to_owned();
        if root.join(".git").exists() {
            return Err(GitError::AlreadyRepository);
        }
        let attributes_path = root.join(".gitattributes");
        let previous_attributes = match fs::read(&attributes_path) {
            Ok(contents) => Some(contents),
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
            Err(_) => return Err(io("read", Path::new(".gitattributes"))),
        };
        gix::ThreadSafeRepository::init_opts(
            &root,
            gix::create::Kind::WithWorktree,
            gix::create::Options::default(),
            isolated_options(),
        )
        .map_err(invalid)?;
        let initialized = (|| {
            let repository = Self::open_at(root.clone(), identity)?;
            let existing = repository
                .worktree_blob_map()?
                .keys()
                .map(PathBuf::from)
                .collect::<Vec<_>>();
            if !existing.is_empty() {
                repository.stage(&existing)?;
                repository.commit("Add current contents of password store.")?;
            }
            atomic_write_worktree(&root, Path::new(".gitattributes"), b"*.gpg diff=gpg\n")?;
            repository.stage(&[PathBuf::from(".gitattributes")])?;
            repository.commit("Configure git repository for gpg file diff.")?;
            Ok(repository)
        })();
        if initialized.is_err() {
            match previous_attributes {
                Some(contents) => {
                    let _ = atomic_write_worktree(&root, Path::new(".gitattributes"), &contents);
                }
                None => {
                    let _ = fs::remove_file(&attributes_path);
                }
            }
            let _ = fs::remove_dir_all(root.join(".git"));
        }
        initialized
    }

    pub fn open(store: &Repository, identity: GitIdentity) -> Result<Self, GitError> {
        Self::open_at(store.root_path().to_owned(), identity)
    }

    /// Open the innermost repository containing `path`, without crossing the store root.
    pub fn open_innermost(
        store: &Repository,
        path: &Path,
        identity: GitIdentity,
    ) -> Result<Self, GitError> {
        let mut candidate = if path.as_os_str().is_empty() {
            store.root_path().to_owned()
        } else {
            store.root_path().join(validate_relative(path)?)
        };
        if !candidate.is_dir() {
            candidate.pop();
        }
        loop {
            if candidate.join(".git").is_dir() {
                return Self::open_at(candidate, identity);
            }
            if candidate == store.root_path() || !candidate.pop() {
                return Err(GitError::NotRepository);
            }
        }
    }

    fn open_at(root: PathBuf, identity: GitIdentity) -> Result<Self, GitError> {
        let mut repository =
            gix::open_opts(&root, isolated_options()).map_err(|_| GitError::NotRepository)?;
        if repository.is_bare() {
            return Err(GitError::BareRepository);
        }
        validate_local_config_security(&load_local_config(&repository)?)?;
        apply_in_memory_identity(&mut repository, &identity)?;
        Ok(Self {
            root,
            repository,
            identity,
        })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn remotes(&self) -> Vec<String> {
        self.repository
            .remote_names()
            .into_iter()
            .map(|name| name.to_string())
            .collect()
    }

    pub fn remote_url(&self, name: &str) -> Result<String, GitError> {
        validate_remote_name(name)?;
        let remote = self
            .repository
            .find_remote(name)
            .map_err(|_| GitError::RemoteNotFound {
                name: name.to_owned(),
            })?;
        let url =
            remote
                .url(gix::remote::Direction::Fetch)
                .ok_or_else(|| GitError::RemoteNotFound {
                    name: name.to_owned(),
                })?;
        let text = url.to_bstring().to_str_lossy().into_owned();
        validate_https_remote(&text)?;
        Ok(text)
    }

    pub fn add_remote(&mut self, name: &str, url: &str) -> Result<(), GitError> {
        validate_remote_name(name)?;
        validate_https_remote(url)?;
        if self.remotes().iter().any(|existing| existing == name) {
            return Err(GitError::InvalidRepository(format!(
                "remote {name} already exists"
            )));
        }
        self.update_local_config(|config| {
            config
                .set_raw_value_by("remote", Some(name.into()), "url", url)
                .map_err(invalid)?;
            config
                .set_raw_value_by(
                    "remote",
                    Some(name.into()),
                    "fetch",
                    format!("+refs/heads/*:refs/remotes/{name}/*"),
                )
                .map_err(invalid)?;
            Ok(())
        })
    }

    pub fn set_remote_url(&mut self, name: &str, url: &str) -> Result<(), GitError> {
        validate_remote_name(name)?;
        validate_https_remote(url)?;
        if !self.remotes().iter().any(|existing| existing == name) {
            return Err(GitError::RemoteNotFound {
                name: name.to_owned(),
            });
        }
        self.update_local_config(|config| {
            config
                .set_raw_value_by("remote", Some(name.into()), "url", url)
                .map_err(invalid)?;
            Ok(())
        })
    }

    pub fn remove_remote(&mut self, name: &str) -> Result<(), GitError> {
        validate_remote_name(name)?;
        self.update_local_config(|config| {
            if config.remove_section("remote", name).is_none() {
                return Err(GitError::RemoteNotFound {
                    name: name.to_owned(),
                });
            }
            Ok(())
        })
    }

    pub fn config_get(&self, key: &str) -> Result<Option<String>, GitError> {
        validate_config_key(key)?;
        let config = load_local_config(&self.repository)?;
        match config.raw_value(key) {
            Ok(value) => Ok(Some(value.to_str_lossy().into_owned())),
            Err(_) => Ok(None),
        }
    }

    pub fn config_set(&mut self, key: &str, value: &str) -> Result<(), GitError> {
        validate_config_key(key)?;
        let parts = key.split('.').collect::<Vec<_>>();
        if parts
            .first()
            .is_some_and(|section| section.eq_ignore_ascii_case("remote"))
            && parts
                .last()
                .is_some_and(|name| name.eq_ignore_ascii_case("url"))
        {
            if parts.len() < 3 {
                return Err(GitError::InvalidRepository(
                    "invalid remote URL configuration key".to_owned(),
                ));
            }
            validate_remote_name(&parts[1..parts.len() - 1].join("."))?;
            validate_https_remote(value)?;
        }
        if value.contains(['\n', '\r', '\0']) {
            return Err(GitError::InvalidRepository(
                "invalid Git configuration value".to_owned(),
            ));
        }
        self.update_local_config(|config| {
            config.set_raw_value(key, value).map_err(invalid)?;
            Ok(())
        })
    }

    pub fn fetch(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
    ) -> Result<FetchOutcome, GitError> {
        self.fetch_with_transport(configured, credentials, &EmbeddedFetchTransport)
    }

    pub fn fetch_with_transport(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
    ) -> Result<FetchOutcome, GitError> {
        self.fetch_with_transport_controlled(
            configured,
            credentials,
            transport,
            &GitOperationControl::default(),
        )
    }

    pub fn fetch_with_transport_controlled(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
        control: &GitOperationControl,
    ) -> Result<FetchOutcome, GitError> {
        control.checkpoint(GitProgressPhase::Validating)?;
        let name = configured.name().as_str();
        let actual_url = self.remote_url(name)?;
        if actual_url != configured.url().as_str() {
            return Err(GitError::ForbiddenRemoteUrl);
        }
        control.checkpoint(GitProgressPhase::Authenticating)?;
        let credential =
            credentials.credential(configured.server_id(), configured.application_id())?;
        let received_pack = transport.fetch_controlled(self, configured, &credential, control)?;
        Ok(FetchOutcome {
            remote: name.to_owned(),
            received_pack,
        })
    }

    #[allow(
        clippy::result_large_err,
        reason = "the gix credential callback fixes its protocol error type"
    )]
    fn fetch_embedded(
        &self,
        configured: &GitRemote,
        credential: &GitCredential,
    ) -> Result<bool, GitError> {
        self.fetch_embedded_controlled(configured, credential, &GitOperationControl::default())
    }

    #[allow(
        clippy::result_large_err,
        reason = "the gix credential callback fixes its protocol error type"
    )]
    fn fetch_embedded_controlled(
        &self,
        configured: &GitRemote,
        credential: &GitCredential,
        control: &GitOperationControl,
    ) -> Result<bool, GitError> {
        let name = configured.name().as_str();
        let password = std::str::from_utf8(credential.password())
            .map_err(|_| GitError::CredentialsUnavailable)?
            .to_owned();
        let username = credential.username().to_owned();
        let expected_origin = configured.url().clone();
        let remote = self
            .repository
            .find_fetch_remote(Some(name.into()))
            .map_err(invalid)?;
        let mut connection = remote
            .connect(gix::remote::Direction::Fetch)
            .map_err(invalid)?;
        connection.set_credentials(move |action| match action {
            gix::credentials::helper::Action::Get(context) => {
                let same_origin = context
                    .url
                    .as_ref()
                    .and_then(|value| value.to_str().ok())
                    .and_then(|value| url::Url::parse(value).ok())
                    .is_some_and(|requested| same_https_origin(&expected_origin, &requested));
                if !same_origin {
                    return Err(gix::credentials::protocol::Error::Quit);
                }
                Ok(Some(gix::credentials::protocol::Outcome {
                    identity: gix::sec::identity::Account {
                        username: username.clone(),
                        password: password.clone(),
                        oauth_refresh_token: None,
                    },
                    next: context.into(),
                }))
            }
            gix::credentials::helper::Action::Store(_)
            | gix::credentials::helper::Action::Erase(_) => Ok(None),
        });
        let prepared = connection
            .prepare_fetch(gix::progress::Discard, Default::default())
            .map_err(invalid)?;
        let outcome = prepared
            .receive(gix::progress::Discard, control.cancelled.as_ref())
            .map_err(|error| {
                if control.is_cancelled() {
                    return GitError::Cancelled;
                }
                let text = error.to_string();
                let lower = text.to_ascii_lowercase();
                if text.contains("401")
                    || text.contains("403")
                    || lower.contains("authentication")
                    || (lower.contains("credential") && lower.contains("not accepted"))
                {
                    GitError::AuthenticationFailed
                } else if lower.contains("certificate") || lower.contains("tls") {
                    GitError::TlsFailed
                } else if lower.contains("network")
                    || lower.contains("connect")
                    || lower.contains("dns")
                {
                    GitError::NetworkUnavailable
                } else {
                    invalid(error)
                }
            })?;
        Ok(matches!(
            outcome.status,
            gix::remote::fetch::Status::Change { .. }
        ))
    }

    pub fn pull(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
    ) -> Result<PullOutcome, GitError> {
        self.pull_with_transport(configured, branch, credentials, &EmbeddedFetchTransport)
    }

    pub fn pull_with_transport(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
    ) -> Result<PullOutcome, GitError> {
        self.pull_with_transport_controlled(
            configured,
            branch,
            credentials,
            transport,
            &GitOperationControl::default(),
        )
    }

    pub fn pull_with_transport_controlled(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitFetchTransport,
        control: &GitOperationControl,
    ) -> Result<PullOutcome, GitError> {
        if !self.status()?.is_clean() {
            return Err(GitError::DirtyWorktree);
        }
        self.fetch_with_transport_controlled(configured, credentials, transport, control)?;
        control.checkpoint(GitProgressPhase::Integrating)?;
        self.integrate_fetched(configured, branch)
    }

    /// Integrate an already fetched remote-tracking branch. This is exposed so
    /// platform networking adapters and deterministic tests can separate the
    /// transport step from merge/checkout policy.
    pub fn integrate_fetched(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
    ) -> Result<PullOutcome, GitError> {
        if !self.status()?.is_clean() {
            return Err(GitError::DirtyWorktree);
        }
        let branch = branch.map_or_else(
            || self.current_branch(),
            |branch| {
                validate_remote_name(branch)?;
                Ok(branch.to_owned())
            },
        )?;
        let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name());
        let remote_id = self
            .repository
            .find_reference(&remote_ref_name)
            .map_err(|_| GitError::RemoteNotFound {
                name: remote_ref_name.clone(),
            })?
            .into_fully_peeled_id()
            .map_err(invalid)?
            .detach();
        let local = self.repository.head_id().ok().map(|id| id.detach());
        let Some(local_id) = local else {
            self.checkout_and_update(remote_id, None)?;
            return Ok(PullOutcome::FastForward);
        };
        if local_id == remote_id {
            return Ok(PullOutcome::UpToDate);
        }
        let base = self
            .repository
            .merge_base(local_id, remote_id)
            .map_err(invalid)?
            .detach();
        if base == remote_id {
            return Ok(PullOutcome::UpToDate);
        }
        if base == local_id {
            self.checkout_and_update(remote_id, Some(local_id))?;
            return Ok(PullOutcome::FastForward);
        }

        let labels = gix::merge::blob::builtin_driver::text::Labels {
            ancestor: Some("base".into()),
            current: Some("HEAD".into()),
            other: Some(configured.name().as_str().into()),
        };
        let options = self
            .repository
            .tree_merge_options()
            .map_err(invalid)?
            .into();
        let mut outcome = self
            .repository
            .merge_commits(local_id, remote_id, labels, options)
            .map_err(invalid)?;
        let unresolved = gix::merge::tree::TreatAsUnresolved::default();
        if outcome.tree_merge.has_unresolved_conflicts(unresolved) {
            let mut conflicts = outcome
                .tree_merge
                .conflicts
                .iter()
                .filter(|conflict| conflict.is_unresolved(unresolved))
                .map(conflict_description)
                .collect::<Vec<_>>();
            conflicts.sort_by(|left, right| left.path.cmp(&right.path));
            conflicts.dedup_by(|left, right| left.path == right.path);
            return Err(GitError::MergeConflicts { conflicts });
        }
        let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach();
        let signature = self.identity.signature();
        let mut committer_time = gix::date::parse::TimeBuf::default();
        let mut author_time = gix::date::parse::TimeBuf::default();
        let commit = self
            .repository
            .new_commit_as(
                signature.to_ref(&mut committer_time),
                signature.to_ref(&mut author_time),
                format!(
                    "Merge remote-tracking branch '{}/{}'.",
                    configured.name(),
                    branch
                ),
                tree,
                [local_id, remote_id],
            )
            .map_err(invalid)?;
        self.checkout_and_update(commit.id, Some(local_id))?;
        Ok(PullOutcome::Merged)
    }

    pub fn push(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
    ) -> Result<PushOutcome, GitError> {
        self.push_with_transport(configured, branch, credentials, &ReqwestGitTransport)
    }

    pub fn push_with_transport(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitSmartHttpTransport,
    ) -> Result<PushOutcome, GitError> {
        self.push_with_transport_controlled(
            configured,
            branch,
            credentials,
            transport,
            &GitOperationControl::default(),
        )
    }

    pub fn push_with_transport_controlled(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        credentials: &impl GitCredentialProvider,
        transport: &impl GitSmartHttpTransport,
        control: &GitOperationControl,
    ) -> Result<PushOutcome, GitError> {
        control.checkpoint(GitProgressPhase::Validating)?;
        if !self.status()?.is_clean() {
            return Err(GitError::DirtyWorktree);
        }
        let name = configured.name().as_str();
        let actual_url = self.remote_url(name)?;
        if actual_url != configured.url().as_str() {
            return Err(GitError::ForbiddenRemoteUrl);
        }
        let url = validate_https_remote(&actual_url)?;
        let branch = branch.map_or_else(
            || self.current_branch(),
            |branch| {
                validate_remote_name(branch)?;
                Ok(branch.to_owned())
            },
        )?;
        let reference = format!("refs/heads/{branch}");
        let new = self
            .repository
            .head_id()
            .map_err(|_| GitError::UnbornHead)?
            .detach();
        control.checkpoint(GitProgressPhase::Authenticating)?;
        let credential =
            credentials.credential(configured.server_id(), configured.application_id())?;
        let advertisement =
            transport.advertise_receive_pack_controlled(&url, &credential, control)?;
        let advertised = parse_receive_pack_advertisement(&advertisement)?;
        let old = advertised.refs.get(&reference).copied();
        if let Some(old) = old {
            if old == new {
                return Ok(PushOutcome {
                    remote: name.to_owned(),
                    branch,
                    old: Some(old.to_string()),
                    new: new.to_string(),
                });
            }
            let base = self
                .repository
                .merge_base(old, new)
                .map_err(|_| GitError::NonFastForward)?
                .detach();
            if base != old {
                return Err(GitError::NonFastForward);
            }
        }
        if !advertised.capabilities.contains("report-status") {
            return Err(GitError::InvalidRepository(
                "server does not support receive-pack status reports".to_owned(),
            ));
        }
        let pack = build_pack(&self.repository, new, old)?;
        let old_hex = old.map_or_else(
            || "0000000000000000000000000000000000000000".to_owned(),
            |id| id.to_string(),
        );
        let command = format!(
            "{old_hex} {new} {reference}\0report-status agent=ironstorage/{}\n",
            env!("CARGO_PKG_VERSION")
        );
        let mut request = encode_pkt_line(command.as_bytes())?;
        request.extend_from_slice(b"0000");
        request.extend_from_slice(&pack);
        let response = transport.receive_pack_controlled(&url, &credential, request, control)?;
        parse_receive_pack_result(&response, &reference)?;
        self.update_remote_tracking(name, &branch, new)?;
        Ok(PushOutcome {
            remote: name.to_owned(),
            branch,
            old: old.map(|id| id.to_string()),
            new: new.to_string(),
        })
    }

    pub fn sync(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
    ) -> Result<(PullOutcome, PushOutcome), GitError> {
        let pull = self.pull(configured, None, credentials)?;
        let push = self.push(configured, None, credentials)?;
        Ok((pull, push))
    }

    pub fn sync_controlled(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        control: &GitOperationControl,
    ) -> Result<(PullOutcome, PushOutcome), GitError> {
        self.sync_with_transports_controlled(
            configured,
            credentials,
            &EmbeddedFetchTransport,
            &ReqwestGitTransport,
            control,
        )
    }

    pub fn sync_with_transports_controlled(
        &self,
        configured: &GitRemote,
        credentials: &impl GitCredentialProvider,
        fetch: &impl GitFetchTransport,
        push: &impl GitSmartHttpTransport,
        control: &GitOperationControl,
    ) -> Result<(PullOutcome, PushOutcome), GitError> {
        let pull =
            self.pull_with_transport_controlled(configured, None, credentials, fetch, control)?;
        let push =
            self.push_with_transport_controlled(configured, None, credentials, push, control)?;
        Ok((pull, push))
    }

    /// Return one internally consistent, secret-free view of repository state
    /// for frontends. The remote relation uses only the last fetched tracking
    /// ref and therefore never performs network access.
    pub fn snapshot(
        &self,
        configured: Option<&GitRemote>,
        recent_limit: usize,
    ) -> Result<GitSnapshot, GitError> {
        let branch = self.current_branch()?;
        let status = self.status()?;
        let recent = match self.log(Some(recent_limit)) {
            Ok(entries) => entries,
            Err(GitError::UnbornHead) => Vec::new(),
            Err(error) => return Err(error),
        };
        let remote = configured
            .map(|configured| {
                let name = configured.name().as_str();
                let actual_url = if self.remotes().iter().any(|remote| remote == name) {
                    let actual = self.remote_url(name)?;
                    if actual != configured.url().as_str() {
                        return Err(GitError::ForbiddenRemoteUrl);
                    }
                    actual
                } else {
                    configured.url().to_string()
                };
                let remote_ref = format!("refs/remotes/{name}/{branch}");
                let relation = self
                    .repository
                    .find_reference(&remote_ref)
                    .ok()
                    .and_then(|reference| reference.into_fully_peeled_id().ok())
                    .and_then(|remote_id| {
                        self.repository
                            .head_id()
                            .ok()
                            .map(|local_id| (local_id.detach(), remote_id.detach()))
                    });
                let (ahead, behind) = relation.map_or(Ok((0, 0)), |(local, remote)| {
                    self.ahead_behind(local, remote)
                })?;
                Ok(GitRemoteStatus {
                    name: name.to_owned(),
                    url: actual_url,
                    ahead,
                    behind,
                })
            })
            .transpose()?;
        Ok(GitSnapshot {
            root: self.root.clone(),
            branch,
            status,
            remote,
            recent,
        })
    }

    /// Return commits and typed path changes on each side of the configured
    /// remote-tracking branch without performing network access.
    pub fn divergence(
        &self,
        configured: &GitRemote,
        activity_limit: usize,
    ) -> Result<GitDivergence, GitError> {
        let branch = self.current_branch()?;
        let status = self.status()?;
        let name = configured.name().as_str();
        let actual_url = self.remote_url(name)?;
        if actual_url != configured.url().as_str() {
            return Err(GitError::ForbiddenRemoteUrl);
        }
        let local_id = self
            .repository
            .head_id()
            .map_err(|_| GitError::UnbornHead)?
            .detach();
        let remote_ref = format!("refs/remotes/{name}/{branch}");
        let remote_id = self
            .repository
            .find_reference(&remote_ref)
            .map_err(|_| GitError::RemoteNotFound {
                name: remote_ref.clone(),
            })?
            .into_fully_peeled_id()
            .map_err(invalid)?
            .detach();
        let local_ids = self.ancestor_ids(local_id)?;
        let remote_ids = self.ancestor_ids(remote_id)?;
        let ahead = local_ids.difference(&remote_ids).count();
        let behind = remote_ids.difference(&local_ids).count();
        let incoming = self.commit_activities(remote_id, &local_ids, activity_limit)?;
        let outgoing = self.commit_activities(local_id, &remote_ids, activity_limit)?;
        Ok(GitDivergence {
            branch,
            status,
            remote: GitRemoteStatus {
                name: name.to_owned(),
                url: actual_url,
                ahead,
                behind,
            },
            incoming,
            outgoing,
        })
    }

    fn commit_activities(
        &self,
        tip: gix::hash::ObjectId,
        excluded: &BTreeSet<gix::hash::ObjectId>,
        limit: usize,
    ) -> Result<Vec<GitCommitActivity>, GitError> {
        if limit == 0 {
            return Ok(Vec::new());
        }
        let commit = self
            .repository
            .find_object(tip)
            .map_err(invalid)?
            .peel_to_commit()
            .map_err(invalid)?;
        let mut output = Vec::new();
        for info in commit.ancestors().all().map_err(invalid)? {
            let info = info.map_err(invalid)?;
            if excluded.contains(&info.id) {
                continue;
            }
            output.push(self.commit_activity(info.id)?);
            if output.len() >= limit {
                break;
            }
        }
        Ok(output)
    }

    fn commit_activity(&self, id: gix::hash::ObjectId) -> Result<GitCommitActivity, GitError> {
        let commit = self
            .repository
            .find_object(id)
            .map_err(invalid)?
            .peel_to_commit()
            .map_err(invalid)?;
        let new_tree = commit.tree_id().map_err(invalid)?.detach();
        let old_tree = commit
            .parent_ids()
            .next()
            .map(|parent| {
                self.repository
                    .find_object(parent.detach())
                    .map_err(invalid)?
                    .peel_to_commit()
                    .map_err(invalid)?
                    .tree_id()
                    .map_err(invalid)
                    .map(|tree| tree.detach())
            })
            .transpose()?;
        let old = tree_map_by_id(&self.repository, old_tree)?;
        let new = tree_map_by_id(&self.repository, Some(new_tree))?;
        Ok(GitCommitActivity {
            commit: git_log_entry(&commit)?,
            changes: compare_maps(&old, &new),
        })
    }

    fn ahead_behind(
        &self,
        local: gix::hash::ObjectId,
        remote: gix::hash::ObjectId,
    ) -> Result<(usize, usize), GitError> {
        let local_ids = self.ancestor_ids(local)?;
        let remote_ids = self.ancestor_ids(remote)?;
        Ok((
            local_ids.difference(&remote_ids).count(),
            remote_ids.difference(&local_ids).count(),
        ))
    }

    fn ancestor_ids(
        &self,
        id: gix::hash::ObjectId,
    ) -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
        let commit = self
            .repository
            .find_object(id)
            .map_err(invalid)?
            .peel_to_commit()
            .map_err(invalid)?;
        let mut ids = BTreeSet::from([id]);
        for info in commit.ancestors().all().map_err(invalid)? {
            ids.insert(info.map_err(invalid)?.id);
        }
        Ok(ids)
    }

    fn update_remote_tracking(
        &self,
        remote: &str,
        branch: &str,
        id: gix::hash::ObjectId,
    ) -> Result<(), GitError> {
        use gix::refs::{
            Target,
            transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
        };
        let name = gix::refs::FullName::try_from(format!("refs/remotes/{remote}/{branch}"))
            .map_err(invalid)?;
        let edit = RefEdit {
            change: Change::Update {
                log: LogChange {
                    mode: RefLog::AndReference,
                    force_create_reflog: false,
                    message: "push: update remote-tracking branch".into(),
                },
                expected: PreviousValue::Any,
                new: Target::Object(id),
            },
            name,
            deref: false,
        };
        let signature = self.identity.signature();
        let mut time = gix::date::parse::TimeBuf::default();
        self.repository
            .edit_references_as(Some(edit), Some(signature.to_ref(&mut time)))
            .map_err(invalid)?;
        Ok(())
    }

    /// Resolve every conflict in the current fetched merge using explicit
    /// whole-path local/remote choices. No repository state is changed unless
    /// all choices are valid and the merge commit and checkout both succeed.
    pub fn resolve_fetched(
        &self,
        configured: &GitRemote,
        branch: Option<&str>,
        resolutions: &[GitConflictResolution],
    ) -> Result<PullOutcome, GitError> {
        if !self.status()?.is_clean() {
            return Err(GitError::DirtyWorktree);
        }
        let branch = branch.map_or_else(
            || self.current_branch(),
            |branch| {
                validate_remote_name(branch)?;
                Ok(branch.to_owned())
            },
        )?;
        let remote_ref_name = format!("refs/remotes/{}/{branch}", configured.name());
        let remote_id = self
            .repository
            .find_reference(&remote_ref_name)
            .map_err(|_| GitError::RemoteNotFound {
                name: remote_ref_name.clone(),
            })?
            .into_fully_peeled_id()
            .map_err(invalid)?
            .detach();
        let local_id = self
            .repository
            .head_id()
            .map_err(|_| GitError::UnbornHead)?
            .detach();
        let labels = gix::merge::blob::builtin_driver::text::Labels {
            ancestor: Some("base".into()),
            current: Some("HEAD".into()),
            other: Some(configured.name().as_str().into()),
        };
        let options = self
            .repository
            .tree_merge_options()
            .map_err(invalid)?
            .into();
        let mut outcome = self
            .repository
            .merge_commits(local_id, remote_id, labels, options)
            .map_err(invalid)?;
        let unresolved = gix::merge::tree::TreatAsUnresolved::default();
        let choices = resolutions
            .iter()
            .map(|resolution| (resolution.path.clone(), resolution.choice))
            .collect::<BTreeMap<_, _>>();
        let conflicts = outcome
            .tree_merge
            .conflicts
            .iter()
            .filter(|conflict| conflict.is_unresolved(unresolved))
            .collect::<Vec<_>>();
        for conflict in &conflicts {
            let description = conflict_description(conflict);
            let choice = choices.get(&description.path).ok_or_else(|| {
                GitError::InvalidConflictResolution {
                    path: description.path.clone(),
                }
            })?;
            let entries = conflict.entries();
            let selected = match choice {
                GitConflictChoice::Local => entries[1],
                GitConflictChoice::Remote => entries[2],
            };
            let path = path_to_git(&description.path)?;
            let _ = outcome.tree_merge.tree.remove(path.as_bstr());
            if let Some(entry) = selected {
                outcome
                    .tree_merge
                    .tree
                    .upsert(path.as_bstr(), entry.mode.kind(), entry.id)
                    .map_err(invalid)?;
            }
        }
        if choices.len() != conflicts.len() {
            let extra = choices
                .keys()
                .find(|path| {
                    !conflicts
                        .iter()
                        .any(|conflict| conflict_description(conflict).path == **path)
                })
                .cloned()
                .unwrap_or_default();
            return Err(GitError::InvalidConflictResolution { path: extra });
        }
        if conflicts.is_empty() {
            return self.integrate_fetched(configured, Some(&branch));
        }
        let tree = outcome.tree_merge.tree.write().map_err(invalid)?.detach();
        let signature = self.identity.signature();
        let mut committer_time = gix::date::parse::TimeBuf::default();
        let mut author_time = gix::date::parse::TimeBuf::default();
        let commit = self
            .repository
            .new_commit_as(
                signature.to_ref(&mut committer_time),
                signature.to_ref(&mut author_time),
                format!(
                    "Merge remote-tracking branch '{}/{}'.",
                    configured.name(),
                    branch
                ),
                tree,
                [local_id, remote_id],
            )
            .map_err(invalid)?;
        self.checkout_and_update(commit.id, Some(local_id))?;
        Ok(PullOutcome::Merged)
    }

    pub fn current_branch(&self) -> Result<String, GitError> {
        let name = self
            .repository
            .head_name()
            .map_err(invalid)?
            .ok_or_else(|| {
                GitError::InvalidRepository("detached HEAD is unsupported".to_owned())
            })?;
        name.as_bstr()
            .strip_prefix(b"refs/heads/")
            .and_then(|name| name.to_str().ok())
            .map(str::to_owned)
            .ok_or_else(|| {
                GitError::InvalidRepository("HEAD does not name a local branch".to_owned())
            })
    }

    fn checkout_and_update(
        &self,
        commit_id: gix::hash::ObjectId,
        expected: Option<gix::hash::ObjectId>,
    ) -> Result<(), GitError> {
        let target_tree = self
            .repository
            .find_object(commit_id)
            .map_err(invalid)?
            .peel_to_commit()
            .map_err(invalid)?
            .tree_id()
            .map_err(invalid)?
            .detach();
        let old_tree = expected
            .map(|id| {
                self.repository
                    .find_object(id)
                    .map_err(invalid)?
                    .peel_to_commit()
                    .map_err(invalid)?
                    .tree_id()
                    .map_err(invalid)
                    .map(|id| id.detach())
            })
            .transpose()?;
        let previous_index = load_index(&self.repository)?;
        apply_tree_to_worktree(&self.repository, &self.root, old_tree, target_tree)?;
        let mut next_index = self
            .repository
            .index_from_tree(&target_tree)
            .map_err(invalid)?;
        if let Err(error) = next_index.write(Default::default()) {
            if let Some(old_tree) = old_tree {
                let _ = apply_tree_to_worktree(
                    &self.repository,
                    &self.root,
                    Some(target_tree),
                    old_tree,
                );
            }
            return Err(invalid(error));
        }
        let update = self.update_head(commit_id, expected, "pull: update local branch");
        if let Err(error) = update {
            let mut previous_index = previous_index;
            let _ = previous_index.write(Default::default());
            if let Some(old_tree) = old_tree {
                let _ = apply_tree_to_worktree(
                    &self.repository,
                    &self.root,
                    Some(target_tree),
                    old_tree,
                );
            }
            return Err(error);
        }
        Ok(())
    }

    fn update_head(
        &self,
        new: gix::hash::ObjectId,
        expected: Option<gix::hash::ObjectId>,
        message: impl Into<BString>,
    ) -> Result<(), GitError> {
        use gix::refs::{
            Target,
            transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog},
        };

        let name = self
            .repository
            .head_name()
            .map_err(invalid)?
            .ok_or_else(|| GitError::InvalidRepository("detached HEAD".to_owned()))?;
        let expected = expected.map_or(PreviousValue::MustNotExist, |id| {
            PreviousValue::MustExistAndMatch(Target::Object(id))
        });
        let edit = RefEdit {
            change: Change::Update {
                log: LogChange {
                    mode: RefLog::AndReference,
                    force_create_reflog: false,
                    message: message.into(),
                },
                expected,
                new: Target::Object(new),
            },
            name,
            deref: true,
        };
        let signature = self.identity.signature();
        let mut time = gix::date::parse::TimeBuf::default();
        self.repository
            .edit_references_as(Some(edit), Some(signature.to_ref(&mut time)))
            .map_err(invalid)?;
        Ok(())
    }

    fn update_local_config(
        &mut self,
        update: impl FnOnce(&mut gix_config::File) -> Result<(), GitError>,
    ) -> Result<(), GitError> {
        let mut config = load_local_config(&self.repository)?;
        update(&mut config)?;
        validate_local_config_security(&config)?;
        write_local_config(&self.repository, &config)?;
        self.repository = gix::open_opts(&self.root, isolated_options()).map_err(invalid)?;
        apply_in_memory_identity(&mut self.repository, &self.identity)?;
        Ok(())
    }

    pub fn stage(&self, paths: &[PathBuf]) -> Result<(), GitError> {
        if paths.is_empty() {
            return Err(GitError::InvalidPath {
                path: PathBuf::new(),
            });
        }
        let mut selected = Vec::with_capacity(paths.len());
        for path in paths {
            selected.push(validate_relative(path)?);
        }
        let index = load_index(&self.repository)?;
        let mut editor = self
            .repository
            .edit_tree(self.repository.empty_tree().id)
            .map_err(invalid)?;
        let mut tracked = Vec::new();
        for entry in index.entries() {
            let path = entry.path(&index);
            let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| {
                GitError::InvalidRepository("the index contains an invalid entry mode".to_owned())
            })?;
            editor
                .upsert(path, mode.kind(), entry.id)
                .map_err(invalid)?;
            tracked.push(BString::from(path));
        }

        for path in &selected {
            let path_bytes = path_to_git(path)?;
            for tracked_path in &tracked {
                if matches_path(tracked_path.as_slice(), &path_bytes) {
                    editor.remove(tracked_path.as_bstr()).map_err(invalid)?;
                }
            }
            self.add_worktree_path(&mut editor, path)?;
        }
        let tree = editor.write().map_err(invalid)?;
        let mut next = self.repository.index_from_tree(&tree).map_err(invalid)?;
        next.write(Default::default()).map_err(invalid)?;
        Ok(())
    }

    fn add_worktree_path(
        &self,
        editor: &mut gix::object::tree::Editor<'_>,
        path: &Path,
    ) -> Result<(), GitError> {
        let absolute = self.root.join(path);
        let metadata = match fs::symlink_metadata(&absolute) {
            Ok(metadata) => metadata,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
            Err(_) => return Err(io("inspect", path)),
        };
        if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
            return Err(GitError::UnsafeWorktreeObject {
                path: path.to_owned(),
            });
        }
        if metadata.is_file() {
            if metadata.len() > MAX_FILE_BYTES {
                return Err(GitError::FileTooLarge {
                    path: path.to_owned(),
                });
            }
            let contents = fs::read(&absolute).map_err(|_| io("read", path))?;
            let id = self.repository.write_blob(contents).map_err(invalid)?;
            editor
                .upsert(BString::from(path_to_git(path)?), EntryKind::Blob, id)
                .map_err(invalid)?;
            return Ok(());
        }
        let entries = fs::read_dir(&absolute).map_err(|_| io("read directory", path))?;
        let mut children = entries
            .collect::<Result<Vec<_>, _>>()
            .map_err(|_| io("read directory", path))?;
        children.sort_by_key(std::fs::DirEntry::file_name);
        for child in children {
            if child.file_name() == ".git" {
                continue;
            }
            self.add_worktree_path(editor, &path.join(child.file_name()))?;
        }
        Ok(())
    }

    pub fn commit(&self, message: &str) -> Result<String, GitError> {
        let index = load_index(&self.repository)?;
        let tree = tree_from_index(&self.repository, &index)?;
        self.commit_tree(message, tree)
    }

    fn commit_tree(&self, message: &str, tree: gix::hash::ObjectId) -> Result<String, GitError> {
        validate_commit_message(message)?;
        let parent = self.repository.head_id().ok().map(|id| id.detach());
        if let Some(parent) = parent {
            let parent_tree = self
                .repository
                .find_object(parent)
                .map_err(invalid)?
                .peel_to_commit()
                .map_err(invalid)?
                .tree_id()
                .map_err(invalid)?
                .detach();
            if parent_tree == tree {
                return Err(GitError::NoChanges);
            }
        }
        let signature = self.identity.signature();
        let mut committer_time = gix::date::parse::TimeBuf::default();
        let mut author_time = gix::date::parse::TimeBuf::default();
        let parents = parent.into_iter().collect::<Vec<_>>();
        let id = self
            .repository
            .commit_as(
                signature.to_ref(&mut committer_time),
                signature.to_ref(&mut author_time),
                "HEAD",
                message,
                tree,
                parents,
            )
            .map_err(invalid)?;
        Ok(id.to_string())
    }

    pub fn commit_signed(
        &self,
        message: &str,
        keys: &KeyStore,
        signer: &KeyHandle,
        secrets: &mut impl SecretProvider,
    ) -> Result<String, GitError> {
        let index = load_index(&self.repository)?;
        let tree = tree_from_index(&self.repository, &index)?;
        self.commit_tree_signed(message, tree, keys, signer, secrets)
    }

    fn commit_tree_signed(
        &self,
        message: &str,
        tree: gix::hash::ObjectId,
        keys: &KeyStore,
        signer: &KeyHandle,
        secrets: &mut impl SecretProvider,
    ) -> Result<String, GitError> {
        validate_commit_message(message)?;
        let parent = self.repository.head_id().ok().map(|id| id.detach());
        if let Some(parent) = parent {
            let parent_tree = self
                .repository
                .find_object(parent)
                .map_err(invalid)?
                .peel_to_commit()
                .map_err(invalid)?
                .tree_id()
                .map_err(invalid)?
                .detach();
            if parent_tree == tree {
                return Err(GitError::NoChanges);
            }
        }
        let signature = self.identity.signature();
        let mut commit = gix::objs::Commit {
            tree,
            parents: parent.into_iter().collect(),
            author: signature.clone(),
            committer: signature,
            encoding: None,
            message: message.into(),
            extra_headers: Vec::new(),
        };
        let mut unsigned = Vec::new();
        gix::objs::WriteTo::write_to(&commit, &mut unsigned).map_err(invalid)?;
        let detached = keys.sign(&unsigned, signer, secrets).map_err(invalid)?;
        let armor = keys.armor_signature(&detached).map_err(invalid)?;
        commit.extra_headers.push(("gpgsig".into(), armor.into()));
        let id = self.repository.write_object(&commit).map_err(invalid)?;
        self.update_head(id.detach(), parent, format!("commit: {message}"))?;
        Ok(id.to_string())
    }

    pub fn stage_and_commit(
        &self,
        paths: &[PathBuf],
        message: &str,
    ) -> Result<Option<String>, GitError> {
        let (previous, tree) = self.prepare_automatic_commit(paths)?;
        match self.commit_tree(message, tree) {
            Ok(id) => Ok(Some(id)),
            Err(GitError::NoChanges) => {
                restore_index(previous)?;
                Ok(None)
            }
            Err(error) => {
                restore_index(previous)?;
                Err(error)
            }
        }
    }

    pub fn stage_and_commit_signed(
        &self,
        paths: &[PathBuf],
        message: &str,
        keys: &KeyStore,
        signer: &KeyHandle,
        secrets: &mut impl SecretProvider,
    ) -> Result<Option<String>, GitError> {
        let (previous, tree) = self.prepare_automatic_commit(paths)?;
        match self.commit_tree_signed(message, tree, keys, signer, secrets) {
            Ok(id) => Ok(Some(id)),
            Err(GitError::NoChanges) => {
                restore_index(previous)?;
                Ok(None)
            }
            Err(error) => {
                restore_index(previous)?;
                Err(error)
            }
        }
    }

    fn prepare_automatic_commit(
        &self,
        paths: &[PathBuf],
    ) -> Result<(gix::index::File, gix::hash::ObjectId), GitError> {
        let selected = paths
            .iter()
            .map(|path| path_to_git(path))
            .collect::<Result<Vec<_>, _>>()?;
        if selected.is_empty() {
            return Err(GitError::InvalidPath {
                path: PathBuf::new(),
            });
        }
        let previous = load_index(&self.repository)?;
        let prepared = (|| {
            let head_tree = self
                .repository
                .head_commit()
                .ok()
                .map(|commit| commit.tree_id().map(|id| id.detach()))
                .transpose()
                .map_err(invalid)?;
            let base_tree = head_tree.unwrap_or_else(|| self.repository.empty_tree().id);
            let mut base_index = self
                .repository
                .index_from_tree(&base_tree)
                .map_err(invalid)?;
            base_index.write(Default::default()).map_err(invalid)?;

            self.stage(paths)?;
            let selected_index = load_index(&self.repository)?;
            let commit_tree = tree_from_index(&self.repository, &selected_index)?;

            let head = self.head_tree_map()?.unwrap_or_default();
            let prior = index_map(&previous)?;
            let mut editor = self.repository.edit_tree(commit_tree).map_err(invalid)?;
            let paths = head
                .keys()
                .chain(prior.keys())
                .cloned()
                .collect::<BTreeSet<_>>();
            for path in paths {
                if head.get(&path) == prior.get(&path)
                    || selected
                        .iter()
                        .any(|selected| matches_path(path.as_bytes(), selected))
                {
                    continue;
                }
                editor.remove(path.as_bytes().as_bstr()).map_err(invalid)?;
                if let Some(entry) = previous.entry_by_path(path.as_bytes().as_bstr()) {
                    let mode = entry.mode.to_tree_entry_mode().ok_or_else(|| {
                        GitError::InvalidRepository(
                            "the index contains an invalid entry mode".to_owned(),
                        )
                    })?;
                    editor
                        .upsert(path.as_bytes().as_bstr(), mode.kind(), entry.id)
                        .map_err(invalid)?;
                }
            }
            let retained_tree = editor.write().map_err(invalid)?;
            let mut retained_index = self
                .repository
                .index_from_tree(&retained_tree)
                .map_err(invalid)?;
            retained_index.write(Default::default()).map_err(invalid)?;
            Ok(commit_tree)
        })();
        match prepared {
            Ok(tree) => Ok((previous, tree)),
            Err(error) => {
                restore_index(previous)?;
                Err(error)
            }
        }
    }

    pub fn status(&self) -> Result<GitStatus, GitError> {
        let index = load_index(&self.repository)?;
        let index_map = index_map(&index)?;
        let head_map = self.head_tree_map()?.unwrap_or_default();
        let worktree_map = self.worktree_map()?;
        Ok(GitStatus {
            staged: compare_maps(&head_map, &index_map),
            unstaged: compare_maps(&index_map, &worktree_map),
        })
    }

    pub fn log(&self, maximum: Option<usize>) -> Result<Vec<GitLogEntry>, GitError> {
        let head = self
            .repository
            .head_commit()
            .map_err(|_| GitError::UnbornHead)?;
        let mut output = Vec::new();
        for info in head.ancestors().all().map_err(invalid)? {
            if maximum.is_some_and(|maximum| output.len() >= maximum) {
                break;
            }
            let info = info.map_err(invalid)?;
            let commit = info.object().map_err(invalid)?;
            output.push(git_log_entry(&commit)?);
        }
        Ok(output)
    }

    pub fn diff(&self, paths: &[PathBuf]) -> Result<Vec<GitDiffEntry>, GitError> {
        let head = self.head_blob_map()?.unwrap_or_default();
        let worktree = self.worktree_blob_map()?;
        let filter = paths
            .iter()
            .map(|p| validate_relative(p))
            .collect::<Result<Vec<_>, _>>()?;
        let mut names = head
            .keys()
            .chain(worktree.keys())
            .cloned()
            .collect::<BTreeSet<_>>();
        let mut output = Vec::new();
        for name in std::mem::take(&mut names) {
            if !filter.is_empty()
                && !filter.iter().any(|path| {
                    matches_path(name.as_bytes(), &path_to_git(path).unwrap_or_default())
                })
            {
                continue;
            }
            let old = head.get(&name).cloned();
            let new = worktree.get(&name).cloned();
            if old != new {
                output.push(GitDiffEntry {
                    path: PathBuf::from(&name),
                    old,
                    new,
                });
            }
        }
        Ok(output)
    }

    /// Render a helper-free working-tree diff. Password entries are decrypted
    /// in storage before rendering, so ciphertext is never used as display
    /// state and no Git textconv or GPG process is required.
    pub fn render_diff(
        &self,
        paths: &[PathBuf],
        keys: &KeyStore,
        secrets: &mut impl SecretProvider,
    ) -> Result<SecretBytes, GitError> {
        let mut rendered = Vec::new();
        for entry in self.diff(paths)? {
            let display = entry.path().to_string_lossy();
            rendered.extend_from_slice(
                format!("diff --ironstorage a/{display} b/{display}\n").as_bytes(),
            );
            match entry.old() {
                Some(_) => rendered.extend_from_slice(format!("--- a/{display}\n").as_bytes()),
                None => rendered.extend_from_slice(b"--- /dev/null\n"),
            }
            match entry.current() {
                Some(_) => rendered.extend_from_slice(format!("+++ b/{display}\n").as_bytes()),
                None => rendered.extend_from_slice(b"+++ /dev/null\n"),
            }
            if let Some(old) = entry.old() {
                let plaintext = diff_contents(entry.path(), old, keys, secrets)?;
                append_diff_lines(&mut rendered, b'-', plaintext.expose());
            }
            if let Some(current) = entry.current() {
                let plaintext = diff_contents(entry.path(), current, keys, secrets)?;
                append_diff_lines(&mut rendered, b'+', plaintext.expose());
            }
        }
        Ok(SecretBytes::new(rendered))
    }

    fn head_tree_map(&self) -> Result<Option<BTreeMap<String, gix::hash::ObjectId>>, GitError> {
        let Some(id) = self.repository.head_id().ok() else {
            return Ok(None);
        };
        let tree = id
            .object()
            .map_err(invalid)?
            .peel_to_commit()
            .map_err(invalid)?
            .tree()
            .map_err(invalid)?;
        tree_map(tree).map(Some)
    }

    fn head_blob_map(&self) -> Result<Option<BTreeMap<String, Vec<u8>>>, GitError> {
        let Some(ids) = self.head_tree_map()? else {
            return Ok(None);
        };
        let mut output = BTreeMap::new();
        for (path, id) in ids {
            output.insert(
                path,
                self.repository
                    .find_object(id)
                    .map_err(invalid)?
                    .into_blob()
                    .data
                    .clone(),
            );
        }
        Ok(Some(output))
    }

    fn worktree_map(&self) -> Result<BTreeMap<String, gix::hash::ObjectId>, GitError> {
        let mut output = BTreeMap::new();
        for (path, bytes) in self.worktree_blob_map()? {
            output.insert(
                path,
                gix::objs::compute_hash(gix::hash::Kind::Sha1, gix::objs::Kind::Blob, &bytes)
                    .map_err(invalid)?,
            );
        }
        Ok(output)
    }

    fn worktree_blob_map(&self) -> Result<BTreeMap<String, Vec<u8>>, GitError> {
        let mut output = BTreeMap::new();
        collect_worktree(&self.root, Path::new(""), &mut output)?;
        Ok(output)
    }
}

fn diff_contents(
    path: &Path,
    contents: &[u8],
    keys: &KeyStore,
    secrets: &mut impl SecretProvider,
) -> Result<SecretBytes, GitError> {
    if path.extension().is_some_and(|extension| extension == "gpg") {
        keys.decrypt(&EncryptedEntry::new(contents.to_vec()), secrets)
            .map_err(invalid)
    } else {
        Ok(SecretBytes::new(contents.to_vec()))
    }
}

fn conflict_description(conflict: &gix::merge::tree::Conflict) -> GitConflict {
    let entries = conflict.entries();
    let kind = match (
        entries[0].is_some(),
        entries[1].is_some(),
        entries[2].is_some(),
    ) {
        (false, true, true) => GitConflictKind::AddAdd,
        (true, false, true) | (true, true, false) => GitConflictKind::ModifyDelete,
        (true, true, true) if conflict.content_merge().is_some() => GitConflictKind::Content,
        _ => GitConflictKind::Structural,
    };
    let (ours, theirs) = conflict.changes_in_resolution();
    let location = if ours.location().is_empty() {
        theirs.location()
    } else {
        ours.location()
    };
    GitConflict {
        path: PathBuf::from(location.to_str_lossy().as_ref()),
        kind,
    }
}

fn append_diff_lines(output: &mut Vec<u8>, prefix: u8, contents: &[u8]) {
    if contents.is_empty() {
        return;
    }
    for line in contents.split_inclusive(|byte| *byte == b'\n') {
        output.push(prefix);
        output.extend_from_slice(line);
        if !line.ends_with(b"\n") {
            output.push(b'\n');
            output.extend_from_slice(b"\\ No newline at end of file\n");
        }
    }
}

fn git_log_entry(commit: &gix::Commit<'_>) -> Result<GitLogEntry, GitError> {
    let decoded = commit.decode().map_err(invalid)?;
    let author = decoded.author().map_err(invalid)?;
    Ok(GitLogEntry {
        id: commit.id.to_string(),
        parents: decoded.parents().map(|id| id.to_string()).collect(),
        author_name: author.name.to_str_lossy().into_owned(),
        author_email: author.email.to_str_lossy().into_owned(),
        message: decoded.message.to_str_lossy().into_owned(),
        timestamp: author.time().map_err(invalid)?.seconds,
    })
}

impl EntryCommitter for GitRepository {
    fn commit(&mut self, change: &EntryCommit) -> Result<(), EntryCommitError> {
        self.stage_and_commit(&[change.path().encrypted_relative_path()], change.message())
            .map(|_| ())
            .map_err(|error| EntryCommitError::new(error.to_string()))
    }
}

impl PolicyCommitter for GitRepository {
    fn commit(&mut self, change: &PolicyCommit) -> Result<(), PolicyCommitError> {
        self.stage_and_commit(&change.changed_paths(), change.message())
            .map(|_| ())
            .map_err(|error| PolicyCommitError::new(error.to_string()))
    }
}

impl TreeCommitter for GitRepository {
    fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> {
        self.stage_and_commit(change.changed_paths(), change.message())
            .map(|_| ())
            .map_err(|error| TreeCommitError::new(error.to_string()))
    }
}

fn isolated_options() -> gix::open::Options {
    gix::open::Options::isolated().config_overrides([
        format!("init.defaultBranch={DEFAULT_BRANCH}"),
        "core.autocrlf=false".to_owned(),
        "core.symlinks=false".to_owned(),
    ])
}

fn load_index(repository: &gix::Repository) -> Result<gix::index::File, GitError> {
    if repository.index_path().exists() {
        return repository.open_index().map_err(invalid);
    }
    match repository.head_commit() {
        Ok(commit) => {
            let tree = commit.tree_id().map_err(invalid)?;
            repository.index_from_tree(&tree).map_err(invalid)
        }
        Err(_) => Ok(gix::index::File::from_state(
            gix::index::State::new(repository.object_hash()),
            repository.index_path(),
        )),
    }
}

fn tree_from_index(
    repository: &gix::Repository,
    index: &gix::index::File,
) -> Result<gix::hash::ObjectId, GitError> {
    let mut editor = repository
        .edit_tree(repository.empty_tree().id)
        .map_err(invalid)?;
    for entry in index.entries() {
        let mode = entry
            .mode
            .to_tree_entry_mode()
            .ok_or_else(|| GitError::InvalidRepository("invalid index entry mode".to_owned()))?;
        editor
            .upsert(entry.path(index), mode.kind(), entry.id)
            .map_err(invalid)?;
    }
    Ok(editor.write().map_err(invalid)?.detach())
}

fn index_map(index: &gix::index::File) -> Result<BTreeMap<String, gix::hash::ObjectId>, GitError> {
    index
        .entries()
        .iter()
        .map(|entry| {
            let path = entry.path(index).to_str().map_err(|_| {
                GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned())
            })?;
            Ok((path.to_owned(), entry.id))
        })
        .collect()
}

fn tree_map(tree: gix::Tree<'_>) -> Result<BTreeMap<String, gix::hash::ObjectId>, GitError> {
    let mut output = BTreeMap::new();
    let mut recorder = gix::traverse::tree::Recorder::default();
    tree.traverse()
        .breadthfirst(&mut recorder)
        .map_err(invalid)?;
    for record in recorder.records {
        if record.mode.is_blob() {
            let path = record.filepath.to_str().map_err(|_| {
                GitError::InvalidRepository("non-UTF-8 Git paths are unsupported".to_owned())
            })?;
            output.insert(path.to_owned(), record.oid);
        } else if !record.mode.is_tree() {
            return Err(GitError::UnsafeWorktreeObject {
                path: PathBuf::from(record.filepath.to_str_lossy().as_ref()),
            });
        }
    }
    Ok(output)
}

fn tree_map_by_id(
    repository: &gix::Repository,
    tree: Option<gix::hash::ObjectId>,
) -> Result<BTreeMap<String, gix::hash::ObjectId>, GitError> {
    match tree {
        Some(tree) => tree_map(repository.find_object(tree).map_err(invalid)?.into_tree()),
        None => Ok(BTreeMap::new()),
    }
}

fn apply_tree_to_worktree(
    repository: &gix::Repository,
    root: &Path,
    old_tree: Option<gix::hash::ObjectId>,
    new_tree: gix::hash::ObjectId,
) -> Result<(), GitError> {
    let old = tree_map_by_id(repository, old_tree)?;
    let new = tree_map_by_id(repository, Some(new_tree))?;
    let changed = old
        .keys()
        .chain(new.keys())
        .filter(|path| old.get(*path) != new.get(*path))
        .cloned()
        .collect::<BTreeSet<_>>();
    let mut replacement = BTreeMap::new();
    let mut backup = BTreeMap::new();
    for path in &changed {
        let relative = validate_relative(Path::new(path))?;
        let absolute = root.join(&relative);
        backup.insert(
            path.clone(),
            match fs::symlink_metadata(&absolute) {
                Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {
                    Some(fs::read(&absolute).map_err(|_| io("read", &relative))?)
                }
                Ok(_) => return Err(GitError::UnsafeWorktreeObject { path: relative }),
                Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
                Err(_) => return Err(io("inspect", &relative)),
            },
        );
        if let Some(id) = new.get(path) {
            let bytes = repository
                .find_object(*id)
                .map_err(invalid)?
                .into_blob()
                .data
                .clone();
            if bytes.len() as u64 > MAX_FILE_BYTES {
                return Err(GitError::FileTooLarge { path: relative });
            }
            replacement.insert(path.clone(), bytes);
        }
    }
    let apply = (|| {
        for path in &changed {
            let relative = Path::new(path);
            match replacement.get(path) {
                Some(bytes) => atomic_write_worktree(root, relative, bytes)?,
                None => {
                    let absolute = root.join(relative);
                    match fs::remove_file(&absolute) {
                        Ok(()) => cleanup_worktree_parents(root, relative),
                        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
                        Err(_) => return Err(io("remove", relative)),
                    }
                }
            }
        }
        Ok(())
    })();
    if let Err(error) = apply {
        for (path, contents) in backup {
            match contents {
                Some(contents) => {
                    let _ = atomic_write_worktree(root, Path::new(&path), &contents);
                }
                None => {
                    let _ = fs::remove_file(root.join(&path));
                }
            }
        }
        return Err(error);
    }
    Ok(())
}

fn atomic_write_worktree(root: &Path, relative: &Path, contents: &[u8]) -> Result<(), GitError> {
    use std::io::Write as _;

    let relative = validate_relative(relative)?;
    let parent = relative.parent().unwrap_or(Path::new(""));
    ensure_worktree_directory(root, parent)?;
    let absolute = root.join(&relative);
    let file_name = relative.file_name().ok_or_else(|| GitError::InvalidPath {
        path: relative.clone(),
    })?;
    let mut temporary = None;
    for attempt in 0..128_u8 {
        let name = format!(
            ".{}.ironstorage-{}-{attempt}",
            file_name.to_string_lossy(),
            std::process::id()
        );
        let path = root.join(parent).join(name);
        match fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&path)
        {
            Ok(file) => {
                temporary = Some((path, file));
                break;
            }
            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
            Err(_) => return Err(io("create temporary file for", &relative)),
        }
    }
    let (temporary_path, mut file) =
        temporary.ok_or_else(|| io("create temporary file for", &relative))?;
    let result = (|| {
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt as _;
            file.set_permissions(fs::Permissions::from_mode(0o600))
                .map_err(|_| io("set permissions on", &relative))?;
        }
        file.write_all(contents)
            .map_err(|_| io("write", &relative))?;
        file.flush().map_err(|_| io("flush", &relative))?;
        file.sync_all().map_err(|_| io("synchronize", &relative))?;
        drop(file);
        fs::rename(&temporary_path, &absolute).map_err(|_| io("replace", &relative))?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_file(temporary_path);
    }
    result
}

fn ensure_worktree_directory(root: &Path, relative: &Path) -> Result<(), GitError> {
    let mut current = root.to_owned();
    for component in relative.components() {
        let Component::Normal(name) = component else {
            return Err(GitError::InvalidPath {
                path: relative.to_owned(),
            });
        };
        current.push(name);
        match fs::symlink_metadata(&current) {
            Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
            Ok(_) => {
                return Err(GitError::UnsafeWorktreeObject {
                    path: current.strip_prefix(root).unwrap_or(&current).to_owned(),
                });
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                fs::create_dir(&current).map_err(|_| io("create directory", relative))?;
                #[cfg(unix)]
                {
                    use std::os::unix::fs::PermissionsExt as _;
                    fs::set_permissions(&current, fs::Permissions::from_mode(0o700))
                        .map_err(|_| io("set directory permissions", relative))?;
                }
            }
            Err(_) => return Err(io("inspect directory", relative)),
        }
    }
    Ok(())
}

fn cleanup_worktree_parents(root: &Path, relative: &Path) {
    let mut current = root.join(relative).parent().map(Path::to_owned);
    while let Some(directory) = current {
        if directory == root {
            break;
        }
        if fs::remove_dir(&directory).is_err() {
            break;
        }
        current = directory.parent().map(Path::to_owned);
    }
}

struct ReceivePackAdvertisement {
    refs: BTreeMap<String, gix::hash::ObjectId>,
    capabilities: BTreeSet<String>,
}

fn parse_receive_pack_advertisement(input: &[u8]) -> Result<ReceivePackAdvertisement, GitError> {
    let packets = decode_pkt_lines(input)?;
    let mut refs = BTreeMap::new();
    let mut capabilities = BTreeSet::new();
    let mut first_ref = true;
    for packet in packets.into_iter().flatten() {
        let line = packet.strip_suffix(b"\n").unwrap_or(&packet);
        if line.starts_with(b"# service=") || line.is_empty() {
            continue;
        }
        let (reference, caps) = if first_ref {
            match line.split_once_str(b"\0") {
                Some((reference, caps)) => (reference, Some(caps)),
                None => (line, None),
            }
        } else {
            (line, None)
        };
        first_ref = false;
        let Some(space) = reference.find_byte(b' ') else {
            return Err(GitError::InvalidRepository(
                "malformed receive-pack advertisement".to_owned(),
            ));
        };
        let id = &reference[..space];
        let name = &reference[space + 1..];
        if id == b"0000000000000000000000000000000000000000" && name == b"capabilities^{}" {
            // Empty repositories advertise capabilities in a pseudo-ref.
        } else {
            let id = gix::hash::ObjectId::from_hex(id).map_err(invalid)?;
            let name =
                std::str::from_utf8(name).map_err(|_| invalid("non-UTF-8 reference name"))?;
            if !name.ends_with("^{}") {
                refs.insert(name.to_owned(), id);
            }
        }
        if let Some(caps) = caps {
            capabilities.extend(
                caps.split(|byte| *byte == b' ')
                    .filter(|capability| !capability.is_empty())
                    .map(|capability| {
                        String::from_utf8_lossy(capability)
                            .split('=')
                            .next()
                            .unwrap_or_default()
                            .to_owned()
                    }),
            );
        }
    }
    if capabilities.is_empty() {
        return Err(GitError::InvalidRepository(
            "receive-pack advertisement has no capabilities".to_owned(),
        ));
    }
    Ok(ReceivePackAdvertisement { refs, capabilities })
}

fn parse_receive_pack_result(input: &[u8], reference: &str) -> Result<(), GitError> {
    let packets = decode_pkt_lines(input)?;
    let mut unpacked = false;
    let mut updated = false;
    for packet in packets.into_iter().flatten() {
        let line = packet.strip_prefix(b"\x01").unwrap_or(&packet);
        for line in line
            .split(|byte| *byte == b'\n')
            .filter(|line| !line.is_empty())
        {
            if line == b"unpack ok" {
                unpacked = true;
            } else if let Some(reason) = line.strip_prefix(b"unpack ") {
                return Err(GitError::InvalidRepository(format!(
                    "remote could not unpack objects: {}",
                    String::from_utf8_lossy(reason)
                )));
            } else if line == format!("ok {reference}").as_bytes() {
                updated = true;
            } else if let Some(reason) = line.strip_prefix(format!("ng {reference} ").as_bytes()) {
                let reason = String::from_utf8_lossy(reason);
                return if reason.to_ascii_lowercase().contains("non-fast-forward") {
                    Err(GitError::NonFastForward)
                } else {
                    Err(GitError::InvalidRepository(format!(
                        "remote rejected update: {reason}"
                    )))
                };
            }
        }
    }
    if !unpacked || !updated {
        return Err(GitError::InvalidRepository(
            "receive-pack response omitted update status".to_owned(),
        ));
    }
    Ok(())
}

fn decode_pkt_lines(input: &[u8]) -> Result<Vec<Option<Vec<u8>>>, GitError> {
    if input.len() > 128 * 1024 * 1024 {
        return Err(GitError::InvalidRepository(
            "Git protocol response is too large".to_owned(),
        ));
    }
    let mut cursor = 0;
    let mut packets = Vec::new();
    while cursor < input.len() {
        if input.len() - cursor < 4 {
            return Err(GitError::InvalidRepository(
                "truncated Git packet line".to_owned(),
            ));
        }
        let length = std::str::from_utf8(&input[cursor..cursor + 4])
            .ok()
            .and_then(|value| usize::from_str_radix(value, 16).ok())
            .ok_or_else(|| GitError::InvalidRepository("invalid Git packet length".to_owned()))?;
        cursor += 4;
        if length == 0 || length == 1 || length == 2 {
            packets.push(None);
            continue;
        }
        if !(4..=65_520).contains(&length) || cursor + length - 4 > input.len() {
            return Err(GitError::InvalidRepository(
                "invalid Git packet line".to_owned(),
            ));
        }
        let end = cursor + length - 4;
        packets.push(Some(input[cursor..end].to_vec()));
        cursor = end;
        if packets.len() > 1_000_000 {
            return Err(GitError::InvalidRepository(
                "too many Git packet lines".to_owned(),
            ));
        }
    }
    Ok(packets)
}

fn encode_pkt_line(data: &[u8]) -> Result<Vec<u8>, GitError> {
    let length = data
        .len()
        .checked_add(4)
        .filter(|length| *length <= 65_520)
        .ok_or_else(|| GitError::InvalidRepository("Git packet line is too large".to_owned()))?;
    let mut output = format!("{length:04x}").into_bytes();
    output.extend_from_slice(data);
    Ok(output)
}

fn build_pack(
    repository: &gix::Repository,
    tip: gix::hash::ObjectId,
    remote_tip: Option<gix::hash::ObjectId>,
) -> Result<Vec<u8>, GitError> {
    let excluded = match remote_tip {
        Some(id) => reachable_objects(repository, id, &BTreeSet::new())?,
        None => BTreeSet::new(),
    };
    let objects = reachable_objects(repository, tip, &excluded)?;
    let count =
        u32::try_from(objects.len()).map_err(|_| invalid("too many objects for a Git pack"))?;
    let mut pack = Vec::new();
    pack.extend_from_slice(b"PACK");
    pack.extend_from_slice(&2_u32.to_be_bytes());
    pack.extend_from_slice(&count.to_be_bytes());
    for id in objects {
        let object = repository.find_object(id).map_err(invalid)?;
        encode_pack_object_header(&mut pack, object.kind, object.data.len())?;
        let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
        use std::io::Write as _;
        encoder.write_all(&object.data).map_err(invalid)?;
        pack.extend_from_slice(&encoder.finish().map_err(invalid)?);
    }
    let checksum = Sha1::digest(&pack);
    pack.extend_from_slice(&checksum);
    Ok(pack)
}

fn reachable_objects(
    repository: &gix::Repository,
    tip: gix::hash::ObjectId,
    excluded: &BTreeSet<gix::hash::ObjectId>,
) -> Result<BTreeSet<gix::hash::ObjectId>, GitError> {
    let mut pending = vec![tip];
    let mut found = BTreeSet::new();
    while let Some(id) = pending.pop() {
        if excluded.contains(&id) || !found.insert(id) {
            continue;
        }
        let object = repository.find_object(id).map_err(invalid)?;
        match object.kind {
            gix::objs::Kind::Commit => {
                let commit = object.into_commit();
                pending.push(commit.tree_id().map_err(invalid)?.detach());
                pending.extend(commit.parent_ids().map(|id| id.detach()));
            }
            gix::objs::Kind::Tree => {
                pending.extend(
                    object
                        .into_tree()
                        .decode()
                        .map_err(invalid)?
                        .entries
                        .into_iter()
                        .map(|entry| entry.oid.to_owned()),
                );
            }
            gix::objs::Kind::Tag => {
                pending.push(object.into_tag().target_id().map_err(invalid)?.detach())
            }
            gix::objs::Kind::Blob => {}
        }
        if found.len() > 10_000_000 {
            return Err(GitError::InvalidRepository(
                "repository has too many reachable objects".to_owned(),
            ));
        }
    }
    Ok(found)
}

fn encode_pack_object_header(
    output: &mut Vec<u8>,
    kind: gix::objs::Kind,
    size: usize,
) -> Result<(), GitError> {
    let object_type = match kind {
        gix::objs::Kind::Commit => 1_u8,
        gix::objs::Kind::Tree => 2,
        gix::objs::Kind::Blob => 3,
        gix::objs::Kind::Tag => 4,
    };
    let mut remaining = u64::try_from(size).map_err(|_| invalid("Git object is too large"))?;
    let mut byte = (object_type << 4) | (remaining as u8 & 0x0f);
    remaining >>= 4;
    if remaining != 0 {
        byte |= 0x80;
    }
    output.push(byte);
    while remaining != 0 {
        let mut byte = remaining as u8 & 0x7f;
        remaining >>= 7;
        if remaining != 0 {
            byte |= 0x80;
        }
        output.push(byte);
    }
    Ok(())
}

fn compare_maps(
    before: &BTreeMap<String, gix::hash::ObjectId>,
    after: &BTreeMap<String, gix::hash::ObjectId>,
) -> Vec<GitChange> {
    before
        .keys()
        .chain(after.keys())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .filter_map(|path| {
            let kind = match (before.get(path), after.get(path)) {
                (None, Some(_)) => GitChangeKind::Added,
                (Some(_), None) => GitChangeKind::Deleted,
                (Some(old), Some(new)) if old != new => GitChangeKind::Modified,
                _ => return None,
            };
            Some(GitChange {
                path: PathBuf::from(path),
                kind,
            })
        })
        .collect()
}

fn collect_worktree(
    root: &Path,
    relative: &Path,
    output: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), GitError> {
    let absolute = root.join(relative);
    let mut entries = fs::read_dir(&absolute)
        .map_err(|_| io("read directory", relative))?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|_| io("read directory", relative))?;
    entries.sort_by_key(std::fs::DirEntry::file_name);
    for entry in entries {
        if relative.as_os_str().is_empty() && entry.file_name() == ".git" {
            continue;
        }
        let path = relative.join(entry.file_name());
        let metadata = fs::symlink_metadata(entry.path()).map_err(|_| io("inspect", &path))?;
        if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
            return Err(GitError::UnsafeWorktreeObject { path });
        }
        if metadata.is_dir() {
            if entry.path().join(".git").exists() {
                continue;
            }
            collect_worktree(root, &path, output)?;
        } else {
            if metadata.len() > MAX_FILE_BYTES {
                return Err(GitError::FileTooLarge { path });
            }
            let name = path_to_git(&path)?;
            let name = std::str::from_utf8(&name)
                .map_err(|_| GitError::InvalidPath { path: path.clone() })?
                .to_owned();
            output.insert(name, fs::read(entry.path()).map_err(|_| io("read", &path))?);
        }
    }
    Ok(())
}

fn validate_relative(path: &Path) -> Result<PathBuf, GitError> {
    if path.as_os_str().is_empty() || path.is_absolute() {
        return Err(GitError::InvalidPath {
            path: path.to_owned(),
        });
    }
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Normal(name) if name != ".git" => normalized.push(name),
            _ => {
                return Err(GitError::InvalidPath {
                    path: path.to_owned(),
                });
            }
        }
    }
    Ok(normalized)
}

fn validate_remote_name(name: &str) -> Result<(), GitError> {
    if name.is_empty()
        || name.starts_with('-')
        || name.contains(['\0', '\n', '\r', ' ', '\\', '~', '^', ':', '?', '*', '['])
        || name.contains("..")
        || name.contains("@{")
        || name.ends_with('.')
        || name.ends_with('/')
        || name.starts_with('/')
        || name
            .split('/')
            .any(|part| part.is_empty() || part.ends_with(".lock"))
    {
        return Err(GitError::InvalidRemoteName);
    }
    Ok(())
}

fn ensure_clone_parent(parent: &Path) -> Result<(), GitError> {
    let metadata = fs::symlink_metadata(parent).map_err(|_| io("inspect clone parent", parent))?;
    if !metadata.is_dir() || metadata.file_type().is_symlink() {
        return Err(GitError::UnsafeWorktreeObject {
            path: parent.to_owned(),
        });
    }
    Ok(())
}

fn private_temporary_directory(parent: &Path, purpose: &str) -> Result<PathBuf, GitError> {
    (0..128_u8)
        .find_map(|attempt| {
            let name = format!(".ironstorage-{purpose}-{}-{attempt}", rand::random::<u64>());
            let candidate = parent.join(name);
            match fs::create_dir(&candidate) {
                Ok(()) => {
                    #[cfg(unix)]
                    {
                        use std::os::unix::fs::PermissionsExt as _;
                        if fs::set_permissions(&candidate, fs::Permissions::from_mode(0o700))
                            .is_err()
                        {
                            let _ = fs::remove_dir(&candidate);
                            return Some(Err(io("secure private Git directory", parent)));
                        }
                    }
                    Some(Ok(candidate))
                }
                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => None,
                Err(_) => Some(Err(io("create private Git directory", parent))),
            }
        })
        .transpose()?
        .ok_or_else(|| io("create private Git directory", parent))
}

fn validate_https_remote(value: &str) -> Result<url::Url, GitError> {
    let parsed = url::Url::parse(value).map_err(|_| GitError::ForbiddenRemoteUrl)?;
    if parsed.scheme() != "https"
        || parsed.host_str().is_none()
        || !parsed.username().is_empty()
        || parsed.password().is_some()
        || parsed.query().is_some()
        || parsed.fragment().is_some()
    {
        return Err(GitError::ForbiddenRemoteUrl);
    }
    Ok(parsed)
}

fn same_https_origin(expected: &url::Url, requested: &url::Url) -> bool {
    expected.scheme() == "https"
        && requested.scheme() == "https"
        && expected.host_str() == requested.host_str()
        && expected.port_or_known_default() == requested.port_or_known_default()
}

fn validate_config_key(key: &str) -> Result<(), GitError> {
    let mut parts = key.split('.');
    let section = parts.next().unwrap_or_default();
    let rest = parts.collect::<Vec<_>>();
    if section.is_empty()
        || rest.is_empty()
        || key.contains(['\0', '\n', '\r', ' ', '='])
        || key.starts_with('.')
        || key.ends_with('.')
        || rest.iter().any(|part| part.is_empty())
    {
        return Err(GitError::InvalidRepository(
            "invalid Git configuration key".to_owned(),
        ));
    }
    // These keys can make otherwise innocent library operations execute a
    // program or read configuration outside the repository. They are never
    // meaningful to an IronStorage frontend.
    let lower = key.to_ascii_lowercase();
    let section = lower.split('.').next().unwrap_or_default();
    if lower == "core.hookspath"
        || lower == "core.sshcommand"
        || lower == "core.gitproxy"
        || lower == "core.fsmonitor"
        || lower.ends_with(".pushurl")
        || matches!(
            section,
            "include"
                | "includeif"
                | "credential"
                | "filter"
                | "diff"
                | "merge"
                | "protocol"
                | "url"
        )
    {
        return Err(GitError::InvalidRepository(
            "unsafe Git configuration key".to_owned(),
        ));
    }
    Ok(())
}

fn validate_local_config_security(config: &gix_config::File) -> Result<(), GitError> {
    for section in config.sections() {
        let name = section.header().name().to_str_lossy().to_ascii_lowercase();
        if matches!(
            name.as_str(),
            "include" | "includeif" | "credential" | "filter" | "merge" | "protocol" | "url"
        ) {
            return Err(GitError::InvalidRepository(format!(
                "unsafe Git configuration section: {name}"
            )));
        }
    }
    // Upstream `pass git init` installs diff.gpg.binary and
    // diff.gpg.textconv. IronStorage parses blobs and renders diffs itself, so
    // these passive settings are preserved but never evaluated or executed.
    for key in [
        "core.hooksPath",
        "core.sshCommand",
        "core.gitProxy",
        "core.fsmonitor",
    ] {
        if config.raw_value(key).is_ok() {
            return Err(GitError::InvalidRepository(format!(
                "unsafe Git configuration key: {key}"
            )));
        }
    }
    if let Some(remotes) = config.sections_by_name("remote") {
        for remote in remotes {
            let name = remote
                .header()
                .subsection_name()
                .ok_or(GitError::InvalidRemoteName)?
                .to_str()
                .map_err(|_| GitError::InvalidRemoteName)?;
            validate_remote_name(name)?;
            if config
                .raw_value_by("remote", Some(name.into()), "pushurl")
                .is_ok()
            {
                return Err(GitError::InvalidRepository(
                    "separate Git push URLs are unsupported".to_owned(),
                ));
            }
            if let Ok(url) = config.raw_value_by("remote", Some(name.into()), "url") {
                validate_https_remote(url.to_str().map_err(|_| GitError::ForbiddenRemoteUrl)?)?;
            }
        }
    }
    Ok(())
}

fn load_local_config(repository: &gix::Repository) -> Result<gix_config::File, GitError> {
    gix_config::File::from_path_no_includes(
        repository.git_dir().join("config"),
        gix_config::Source::Local,
    )
    .map_err(invalid)
}

fn write_local_config(
    repository: &gix::Repository,
    config: &gix_config::File,
) -> Result<(), GitError> {
    use std::io::Write as _;

    let path = repository.git_dir().join("config");
    let lock_path = repository.git_dir().join("config.lock");
    let mut lock = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(&lock_path)
        .map_err(|_| io("lock", &lock_path))?;
    let result = (|| {
        config.write_to(&mut lock).map_err(invalid)?;
        lock.flush().map_err(|_| io("flush", &lock_path))?;
        lock.sync_all().map_err(|_| io("synchronize", &lock_path))?;
        fs::rename(&lock_path, &path).map_err(|_| io("replace", &path))?;
        Ok(())
    })();
    if result.is_err() {
        let _ = fs::remove_file(&lock_path);
    }
    result
}

fn path_to_git(path: &Path) -> Result<Vec<u8>, GitError> {
    let normalized = validate_relative(path)?;
    let text = normalized.to_str().ok_or_else(|| GitError::InvalidPath {
        path: path.to_owned(),
    })?;
    Ok(text.replace(std::path::MAIN_SEPARATOR, "/").into_bytes())
}

fn matches_path(candidate: &[u8], selected: &[u8]) -> bool {
    candidate == selected
        || candidate
            .strip_prefix(selected)
            .is_some_and(|suffix| suffix.starts_with(b"/"))
}

fn validate_commit_message(message: &str) -> Result<(), GitError> {
    if message.trim().is_empty() || message.contains('\0') {
        return Err(GitError::InvalidRepository(
            "commit messages must not be empty".to_owned(),
        ));
    }
    Ok(())
}

fn restore_index(mut index: gix::index::File) -> Result<(), GitError> {
    index.write(Default::default()).map_err(invalid)
}

fn invalid(error: impl fmt::Display) -> GitError {
    GitError::InvalidRepository(error.to_string())
}

fn apply_in_memory_identity(
    repository: &mut gix::Repository,
    identity: &GitIdentity,
) -> Result<(), GitError> {
    let mut config = repository.config_snapshot_mut();
    config
        .set_raw_value("user.name", identity.name())
        .map_err(invalid)?;
    config
        .set_raw_value("user.email", identity.email())
        .map_err(invalid)?;
    drop(config);
    if repository.committer().is_none() {
        return Err(GitError::InvalidIdentity);
    }
    Ok(())
}

fn io(operation: &'static str, path: &Path) -> GitError {
    GitError::Io {
        operation,
        path: path.to_owned(),
    }
}

#[cfg(test)]
mod tests {
    use super::{GitIdentity, GitRepository};
    use crate::repository::Repository;

    #[test]
    fn embedded_identity_survives_local_config_updates() {
        let temporary = tempfile::tempdir().expect("temporary repository");
        let store = Repository::open(temporary.path()).expect("store");
        let mut repository =
            GitRepository::init(&store, GitIdentity::ironstorage()).expect("initialize Git");
        repository
            .add_remote("origin", "https://example.test/password-store.git")
            .expect("add remote");
        assert!(repository.repository.committer().is_some());
    }
}
