regops 0.2.2

GitOps agent for localhost configuration management
use gix::{Repository, Url};
use std::io::Write;
use std::path::Path;
use tracing::info;

use crate::config::GitAuthentication;

fn token_credentials(
    token: &str,
) -> impl FnMut(gix::credentials::helper::Action) -> gix::credentials::protocol::Result + 'static {
    let token = token.to_owned();

    move |action| match action {
        gix::credentials::helper::Action::Get(context) => {
            Ok(Some(gix::credentials::protocol::Outcome {
                identity: gix::sec::identity::Account {
                    username: "oauth2".to_string(),
                    password: token.clone(),
                    oauth_refresh_token: None,
                },
                next: context.into(),
            }))
        }
        gix::credentials::helper::Action::Store(_) | gix::credentials::helper::Action::Erase(_) => {
            Ok(None)
        }
    }
}

pub fn git_clone(
    repository_url: Url,
    local_path: &str,
    branch: &str,
    auth: &GitAuthentication,
) -> Result<Repository, String> {
    std::fs::create_dir_all(local_path).map_err(|details| format!("{}", details))?;

    let local_path = Path::new(local_path);
    let ref_name = format!("refs/heads/{}", branch);

    let mut prepare_clone = gix::prepare_clone(repository_url, local_path)
        .map_err(|details| format!("{}", details))?
        .with_ref_name(Some(ref_name.as_str()))
        .map_err(|details| format!("{}", details))?;

    if let GitAuthentication::WithToken(token) = auth {
        let token = token.clone();
        prepare_clone = prepare_clone.configure_connection(move |connection| {
            connection.set_credentials(token_credentials(&token));
            Ok(())
        });
    }

    let (mut prepare_checkout, _) = prepare_clone
        .fetch_then_checkout(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .map_err(|details| format!("{}", details))?;

    let (repo, _) = prepare_checkout
        .main_worktree(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .map_err(|details| format!("{}", details))?;

    // Locally unset core.attributesfile to prevent gix from failing on missing global files
    let config_path = repo.git_dir().join("config");
    if let Ok(mut file) = std::fs::OpenOptions::new().append(true).open(&config_path) {
        let _ = writeln!(file, "\n[core]\n    attributesFile = \"\"");
    }

    let head: String = match repo.head_commit() {
        Ok(commit) => commit.id().to_string(),
        Err(_details) => "unknown".to_string(),
    };

    info!(head, "Initial cloning done");

    Ok(repo)
}

pub fn git_pull(local_path: &str, auth: &GitAuthentication) -> Result<(), String> {
    // Inject local committer config before opening the repo so gix loads it into memory
    let config_path = Path::new(local_path).join(".git").join("config");
    if let Ok(mut file) = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&config_path)
    {
        let _ = writeln!(
            file,
            "\n[user]\n    name = Local User\n    email = user@local.invalid"
        );
    }

    // Open the existing repository
    let repo = gix::open(local_path).map_err(|details| format!("{}", details))?;

    // Get current HEAD and find the active local branch name
    let head = repo.head().map_err(|details| format!("{}", details))?;
    let local_branch_ref = head
        .referent_name()
        .ok_or("Repository is in a detached HEAD state")?;

    let branch_short_name = local_branch_ref
        .as_bstr()
        .strip_prefix(b"refs/heads/")
        .ok_or("HEAD does not point to a local branch under refs/heads/")?;
    let branch_name_str =
        std::str::from_utf8(branch_short_name).map_err(|details| format!("{}", details))?;

    // Determine the remote and tracking branch using Git config mapping
    let (remote_name, remote_branch_name) = repo
        .branch_remote_ref_name(local_branch_ref, gix::remote::Direction::Fetch)
        .and_then(|remote_ref| {
            let name_str = remote_ref.unwrap().to_string();
            if let Some(stripped) = name_str.strip_prefix("refs/remotes/") {
                let mut parts = stripped.splitn(2, '/');
                let r = parts.next()?;
                let b = parts.next()?;
                Some((r.to_string(), b.to_string()))
            } else {
                None
            }
        })
        .unwrap_or_else(|| ("origin".to_string(), branch_name_str.to_string()));

    // Connect and fetch from the detected remote
    let remote = repo
        .find_remote(&remote_name)
        .map_err(|details| format!("{}", details))?;

    let mut connection = remote
        .connect(gix::remote::Direction::Fetch)
        .map_err(|details| format!("{}", details))?;

    // Must be set before `prepare_fetch`, which is what performs the handshake.
    if let GitAuthentication::WithToken(token) = auth {
        connection.set_credentials(token_credentials(token));
    }

    connection
        .prepare_fetch(gix::progress::Discard, Default::default())
        .map_err(|details| format!("{}", details))?
        .receive(gix::progress::Discard, &gix::interrupt::IS_INTERRUPTED)
        .map_err(|details| format!("{}", details))?;

    // Resolve the remote-tracking reference
    let remote_ref_name = format!("refs/remotes/{}/{}", remote_name, remote_branch_name);
    let mut remote_ref = repo
        .find_reference(&remote_ref_name)
        .map_err(|details| format!("{}", details))?;
    let target_commit_id = remote_ref
        .peel_to_id()
        .map_err(|details| format!("{}", details))?;

    // Update local branch reference (Fast-forward)
    let mut local_ref = repo
        .find_reference(local_branch_ref.as_bstr())
        .map_err(|details| format!("{}", details))?;
    local_ref
        .set_target_id(target_commit_id.detach(), "gix auto-pull: Fast-forward")
        .map_err(|details| format!("{}", details))?;

    // Update index and check out files into the working directory
    let commit = target_commit_id
        .object()
        .map_err(|details| format!("{}", details))?
        .peel_to_commit()
        .map_err(|details| format!("{}", details))?;
    let tree_id = commit.tree_id().map_err(|details| format!("{}", details))?;

    let mut index = repo
        .index_from_tree(&tree_id)
        .map_err(|details| format!("{}", details))?;
    index
        .write(gix::index::write::Options::default())
        .map_err(|details| format!("{}", details))?;

    if let Some(workdir) = repo.workdir() {
        gix::worktree::state::checkout(
            &mut index,
            workdir,
            repo.objects.clone(),
            &gix::progress::Discard,
            &gix::progress::Discard,
            &Default::default(),
            Default::default(),
        )
        .map_err(|details| format!("{}", details))?;
    }

    let head: String = match repo.head_commit() {
        Ok(commit) => commit.id().to_string(),
        Err(_details) => "unknown".to_string(),
    };

    info!(head, "Git pull done");

    Ok(())
}