🔖 Rustでgitのtagをbumpする

同じチームのメンバーがnodegitというnodeのlibgit2のbindingを利用して、便利なツールを作っているのをみて各言語にgitを操作するためのライブラリーへのbindingがあることを知りました。 そこで、今回はRustのlibgit2 bindingであるgit2-rsを利用して、gitのtagをbumpして、remoteにpushするcliを作ってみようと思います。 sourceはこちら

作ったcli

git-bump

実行するとlocalのtag一覧を取得して、semantic versionでsortし、bumpするversionを選択します。 versionを選択してもらったら、現在のHEADを対象にtagを作成し、remoteにpushします。

Bump処理

[dependencies]
clap = "2.33.0"
git2 = "0.13.0"
tracing = "0.1.13"
tracing-subscriber = "0.2.3"
log = "0.4.8"
semver = "0.9.0"
anyhow = "1.0.27"
colored = "1.9.3"
dialoguer = "0.5.0"

pub mod cli;

use anyhow::anyhow;
use colored::*;
use dialoguer::theme::ColorfulTheme;
use semver::{SemVerError, Version};
use std::io::{self, Write};
use std::result::Result as StdResult;
use std::borrow::Cow;
use tracing::{debug, warn};

#[derive(Debug, PartialEq, Eq)]
pub enum Bump {
    Major,
    Minor,
    Patch,
}

type Result<T> = std::result::Result<T, anyhow::Error>;

pub struct Config {
    pub prefix: Option<String>,
    pub repository_path: Option<String>,
    #[doc(hidden)]
    pub __non_exhaustive: (), // https://xaeroxe.github.io/init-struct-pattern/
}

impl Default for Config {
    fn default() -> Self {
        Self {
            prefix: Some("v".to_owned()),
            repository_path: None,
            __non_exhaustive: (),
        }
    }
}

impl Config {
    pub fn bump(self) -> Result<()> {
        self.build()?.bump()
    }

    fn build(self) -> Result<Bumper> {
        let repo = match self.repository_path {
            Some(path) => git2::Repository::open(&path)?,
            None => git2::Repository::open_from_env()?,
        };
        Ok(Bumper {
            prefix: self.prefix,
            repo,
            cfg: git2::Config::open_default()?,
            w: io::stdout(),
        })
    }
}

struct Bumper {
    prefix: Option<String>,
    repo: git2::Repository,
    cfg: git2::Config,
    w: io::Stdout,
}

impl Bumper {
    fn bump(mut self) -> Result<()> {
        let pattern = self.prefix.as_deref().map(|p| format!("{}*", p));
        let tags = self.repo.tag_names(pattern.as_deref())?;
        debug!(
            "found {} tags (pattern: {})",
            tags.len(),
            pattern.unwrap_or("".to_owned())
        );

        let (mut versions, errs) = self.parse_tags(tags);
        errs.into_iter().for_each(|e| match e {
            (tag, semver::SemVerError::ParseError(e)) => {
                warn!("malformed semantic version: {} {}", tag, e)
            }
        });
        versions.sort();

        let current = match versions.last() {
            None => {
                writeln!(
                    self.w.by_ref(),
                    "{} (pattern: {})",
                    "version tag not found".red(),
                    self.prefix.as_deref().unwrap_or("")
                )?;
                return Ok(());
            }
            Some(v) => v,
        };

        let mut bumped = current.clone();
        match self.prompt_bump(&current)? {
            Bump::Major => bumped.increment_major(),
            Bump::Minor => bumped.increment_minor(),
            Bump::Patch => bumped.increment_patch(),
        }

        if !self.confirm_bump(&current, &bumped)? {
            writeln!(self.w.by_ref(), "canceled")?;
            return Ok(());
        }

        let tag_oid = self.create_tag(&bumped)?;
        debug!("create tag(object_id: {})", tag_oid);

        self.push_tag(&bumped)
    }

