git-next-core 0.14.1

core for git-next, the trunk-based development manager
Documentation
//
use crate::{
    git::{self, repository::OpenRepositoryLike},
    s, BranchName, ForgeDetails, Hostname, RemoteUrl, RepoPath,
};

use derive_more::Constructor;
use gix::bstr::BStr;
use tracing::{info, warn};

use std::{
    borrow::ToOwned,
    path::Path,
    result::Result,
    sync::{Arc, RwLock},
};

#[derive(Clone, Debug, Constructor)]
pub struct RealOpenRepository {
    inner: Arc<RwLock<gix::ThreadSafeRepository>>,
    forge_details: ForgeDetails,
}
impl super::OpenRepositoryLike for RealOpenRepository {
    fn remote_branches(&self) -> git::push::Result<Vec<BranchName>> {
        let refs = self
            .inner
            .read()
            .map_err(|_| git::push::Error::Lock)
            .and_then(|repo| {
                Ok(repo.to_thread_local().references()?).and_then(|refs| {
                    Ok(refs.remote_branches().map(|rb| {
                        rb.filter_map(Result::ok)
                            .map(|r| r.name().to_owned())
                            .map(|n| s!(n))
                            .filter_map(|p| {
                                p.strip_prefix("refs/remotes/origin/")
                                    .map(ToOwned::to_owned)
                            })
                            .filter(|b| b.as_str() != "HEAD")
                            .map(BranchName::new)
                            .collect::<Vec<_>>()
                    })?)
                })
            })?;
        Ok(refs)
    }

    #[tracing::instrument]
    fn find_default_remote(&self, direction: git::repository::Direction) -> Option<RemoteUrl> {
        let Ok(repository) = self.inner.read() else {
            #[cfg(not(tarpaulin_include))] // don't test mutex lock failure
            tracing::debug!("no repository");
            return None;
        };
        let thread_local = repository.to_thread_local();
        let Some(Ok(remote)) = thread_local.find_default_remote(direction.into()) else {
            #[cfg(not(tarpaulin_include))] // test is on local repo - should always have remotes
            tracing::debug!("no remote");
            return None;
        };
        remote
            .url(direction.into())
            .cloned()
            .and_then(|url| RemoteUrl::try_from(url).ok())
    }

    #[tracing::instrument(skip_all)]
    #[cfg(not(tarpaulin_include))] // would require writing to external service
    fn fetch(&self) -> Result<(), git::fetch::Error> {
        if self
            .find_default_remote(git::repository::Direction::Fetch)
            .is_none()
        {
            return Err(git::fetch::Error::NoFetchRemoteFound);
        }
        info!("Fetching");
        gix::command::prepare("/usr/bin/git fetch --prune")
            .with_context(gix::diff::command::Context {
                git_dir: Some(
                    self.inner
                        .read()
                        .map_err(|_| git::fetch::Error::Lock)
                        .map(|r| r.git_dir().to_path_buf())?,
                ),
                ..Default::default()
            })
            .with_shell_allow_manual_argument_splitting()
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()?
            .wait()?;
        info!("Fetch okay");
        Ok(())
    }

    #[cfg(not(tarpaulin_include))] // would require writing to external service
    #[tracing::instrument(skip_all)]
    fn push(
        &self,
        repo_details: &git::RepoDetails,
        branch_name: &BranchName,
        to_commit: &git::GitRef,
        force: &git::push::Force,
    ) -> Result<(), git::push::Error> {
        use secrecy::ExposeSecret as _;

        let origin = repo_details.origin();
        let force = match force {
            git::push::Force::No => String::new(),
            git::push::Force::From(old_ref) => {
                format!("--force-with-lease={branch_name}:{old_ref}")
            }
        };
        // INFO: never log the command as it contains the API token within the 'origin'
        let command: secrecy::SecretString = format!(
            "/usr/bin/git push {} {to_commit}:{branch_name} {force}",
            origin.expose_secret()
        )
        .into();
        let git_dir = self
            .inner
            .read()
            .map_err(|_| git::push::Error::Lock)
            .map(|r| r.git_dir().to_path_buf())?;
        let ctx = gix::diff::command::Context {
            git_dir: Some(git_dir),
            ..Default::default()
        };
        gix::command::prepare(command.expose_secret())
            .with_context(ctx)
            .with_shell_allow_manual_argument_splitting()
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()?
            .wait()?;
        Ok(())
    }

