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.

extern crate git_workarea;
use self::git_workarea::CommitId;

use super::super::*;

#[derive(Debug)]
/// Description of a third party package imported using Kitware's third party import scripts.
///
/// The workflow used at Kitware for third party packages is to keep all changes tracked in
/// separate repositories. This makes tracking patches to the projects easier to manage and extract
/// for submission to the appropriate upstream project.
///
/// When a project is imported, it uses a separate history which contains only snapshots of the
/// tracked repository. When imported into a project, it can select a subset of files to keep, drop
/// extra metadata into the import, or perform other transformations as necessary. Whatever the
/// result of that is, it is added as a new commit on the history of the tracking branch for the
/// project. This is then merged into the main project using a subtree strategy to move the project
/// to the correct place.
///
/// This check checks to make sure that any modifications in the main project's imported location of
/// the third party project are made on the tracking branch.
pub struct ThirdParty {
    /// The name of the imported project.
    pub name: String,
    /// The path the third party project lives once merged.
    pub path: String,
    /// The root commit of the third party tracking branch.
    pub root: String,
    /// The location of the script to use for importing this project.
    pub script: String,
}

impl ThirdParty {
    /// Create a new third party import configuration.
    pub fn new<N, P, R, S>(name: N, path: P, root: R, script: S) -> Self
        where N: ToString,
              P: ToString,
              R: ToString,
              S: ToString,
    {
        ThirdParty {
            name: name.to_string(),
            path: path.to_string(),
            root: root.to_string(),
            script: script.to_string(),
        }
    }
}

enum CheckRefResult {
    IsImport,
    Rejected(String),
}

impl CheckRefResult {
    fn is_import(&self) -> bool {
        if let CheckRefResult::IsImport = *self {
            true
        } else {
            false
        }
    }

    fn add_result(self, result: &mut CheckResult) {
        if let CheckRefResult::Rejected(err) = self {
            result.add_error(err);
        }
    }
}

impl Check for ThirdParty {
    fn name(&self) -> &str {
        "third-party"
    }

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

        // A flag to indicate that we need to check the commit for an evil merge.
        let mut check_tree = false;

        for diff in &commit.diffs {
            let name_path = diff.name.as_path();

            if !name_path.starts_with(&self.path) {
                continue;
            }

            let check_ref = |sha1: &CommitId| {
                let rev_list = try!(ctx.git()
                    .arg("rev-list")
                    .arg("--first-parent")
                    .arg("--max-parents=0")
                    .arg(sha1.as_str())
                    .output()
                    .chain_err(|| "failed to construct rev-list command"));
                if !rev_list.status.success() {
                    bail!(ErrorKind::Git(format!("failed to get list the root commit for a {} \
                                                  third-party change: {}",
                                                 self.name,
                                                 String::from_utf8_lossy(&rev_list.stderr))));
                }
                let refs = String::from_utf8_lossy(&rev_list.stdout);
                let is_import = self.root == refs.trim();

                Ok(if !is_import {
                    let msg = format!("commit {} not allowed; the `{}` file is maintained by the \
                                       third party scripts; please use `{}` to update this file.",
                                      commit.sha1_short,
                                      diff.name,
                                      self.script);

                    CheckRefResult::Rejected(msg)
                } else {
                    CheckRefResult::IsImport
                })
            };

            if commit.parents.len() == 2 {
                let is_import_res: Result<_> = check_ref(&commit.parents[1]);
                let is_import = try!(is_import_res).is_import();

                if is_import {
                    // Check that the merge commit is not "evil" for the import directory.
                    check_tree = true;
                } else {
                    try!(check_ref(&commit.sha1)).add_result(&mut result);
                }
            } else {
                try!(check_ref(&commit.sha1)).add_result(&mut result);
            }
        }

        if check_tree {
            // Get the tree of the import branch.
            let rev_parse = try!(ctx.git()
                .arg("rev-parse")
                .arg(format!("{}^{{tree}}", commit.parents[1]))
                .output()
                .chain_err(|| "failed to construct rev-parse command"));
            if !rev_parse.status.success() {
                bail!(ErrorKind::Git(format!("failed to parse the expected tree object for the \
                                              {} third-party directory: {}",
                                             self.name,
                                             String::from_utf8_lossy(&rev_parse.stderr))));
            }
            let expected_tree = String::from_utf8_lossy(&rev_parse.stdout);

            // Get the tree of the imported tree in the merge commit.
            let ls_tree = try!(ctx.git()
                .arg("ls-tree")
                .arg(commit.sha1.as_str())
                .arg(&self.path)
                .output()
                .chain_err(|| "failed to construct ls-tree command"));
            if !ls_tree.status.success() {
                bail!(ErrorKind::Git(format!("failed to parse the actual tree object for the \
                                              {} third-party directory: {}",
                                             self.name,
                                             String::from_utf8_lossy(&ls_tree.stderr))));
            }
            let ls_tree_output = String::from_utf8_lossy(&ls_tree.stdout);
            let actual_tree = ls_tree_output.split_whitespace()
                .nth(2)
                .unwrap();

            // Ensure the trees match.
            if actual_tree != expected_tree.trim() {
                let msg = format!("commit {} not allowed; the `{}` directory contains changes \
                                   not on the import branch; merge conflicts should not \
                                   happen and indicate that the import directory was \
                                   manually edited at some point. Please find and revert the \
                                   bad edit, apply it to the imported repository (if \
                                   necessary), and then run the import script.",
                                  commit.sha1_short,
                                  self.path);

                result.add_error(msg);
            }
        }

        Ok(result)
    }
}

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

    static BASE_COMMIT: &'static str = "26576e49345a141eca310af92737e489c9baac24";
    static VALID_UPDATE_TOPIC: &'static str = "0bd161c8187d4f727a7acc17020711dcc139b166";
    static INVALID_UPDATE_TOPIC: &'static str = "af154fdff05c871125f2db03eccbdde8571d484e";
    static EVIL_UPDATE_TOPIC: &'static str = "add18e5ab9a67303337cb2754c675fb2e0a45a79";

    fn make_third_party_check() -> ThirdParty {
        ThirdParty::new("check_size",
                        "check_size",
                        "d50197ebd7167b0941d34405686164068db0b77b",
                        "./update.sh")
    }

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

        conf.add_check(&check);

        let result = test_check_base("test_third_party_valid_update",
                                     VALID_UPDATE_TOPIC,
                                     BASE_COMMIT,
                                     &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_third_party_invalid_update() {
        let check = make_third_party_check();
        let mut conf = GitCheckConfiguration::new();

        conf.add_check(&check);

        let result = test_check_base("test_third_party_invalid_update",
                                     INVALID_UPDATE_TOPIC,
                                     BASE_COMMIT,
                                     &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 af154fd not allowed; the `check_size/increased-limit` file is \
                    maintained by the third party scripts; please use `./update.sh` to \
                    update this file.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }

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

        conf.add_check(&check);

        let result = test_check_base("test_third_party_invalid_update_evil",
                                     EVIL_UPDATE_TOPIC,
                                     BASE_COMMIT,
                                     &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 add18e5 not allowed; the `check_size` directory contains changes not \
                    on the import branch; merge conflicts should not happen and indicate that \
                    the import directory was manually edited at some point. Please find and \
                    revert the bad edit, apply it to the imported repository (if necessary), and \
                    then run the import script.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }
}