git-whennes 0.2.0

Find the commit that merged a commit into mainline
Documentation
use git2::{Commit, Oid, Repository, Sort};
use git_whennes::error::{Error, Result};
use pico_args::Arguments;

const HELP: &str = "
Find the merge commit and pull request a commit came from.

Usage:
    git-whennes <commit>

Options:
    -h, --help          Show this help message.
    -b, --branch        Branch to find where `commit` has been merged into it.
                        Default: init.defaultBranch falling back to 'master'";

const FORGE_OPTS: &str = "
    -u, --url           Print the URL linking to where `commit` was merged into
                        mainline.
    -o, --open          Open the URL linking to where `commit` was merged into
                        mainline.
    -r, --remote        Name of the remote to get the merge's URL.
                        Default: origin";

#[derive(Debug)]
struct Args {
    sha: String,
    main_branch: Option<String>,
    #[cfg(feature = "forge")]
    show_url: bool,
    #[cfg(feature = "forge")]
    open_url: bool,
    #[cfg(feature = "forge")]
    remote: String,
}

fn main() -> Result<()> {
    env_logger::init();
    let args = parse_args()?;
    let repo = Repository::open_from_env()?;
    let commit_to_check = get_commit(&repo, &args.sha)?;

    // If it's got more than one parent then the commit is already a merge commit. We can
    // short-circuit here.
    // TODO(nds): What about the case where it has been merged into one branch but not yet into
    //  mainline?
    if commit_to_check.parent_count() > 1 {
        log::warn!("Merge commit");
        show_commit(commit_to_check)?;
        // TODO(nds): This is what git-whence exists with. Does it really make sense? Merge commits are
        //  okay.
        std::process::exit(1);
    }

    let main_branch = if let Some(main_branch) = args.main_branch {
        main_branch
    } else {
        let config = repo.config()?;
        // Why such a round about way? There is a config.get_str method but when I tried to use it
        // git2 doesn't like it because the config is 'live' (not readonly):
        // https://github.com/libgit2/libgit2/blob/4bd172087c30e09e7720a7df11cace47ee002256/src/config.c#L872-L875.
        let entry = config.get_entry("init.defaultbranch")?;
        entry.value().unwrap_or("master").into()
    };
    log::debug!("Using '{}' as the main branch", main_branch);
    let merge_commit = find_merge_simple(&repo, &args.sha, &main_branch)?;
    if cfg!(feature = "forge") && (args.show_url || args.open_url) {
        use git_whennes::forge;
        let url = forge::get_url(&repo, &args.remote, merge_commit)?;
        if args.show_url {
            println!("{}", url);
        }

        if args.open_url {
            let ret = open::that(&url).unwrap();
            if ret.success() {
                eprintln!("Error opening {}", url);
                std::process::exit(ret.code().unwrap_or(0));
            }
        }
    } else {
        show_commit(merge_commit)?;
    }
    Ok(())
}

// Show the given commit.
//
// The shortest unique ID of the commit is printed with summary line of the commit message.
fn show_commit(commit: Commit) -> Result<()> {
    let summary_line: &str = commit
        .message()
        .map(|msg| msg.lines().nth(0).unwrap_or_default())
        .unwrap_or_default();
    let obj = commit.as_object();
    let short_id = obj.short_id()?;
    println!("{} {}", short_id.as_str().unwrap(), summary_line);
    Ok(())
}

fn get_commit<'repo>(repo: &'repo Repository, sha: &str) -> Result<Commit<'repo>> {
    let obj = repo.revparse_single(sha)?;
    let commit = obj.peel_to_commit()?;
    Ok(commit)
}

// Simplest method of finding a merge of `from` into `to`.
//
// We walk the commits between `from` and `to` in reverse order (from `to` to `from`) and return
// the first one that is a merge commit. The commits are filtered to be only the first of a commit
// parent and a descendant of `from`.
//
// This technique is based on the one written using the plumbing command
// git-rev-list here: https://gist.github.com/laanwj/ef8a6dcbb02313442462#file-git-merge-point.
fn find_merge_simple<'repo>(
    repo: &'repo Repository,
    from: &str,
    to: &str,
) -> Result<Commit<'repo>> {
    let from_commit = repo.find_commit(Oid::from_str(from)?)?;
    let spec = format!("{}..{}", from, to);
    let mut revwalk = repo.revwalk()?;
    revwalk.simplify_first_parent()?;
    revwalk.set_sorting(Sort::REVERSE)?;
    revwalk.push_range(&spec)?;
    for id in revwalk {
        // TODO(nds): When would this fail?
        let id = id?;
        let commit = repo.find_commit(id)?;
        if commit.parent_count() > 1 && repo.graph_descendant_of(id, from_commit.id())? {
            return Ok(commit);
        }
    }

    Err(Error::NoMergeCommit { r#for: from.into() })
}

fn parse_args() -> Result<Args> {
    let mut args = Arguments::from_env();
    if args.contains(["-h", "--help"]) {
        let help = if cfg!(feature = "forge") {
            HELP.to_owned() + FORGE_OPTS
        } else {
            HELP.to_owned()
        };
        println!("{}", help);
        std::process::exit(1);
    }

    Ok(Args {
        main_branch: args.opt_value_from_str(["-b", "--branch"])?,
        #[cfg(feature = "forge")]
        show_url: args.contains(["-u", "--url"]),
        #[cfg(feature = "forge")]
        open_url: args.contains(["-o", "--open"]),
        #[cfg(feature = "forge")]
        remote: args
            .opt_value_from_str(["-r", "--remote"])?
            .unwrap_or("origin".into()),
        sha: args.free_from_str()?,
    })
}