git-checks 1.0.0

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright 2016 Kitware, Inc.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use super::super::*;

#[derive(Debug, Default, Clone, Copy)]
/// Check that submodules are reachable from a given branch and available.
pub struct SubmoduleAvailable {
    require_first_parent: bool,
}

impl SubmoduleAvailable {
    /// Checks that submodules in the project are available.
    pub fn new() -> Self {
        SubmoduleAvailable {
            require_first_parent: false,
        }
    }

    /// Sets whether a first parent history is required to reach the commit.
    ///
    /// If the merge commit of the submodule into the tracked branch should be required, set this
    /// flag.
    pub fn require_first_parent(&mut self, require: bool) -> &mut Self {
        self.require_first_parent = require;
        self
    }
}

impl Check for SubmoduleAvailable {
    fn name(&self) -> &str {
        "submodule-available"
    }

    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult> {
        let mut result = CheckResult::new();

        for diff in &commit.diffs {
            // Ignore diffs which are not submodules on the new side.
            if diff.new_mode != "160000" {
                continue;
            }

            // Ignore deleted submodules.
            if let StatusChange::Deleted = diff.status {
                continue;
            }

            let submodule_ctx = SubmoduleContext::new(ctx, diff.name.as_ref());
            if submodule_ctx.is_none() {
                result.add_alert(format!("submodule at `{}` is not configured.", diff.name),
                                 false);

                continue;
            }
            let submodule_ctx = submodule_ctx.unwrap();

            let submodule_commit = &diff.new_blob;

            let cat_file = try!(submodule_ctx.context
                .git()
                .arg("cat-file")
                .arg("-t")
                .arg(submodule_commit.as_str())
                .output()
                .chain_err(|| "failed to construct cat-file command"));
            let object_type = String::from_utf8_lossy(&cat_file.stdout);
            if !cat_file.status.success() || object_type.trim() != "commit" {
                result.add_error(format!("commit {} references an unreachable commit {} at \
                                          `{}`; please make the commit available in the {} \
                                          repository on the `{}` branch first.",
                                         commit.sha1_short,
                                         submodule_commit,
                                         submodule_ctx.path,
                                         submodule_ctx.url,
                                         submodule_ctx.branch));

                continue;
            }

            let merge_base = try!(submodule_ctx.context
                .git()
                .arg("merge-base")
                .arg(submodule_commit.as_str())
                .arg(submodule_ctx.branch)
                .output()
                .chain_err(|| "failed to construct merge-base command"));
            if !merge_base.status.success() {
                bail!(ErrorKind::Git(format!("failed to get the merge base for the {} \
                                              submodule: {}",
                                             diff.name,
                                             String::from_utf8_lossy(&merge_base.stderr))));
            }
            let base = String::from_utf8_lossy(&merge_base.stdout);

            if base.trim() != submodule_commit.as_str() {
                result.add_error(format!("commit {} references the commit {} at `{}`, but it is \
                                          not available on the tracked branch `{}`; please make \
                                          the commit available from the `{}` branch first.",
                                         commit.sha1_short,
                                         submodule_commit,
                                         submodule_ctx.path,
                                         submodule_ctx.branch,
                                         submodule_ctx.branch));
                continue;
            }

            if self.require_first_parent {
                let refs = try!(submodule_ctx.context
                    .git()
                    .arg("rev-list")
                    .arg("--first-parent")   // only look at first-parent history
                    .arg("--reverse")        // start with oldest commits
                    .arg(submodule_ctx.branch)
                    .arg(format!("^{}~", submodule_commit))
                    .output()
                    .chain_err(|| "failed to construct rev-list command"));
                if !refs.status.success() {
                    bail!(ErrorKind::Git(format!("failed to get list the first parent history \
                                                  for the {} submodule: {}",
                                                 diff.name,
                                                 String::from_utf8_lossy(&refs.stderr))));
                }
                let refs = String::from_utf8_lossy(&refs.stdout);

                if !refs.lines().any(|rev| rev == submodule_commit.as_str()) {
                    result.add_error(format!("commit {} references the commit {} at `{}`, but \
                                              it is not available as a first-parent of the \
                                              tracked branch `{}`; please choose the commit \
                                              where it was merged into the `{}` branch.",
                                             commit.sha1_short,
                                             submodule_commit,
                                             submodule_ctx.path,
                                             submodule_ctx.branch,
                                             submodule_ctx.branch));
                    continue;
                }
            }
        }

        Ok(result)
    }
}

#[cfg(test)]
mod tests {
    use super::SubmoduleAvailable;
    use super::super::test::*;

    static BASE_COMMIT: &'static str = "fe90ee22ae3ce4b4dc41f8d0876e59355ff1e21c";
    static MOVE_TOPIC: &'static str = "2088079e35503be3be41dbdca55080ced95614e1";
    static MOVE_NOT_FIRST_PARENT_TOPIC: &'static str = "eb4df16a8a38f6ca30b6e67cfbca0672156b54d2";
    static UNAVAILABLE_TOPIC: &'static str = "1b9275caca1557611df19d1dfea687c3ef302eef";
    static NOT_ANCESTOR_TOPIC: &'static str = "07fb2ca9c1c8c0ddfcf921e762688ffcd476bc09";

    #[test]
    fn test_submodule_unconfigured() {
        let check = SubmoduleAvailable::new();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check("test_submodule_unconfigured", BASE_COMMIT, &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 1);
        assert_eq!(result.alerts()[0],
                   "submodule at `submodule` is not configured.");
        assert_eq!(result.errors().len(), 0);
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), true);
    }

    #[test]
    fn test_submodule_move() {
        let check = SubmoduleAvailable::new();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_submodule("test_submodule_move", MOVE_TOPIC, &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 0);
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), true);
    }

    #[test]
    fn test_submodule_move_not_first_parent() {
        let check = SubmoduleAvailable::new();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_submodule("test_submodule_move_not_first_parent",
                                          MOVE_NOT_FIRST_PARENT_TOPIC,
                                          &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 0);
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), true);
    }

    #[test]
    fn test_submodule_move_not_first_parent_reject() {
        let mut check = SubmoduleAvailable::new();
        check.require_first_parent(true);
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_submodule("test_submodule_move_not_first_parent_reject",
                                          MOVE_NOT_FIRST_PARENT_TOPIC,
                                          &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 1);
        assert_eq!(result.errors()[0],
                   "commit eb4df16 references the commit \
                    c2bd427807b40b1715b8d1441fe92f50e8ad1769 at `submodule`, but it is not \
                    available as a first-parent of the tracked branch `master`; please choose \
                    the commit where it was merged into the `master` branch.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }

    #[test]
    fn test_submodule_unavailable() {
        let check = SubmoduleAvailable::new();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_submodule("test_submodule_unavailable", UNAVAILABLE_TOPIC, &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 1);
        assert_eq!(result.errors()[0],
                   "commit 1b9275c references an unreachable commit \
                    4b029c2e0f186d681caa071fa4dd7eb1f0f033f6 at `submodule`; please make the \
                    commit available in the https://gitlab.kitware.com/utils/test-repo.git \
                    repository on the `master` branch first.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }

    #[test]
    fn test_submodule_not_ancestor() {
        let check = SubmoduleAvailable::new();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_submodule("test_submodule_not_ancestor", NOT_ANCESTOR_TOPIC, &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 1);
        assert_eq!(result.errors()[0],
                   "commit 07fb2ca references the commit \
                    bd89a556b6ab6f378a776713439abbc1c1f15b6d at `submodule`, but it is not \
                    available on the tracked branch `master`; please make the commit available \
                    from the `master` branch first.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }
}