    fn commit_log(
        &self,
        branch_name: &BranchName,
        find_commits: &[git::Commit],
    ) -> Result<Vec<git::Commit>, git::commit::log::Error> {
        let limit: usize = if find_commits.is_empty() {
            1
        } else {
            self.forge_details
                .max_dev_commits()
                .map_or(25, |commit_count| commit_count.clone().peel() as usize)
        };
        self.inner
            .read()
            .map_err(|_| git::commit::log::Error::Lock)
            .map(|repo| {
                let branch = format!("remotes/origin/{branch_name}");
                let branch = BStr::new(&branch);
                let thread_local = repo.to_thread_local();
                let branch_head = thread_local
                    .rev_parse_single(branch)
                    .map_err(|e| s!(e))
                    .map_err(as_gix_error(branch_name.clone()))?;
                let object = branch_head
                    .object()
                    .map_err(|e| s!(e))
                    .map_err(as_gix_error(branch_name.clone()))?;
                let commit = object
                    .try_into_commit()
                    .map_err(|e| s!(e))
                    .map_err(as_gix_error(branch_name.clone()))?;
                let walk = thread_local
                    .rev_walk([commit.id])
                    .all()
                    .map_err(|e| s!(e))
                    .map_err(as_gix_error(branch_name.clone()))?;
                let mut commits = vec![];
                for item in walk.take(limit) {
                    let item = item
                        .map_err(|e| s!(e))
                        .map_err(as_gix_error(branch_name.clone()))?;
                    let commit = item
                        .object()
                        .map_err(|e| s!(e))
                        .map_err(as_gix_error(branch_name.clone()))?;
                    let id = commit.id();
                    let message = commit
                        .message_raw()
                        .map_err(|e| s!(e))
                        .map_err(as_gix_error(branch_name.clone()))?;
                    let commit = git::Commit::new(
                        git::commit::Sha::new(s!(id)),
                        git::commit::Message::new(s!(message)),
                    );
                    if find_commits.contains(&commit) {
                        commits.push(commit);
                        break;
                    }
                    commits.push(commit);
                }
                Ok(commits)
            })?
    }

    #[tracing::instrument(skip_all, fields(%branch_name, ?file_name))]
    fn read_file(&self, branch_name: &BranchName, file_name: &Path) -> git::file::Result<String> {
        self.inner
            .read()
            .map_err(|_| git::file::Error::Lock)
            .and_then(|repo| {
                let thread_local = repo.to_thread_local();
                let fref = thread_local.find_reference(format!("origin/{branch_name}").as_str())?;
                let id = fref.try_id().ok_or(git::file::Error::TryId)?;
                let oid = id.detach();
                let obj = thread_local.find_object(oid)?;
                let commit = obj.into_commit();
                let tree = commit.tree()?;
                let ent = tree
                    .find_entry(s!(file_name.to_string_lossy()))
                    .ok_or(git::file::Error::FileNotFound)?;
                let fobj = ent.object()?;
                let blob = fobj.into_blob().take_data();
                let content = String::from_utf8(blob)?;
                Ok(content)
            })
    }

    fn duplicate(&self) -> Box<dyn OpenRepositoryLike> {
        Box::new(self.clone())
    }
}

fn as_gix_error(branch: BranchName) -> impl FnOnce(String) -> git::commit::log::Error {
    |error| git::commit::log::Error::Gix { branch, error }
}

impl From<&RemoteUrl> for git::GitRemote {
    fn from(url: &RemoteUrl) -> Self {
        let host = url.host.clone().unwrap_or_default();
        let path = s!(url.path);
        let path = path.strip_prefix('/').map_or(path.as_str(), |path| path);
        let path = path.strip_suffix(".git").map_or(path, |path| path);
        Self::new(Hostname::new(host), RepoPath::new(s!(path)))
    }
}