Skip to content

Sử dụng Git với Rust

use std::path::Path;

pub trait GitManagement {
    fn init(&mut self, repo_path: &str) -> Result<(), git2::Error>;
    fn checkout_branch(&self, branch_name: &str) -> Result<(), git2::Error>;
    fn add(&self) -> Result<(), git2::Error>;
    fn commit(&self, subject: &str) -> Result<git2::Oid, git2::Error>;
    fn push(&self, branch_name: &str) -> Result<(), git2::Error>;
}

#[derive(Default)]
pub struct Git {
    repo: Option<git2::Repository>,
}

impl GitManagement for Git {
    fn init(&mut self, repo_path: &str) -> Result<(), git2::Error> {
        git2::Repository::open(Path::new(&repo_path)).map(|repo| self.repo = Some(repo))
    }

    fn checkout_branch(&self, branch_name: &str) -> Result<(), git2::Error> {
        let repo = self.repo.as_ref().unwrap();

        let commit = repo
            .head()
            .map(|head| head.target())
            .and_then(|oid| repo.find_commit(oid.unwrap()))?;

        // Create new branch if it doesn't exist
        match repo.branch(branch_name, &commit, false) {
            // This command can fail due to an existing reference. This error should be ignored.
            Err(err)
                if !(err.class() == git2::ErrorClass::Reference
                    && err.code() == git2::ErrorCode::Exists) =>
            {
                return Err(err);
            }
            _ => {}
        }

        let refname = format!("refs/heads/{}", branch_name);
        let obj = repo.revparse_single(refname.as_str())?;

        repo.checkout_tree(&obj, None)?;
        repo.set_head(refname.as_str())
    }

    fn add(&self) -> Result<(), git2::Error> {
        let mut index = self.repo.as_ref().unwrap().index()?;

        index.add_path(Path::new("README.md"))?;
        index.write()
    }

    fn commit(&self, subject: &str) -> Result<git2::Oid, git2::Error> {
        let repo = self.repo.as_ref().unwrap();
        let mut index = repo.index()?;

        let signature = repo.signature()?; // Use default user.name and user.email

        let oid = index.write_tree()?;
        let parent_commit = find_last_commit(self.repo.as_ref().unwrap())?;
        let tree = repo.find_tree(oid)?;

        repo.commit(
            Some("HEAD"),      // point HEAD to our new commit
            &signature,        // author
            &signature,        // committer
            subject,           // commit message
            &tree,             // tree
            &[&parent_commit], // parent commit
        )
    }

    fn push(&self, branch_name: &str) -> Result<(), git2::Error> {
        with_credentials(self.repo.as_ref().unwrap(), |cred_callback| {
            let mut remote = self.repo.as_ref().unwrap().find_remote("origin")?;

            let mut callbacks = git2::RemoteCallbacks::new();
            let mut options = git2::PushOptions::new();

            callbacks.credentials(cred_callback);
            options.remote_callbacks(callbacks);

            remote.push(
                &[format!(
                    "refs/heads/{}:refs/heads/{}",
                    branch_name, branch_name
                )],
                Some(&mut options),
            )?;

            Ok(())
        })
    }
}

fn find_last_commit(repo: &git2::Repository) -> Result<git2::Commit, git2::Error> {
    let obj = repo.head()?.resolve()?.peel(git2::ObjectType::Commit)?;
    obj.into_commit()
        .map_err(|_| git2::Error::from_str("Couldn't find commit"))
}

/// Helper to run git operations that require authentication.
///
/// This is inspired by [the way Cargo handles this][cargo-impl].
///
/// [cargo-impl]: https://github.com/rust-lang/cargo/blob/94bf4781d0bbd266abe966c6fe1512bb1725d368/src/cargo/sources/git/utils.rs#L437
fn with_credentials<F>(repo: &git2::Repository, mut f: F) -> Result<(), git2::Error>
where
    F: FnMut(&mut git2::Credentials) -> Result<(), git2::Error>,
{
    let config = repo.config()?;

    let mut tried_sshkey = false;
    let mut tried_cred_helper = false;
    let mut tried_default = false;

    f(&mut |url, username, allowed| {
        if allowed.contains(git2::CredentialType::USERNAME) {
            return Err(git2::Error::from_str("No username specified in remote URL"));
        }

        if allowed.contains(git2::CredentialType::SSH_KEY) && !tried_sshkey {
            tried_sshkey = true;
            let username = username.unwrap();
            return git2::Cred::ssh_key_from_agent(username);
        }

        if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) && !tried_cred_helper {
            tried_cred_helper = true;
            return git2::Cred::credential_helper(&config, url, username);
        }

        if allowed.contains(git2::CredentialType::DEFAULT) && !tried_default {
            tried_default = true;
            return git2::Cred::default();
        }

        Err(git2::Error::from_str("No authentication method succeeded"))
    })
}

