git-whennes 0.4.0

Find the commit that merged a commit into mainline
Documentation
use git2::{Commit, ErrorClass, ErrorCode, 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 exits 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 {
        log::debug!(
            "Using '{}' as main branch, specified by flag",
            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 = match config.get_entry("init.defaultbranch") {
            Ok(entry) => {
                entry.value().ok_or_else(|| {
                    log::warn!("Config value for `init.defaultbranch` is not valid UTF-8");
                    Error::InvalidUtf8
                })?.to_string()
            }
            Err(e) => {
                if let (ErrorClass::Config, ErrorCode::NotFound) =
                    (e.class(), e.code())
                {
                    log::debug!("init.defaultbranch config entry not found, defaulting to master");
                    "master".to_string()
                } else {
                    Err(e)?
                }
            }
        };
        entry
    };

    log::debug!(
        "Finding merge commit for {} using strategy 'simple'",
        args.sha
    );
    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().next().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)
        .map_err(|e| Error::RevParseError {
            sha: sha.to_string(),
            source: e,
        })?;
    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_object =
        repo.revparse_single(from)
            .map_err(|e| Error::RevParseError {
                sha: from.to_string(),
                source: e,
            })?;
    let from_commit =
        from_object.as_commit().ok_or(Error::ExpectedCommitError {
            sha: from.to_string(),
        })?;
    let to_object =
        repo.revparse_single(to).map_err(|e| Error::RevParseError {
            sha: to.to_string(),
            source: e,
        })?;
    let to_commit =
        to_object.as_commit().ok_or(Error::ExpectedCommitError {
            sha: to.to_string(),
        })?;
    let spec = format!("{}..{}", from_commit.id(), to_commit.id(),);
    let mut revwalk = repo.revwalk()?;
    revwalk.simplify_first_parent()?;
    revwalk.set_sorting(Sort::REVERSE)?;
    revwalk.push_range(&spec)?;
    log::debug!("Walking revisions for spec {} in reverse order", spec);
    for id in revwalk {
        // TODO(nds): When would this fail?
        let id = id?;
        let commit =
            repo.find_commit(id).map_err(|e| Error::FindCommitError {
                sha: id.to_string(),
                source: e,
            })?;
        if commit.parent_count() > 1
            && repo
                .graph_descendant_of(id, from_commit.id())
                .map_err(|e| Error::GraphDescendantOfError {
                    commit: id.to_string(),
                    ancestory: from_commit.id().to_string(),
                    source: e,
                })?
        {
            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_else(|| "origin".into()),
        sha: args.free_from_str()?,
    })
}