    fn parse_tags(
        &mut self,
        tags: git2::string_array::StringArray,
    ) -> (Vec<Version>, Vec<(String, SemVerError)>) {
        let (versions, errs): (Vec<_>, Vec<_>) = tags
            .iter()
            .flatten()
            .map(|tag| tag.trim_start_matches(self.prefix.as_deref().unwrap_or("")))
            .map(|tag| Version::parse(tag).map_err(|err| (tag.to_owned(), err)))
            .partition(StdResult::is_ok);
        (
            versions.into_iter().map(StdResult::unwrap).collect(),
            errs.into_iter().map(StdResult::unwrap_err).collect(),
        )
    }

    fn prompt_bump(&mut self, current: &Version) -> Result<Bump> {
        let selections = &["major", "minor", "patch"];
        let select = dialoguer::Select::with_theme(&ColorfulTheme::default())
            .with_prompt(&format!("select bump version (current: {})", current))
            .default(0)
            .items(&selections[..])
            .interact()
            .unwrap();
        let bump = match select {
            0 => Bump::Major,
            1 => Bump::Minor,
            2 => Bump::Patch,
            _ => unreachable!(),
        };
        Ok(bump)
    }

    fn confirm_bump(&mut self, current: &Version, bumped: &Version) -> Result<bool> {
        let branch_name = git2::Branch::wrap(self.repo.head()?)
            .name()?
            .unwrap_or("")
            .to_owned();

        let head = self.repo.head()?.peel_to_commit()?;
        let w = self.w.by_ref();
        writeln!(w, "current HEAD")?;
        writeln!(w, "  branch : {}", branch_name)?;
        writeln!(w, "  id     : {}", head.id())?;
        writeln!(w, "  summary: {}", head.summary().unwrap_or(""))?;
        writeln!(w, "")?;
        dialoguer::Confirmation::new()
            .with_text(&format!(
                "bump version {prefix}{current} -> {prefix}{bumped}",
                prefix = format!("{}", self.prefix.as_deref().unwrap_or(""))
                    .red()
                    .bold(),
                current = format!("{}", current).red().bold(),
                bumped = format!("{}", bumped).red().bold(),
            ))
            .default(false)
            .interact()
            .map_err(anyhow::Error::from)
    }

    fn create_tag(&mut self, version: &Version) -> Result<git2::Oid> {
        let head = self.repo.head()?;
        if !head.is_branch() {
            return Err(anyhow!("HEAD is not branch"));
        }
        let obj = head.peel(git2::ObjectType::Commit)?;
        let signature = self.repo.signature()?;
        self.repo
            .tag(&format!("v{}", version), &obj, &signature, "", false)
            .map_err(anyhow::Error::from)
    }

    fn push_tag(&mut self, version: &Version) -> Result<()> {
        let mut origin = self.repo.find_remote("origin")?;

        let mut push_options = git2::PushOptions::new();
        let mut cb = git2::RemoteCallbacks::new();
        cb.transfer_progress(|_progress| {
            debug!(
                "called progress total_objects: {}",
                _progress.total_objects()
            );
            true
        })
        .push_update_reference(|reference, msg| {
            match msg {
                Some(err_msg) => println!("{}", err_msg.yellow()),
                None => println!("successfully pushed origin/{}", reference),
            }
            Ok(())
        })
        .credentials(|url, username_from_url, allowed_types| {
            debug!(
                "credential cb url:{} username_from_url:{:?} allowed_type {:?}",
                url, username_from_url, allowed_types
            );
            if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
                let user_name = match username_from_url {
                    Some(u) => Some(Cow::from(u)),
                    None => match self.user_name() {
                        Ok(Some(u)) => Some(u),
                        _ => None,
                    },
                };
                return match git2::Cred::credential_helper(&self.cfg, url, user_name.as_deref()) {
                    Ok(cred) => {
                        debug!("credential helper success");
                        Ok(cred)
                    }
                    Err(err) => {
                        debug!("{}", err);
                        // TODO: cache user credential to avoid prompt every time if user agree.
                        let cred = prompt_userpass()
                            .map_err(|_| git2::Error::from_str("prompt_userpass"))?;
                        git2::Cred::userpass_plaintext(&cred.0, &cred.1)
                    }
                };
            }
            // TODO: currently only USER_PASS_PLAINTEXT called :(
            git2::Cred::ssh_key_from_agent("xxx")
        });