#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
    use crate::git::{find_last_commit, Git, GitManagement};
    use git2::{BranchType, Repository, RepositoryInitOptions, Status};
    use tempfile::{NamedTempFile, TempDir};

    #[test]
    fn test_git__init__valid_repo() {
        let mut git = Git::default();
        // Valid repo
        let (dir, _repo, _file) = repo_init();

        let actual = git.init(dir.path().to_str().unwrap());

        assert!(actual.is_ok());
    }

    #[test]
    fn test_git__init__invalid_repo() {
        let mut git = Git::default();
        // Invalid repo
        let dir = TempDir::new().unwrap();

        let actual = git.init(dir.path().to_str().unwrap());

        assert!(actual.is_err());
    }

    #[test]
    fn test_git__checkout_branch__missing_branch() {
        let mut git = Git::default();
        let (dir, repo, _file) = repo_init();
        git.init(dir.path().to_str().unwrap()).unwrap();

        // This will create a new branch
        git.checkout_branch("new-branch-name").unwrap();

        let actual = repo.find_branch("new-branch-name", BranchType::Local);

        assert!(actual.is_ok());
    }

    #[test]
    fn test_git__checkout_branch__success() {
        let mut git = Git::default();
        let (dir, repo, _file) = repo_init();
        git.init(dir.path().to_str().unwrap()).unwrap();

        let before = repo.head();
        assert_eq!(before.unwrap().name().unwrap(), "refs/heads/main");

        git.checkout_branch("new-branch-name").unwrap();

        let after = repo.head();

        assert!(after.is_ok());
        assert_eq!(after.unwrap().name().unwrap(), "refs/heads/new-branch-name");
    }

    #[test]
    fn test_git__add__success() {
        let mut git = Git::default();
        let (dir, repo, _file) = repo_init();
        git.init(dir.path().to_str().unwrap()).unwrap();

        let statuses_before = repo.statuses(None).unwrap();
        let before = statuses_before.get(0).unwrap();
        assert_eq!(before.status(), Status::WT_NEW);

        git.add().unwrap();

        let statuses_after = repo.statuses(None).unwrap();
        let after = statuses_after.get(0).unwrap();
        assert_eq!(after.status(), Status::INDEX_NEW);
    }

    #[test]
    fn test_git__commit__success() {
        let mut git = Git::default();
        let (dir, _repo, _file) = repo_init();
        git.init(dir.path().to_str().unwrap()).unwrap();

        // Initial commit
        let before = find_last_commit(git.repo.as_ref().unwrap());
        assert_eq!(before.unwrap().summary().unwrap(), "initial-msg");

        git.add().unwrap();
        git.commit("some-subject").unwrap();

        let after = find_last_commit(git.repo.as_ref().unwrap());
        assert_eq!(after.unwrap().summary().unwrap(), "some-subject");
    }

    fn repo_init() -> (TempDir, Repository, NamedTempFile) {
        let td = TempDir::new().unwrap();
        let mut opts = RepositoryInitOptions::new();
        opts.initial_head("main");
        let repo = Repository::init_opts(td.path(), &opts).unwrap();

        // Create README.md file
        let file = tempfile::Builder::new()
            .prefix("README")
            .suffix(".md")
            .rand_bytes(0)
            .tempfile_in(td.path())
            .unwrap();
        {
            // Set basic config
            let mut config = repo.config().unwrap();
            config.set_str("user.name", "some-name").unwrap();
            config.set_str("user.email", "some-email").unwrap();

            // Make initial commit
            let mut index = repo.index().unwrap();
            let id = index.write_tree().unwrap();
            let tree = repo.find_tree(id).unwrap();
            let sig = repo.signature().unwrap();
            repo.commit(Some("HEAD"), &sig, &sig, "initial-msg", &tree, &[])
                .unwrap();
        }
        // Return file to not drop it and make it disappear
        (td, repo, file)
    }
}