git-next-core 0.14.1

core for git-next, the trunk-based development manager
Documentation
//
use crate::{
    git::{
        self,
        repository::open::{oreal::RealOpenRepository, OpenRepositoryLike},
        Generation,
    },
    pike, s, BranchName, ForgeAlias, ForgeConfig, ForgeDetails, GitDir, Hostname, RemoteUrl,
    RepoAlias, RepoConfig, RepoPath, ServerRepoConfig, StoragePathType,
};

use std::sync::{Arc, RwLock};

use secrecy::{ExposeSecret, SecretString};
use tracing::instrument;

/// The derived information about a repo, used to interact with it
#[derive(Clone, Debug, derive_more::Display, derive_with::With)]
#[display("gen-{}:{}:{}/{}", generation, forge.forge_type(), forge.forge_alias(), repo_alias )]
pub struct RepoDetails {
    pub generation: Generation,
    pub repo_alias: RepoAlias,
    pub repo_path: RepoPath,
    pub branch: BranchName,
    pub forge: ForgeDetails,
    pub repo_config: Option<RepoConfig>,
    pub gitdir: GitDir,
}
impl RepoDetails {
    #[must_use]
    pub fn new(
        generation: Generation,
        repo_alias: &RepoAlias,
        server_repo_config: &ServerRepoConfig,
        forge_alias: &ForgeAlias,
        forge_config: &ForgeConfig,
        gitdir: GitDir,
    ) -> Self {
        Self {
            generation,
            repo_alias: repo_alias.clone(),
            repo_path: server_repo_config.repo(),
            repo_config: server_repo_config.repo_config(),
            branch: server_repo_config.branch(),
            gitdir,
            forge: ForgeDetails::new(
                forge_alias.clone(),
                forge_config.forge_type(),
                forge_config.hostname(),
                forge_config.user(),
                forge_config.token(),
                forge_config.max_dev_commits(),
            ),
        }
    }
    pub(crate) fn origin(&self) -> secrecy::SecretString {
        let repo_details = self;
        let user = &repo_details.forge.user();
        let hostname = &repo_details.forge.hostname();

        let repo_path = &repo_details.repo_path;
        let expose_secret = repo_details.forge.token();

        let token = expose_secret.expose_secret();
        let origin = format!("https://{user}:{token}@{hostname}/{repo_path}.git");
        origin.into()
    }

    pub(crate) const fn gitdir(&self) -> &GitDir {
        &self.gitdir
    }

    #[must_use]
    pub fn with_hostname(mut self, hostname: Hostname) -> Self {
        let forge = self.forge;
        self.forge = forge.with_hostname(hostname);
        self
    }

    // url is a secret as it contains auth token
    pub(crate) fn url(&self) -> SecretString {
        let user = self.forge.user();
        let token = self.forge.token().expose_secret();
        let auth_delim = if token.is_empty() { "" } else { ":" };
        let hostname = self.forge.hostname();
        let repo_path = &self.repo_path;
        format!("https://{user}{auth_delim}{token}@{hostname}/{repo_path}.git").into()
    }

    #[allow(clippy::result_large_err)]
    pub(crate) fn open(&self) -> Result<impl OpenRepositoryLike, git::validation::remotes::Error> {
        let gix_repo = pike! {
            self
            |> Self::gitdir
            |> GitDir::pathbuf
            |> gix::ThreadSafeRepository::open
        }?;
        let repo = RealOpenRepository::new(Arc::new(RwLock::new(gix_repo)), self.forge.clone());
        Ok(repo)
    }

    #[must_use]
    pub fn remote_url(&self) -> Option<RemoteUrl> {
        use secrecy::ExposeSecret;
        RemoteUrl::parse(self.url().expose_secret())
    }

    #[instrument]
    pub fn assert_remote_url(&self, found: Option<RemoteUrl>) -> git::repository::Result<()> {
        let Some(found) = found else {
            tracing::debug!("No remote url found to assert");
            return Ok(());
        };
        let Some(expected) = self.remote_url() else {
            tracing::debug!("No remote url to assert against");
            return Ok(());
        };
        if !found.matches(&expected) {
            tracing::debug!(?found, ?expected, "urls differ");
            match self.gitdir.storage_path_type() {
                StoragePathType::External => {
                    tracing::debug!("Refusing to update an external repo - user must resolve this");
                    return Err(git::repository::Error::MismatchDefaultFetchRemote {
                        found: Box::new(found),
                        expected: Box::new(expected),
                    });
                }
                StoragePathType::Internal => {
                    tracing::debug!(?expected, "Need to update config with new url");
                    self.write_remote_url(&expected)?;
                }
            }
        }
        Ok(())
    }

    #[tracing::instrument]
    pub fn write_remote_url(&self, url: &RemoteUrl) -> Result<(), kxio::fs::Error> {
        if self.gitdir.storage_path_type() != StoragePathType::Internal {
            return Ok(());
        }
        let fs = self.gitdir.as_fs();
        // load config file
        let config_filename = &self.gitdir.join("config");
        let file = fs.file(config_filename);
        let config_file = file.reader()?;
        let mut config_lines = config_file
            .lines()?
            .map(ToOwned::to_owned)
            .collect::<Vec<_>>();
        tracing::debug!(?config_lines, "original file");
        let url_line = format!(r#"   url = "{url}""#);
        if config_lines
            .iter()
            .any(|line| line == r#"[remote "origin"]"#)
        {
            tracing::debug!("has an 'origin' remote");
            config_lines
                .iter_mut()
                .filter(|line| line.starts_with(r"   url = "))
                .for_each(|line| line.clone_from(&url_line));
        } else {
            tracing::debug!("has no 'origin' remote");
            config_lines.push(s!(r#"[remote "origin"]"#));
            config_lines.push(url_line);
        }
        tracing::debug!(?config_lines, "updated file");
        // write config file back out
        file.write(config_lines.join("\n"))?;
        Ok(())
    }
}