        push_options.remote_callbacks(cb);

        let ref_spec = format!("refs/tags/v{0}:refs/tags/v{0}", version);
        debug!("refspec: {}", ref_spec);

        origin
            .push(&[&ref_spec], Some(&mut push_options))
            .map_err(anyhow::Error::from)
    }

    fn user_name(&self) -> Result<Option<Cow<str>>> {
        for entry in &self.cfg.entries(Some("user*"))? {
            if let Ok(entry) = entry {
                debug!("found {:?} => {:?}", entry.name(), entry.value());
                return Ok(entry.value().map(|v| Cow::Owned(String::from(v))));
            }
        }
        Ok(None)
    }
}

fn prompt_userpass() -> Result<(String, String)> {
    let username = dialoguer::Input::<String>::new()
        .with_prompt("username")
        .interact()?;
    let password = dialoguer::PasswordInput::new()
        .with_prompt("password")
        .interact()?;
    Ok((username, password))
}

Bumper::bump() がentry pointです。
流れとしては、まずgit2::Repository::open_from_env()Repositoryを取得します。
このRepositoryに各処理の起点となるmethodが定義されています。
今回は、tagの一覧がほしいので、Repository::tag_names() を呼び出し、StringArrayを取得します。 &'a StringArrayIteratorを実装しているので、perseしてsemantic versionの一覧に変換します。
ユーザにbumpするversionを選択してもらったあとは、bumpされたversionでtagを作成します。

    fn create_tag(&mut self, version: &Version) -> Result<git2::Oid> {
        let head = self.repo.head()?;
        if !head.is_branch() {
            return Err(anyhow!("HEAD is not branch"));
        }
        let obj = head.peel(git2::ObjectType::Commit)?;
        let signature = self.repo.signature()?;
        self.repo
            .tag(&format!("v{}", version), &obj, &signature, "", false)
            .map_err(anyhow::Error::from)
    }

Repository::tag()が定義されているので、tagを作るのはこれを呼ぶのかなと思い、docをみてみると以下のように定義されています。

pub fn tag(
    &self,
    name: &str,
    target: &Object,
    tagger: &Signature,
    message: &str,
    force: bool
) -> Result<Oid, Error>

ここで、Objectなる聞き慣れない型がでてきました。ここで、git objectで検索すると公式?のChapter 10 Git Internalの記事がでてきました。 どうやら、Gitは内部的に、key-value storeを備えており、valueはblobとして保持しているようです。このblobを特定の型(データモデル)として扱うことをpeelと呼んでいるみたいです。 ということで、cliからgit tag vX.Y.Zと実行したときはHEADが対象になることにならって、Repository::head() でHEADを取得するようにしてみました。

認証がやっかい

localにtagを作成したあとはremoteにpushするだけなのですが、ここがやっかいでした。 まず、Repository::find_remote()Remoteを取得し、Remote:push()を実行します。pushは以下のように定義されています。

pub fn push<Str: AsRef<str> + IntoCString + Clone>(
    &mut self,
    refspecs: &[Str],
    opts: Option<&mut PushOptions>
) -> Result<(), Error>

refspecsについては、refs/tags/vX.Y.Z:refs/tags/vX.Y.Zのように、refs以下をそのまま対応させたらうまくいきました。 次の引数、PushOptionsにcallbackとして認証処理をわたせるようになっています。

pub fn credentials<F>(&mut self, cb: F) -> &mut RemoteCallbacks<'a>
where
    F: FnMut(&str, Option<&str>, CredentialType) -> Result<Cred, Error> + 'a, 

ここでdocumentにあまり情報がなく、困ったのですが、issuecargoのコメントから、callbackの第3引数(allowed_types)に応じて、git2::Credを生成して返せばよさそうだったので、git2::Cred::user_pass_plaintext()を実行したところ、githubに認証してもらえました。

毎回認証のpromptをだすのはさすがに煩わしいので、このあたりは改善したいです。 libgit2にもdocがあります。

まとめ

Rustからgitの処理を安全に(FFIをwrapしてもらった形で)扱える。