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 commit message subjects for invalid patterns.
pub struct CommitSubject {
    min_summary: usize,
    max_summary: usize,

    check_wip: bool,

    check_rebase_commands: bool,
}

impl CommitSubject {
    /// Checks commit message subjects for invalid patterns
    ///
    /// Patterns which are checked for:
    ///   - overly long or short summary lines;
    ///   - work-in-progress messages; and
    ///   - `fixup!` and `squash!` messages.
    ///
    /// Commit messages which appear to have been auto generated by actions such as merging or
    /// reverting commits will skip the summary line length limit (if enforced).
    pub fn new() -> Self {
        CommitSubject {
            min_summary: 8,
            max_summary: 78,

            check_wip: true,

            check_rebase_commands: true,
        }
    }

    /// Check the summary line with the given limits.
    pub fn with_summary_limits(&mut self, min: usize, max: usize) -> &mut Self {
        self.min_summary = min;
        self.max_summary = max;
        self
    }

    /// Checks for work-in-progress commits
    ///
    /// Commit messages which mention `WIP` or `wip` at the beginning of their commit messages are
    /// rejected since they are (nominally) incomplete.
    pub fn check_work_in_progress(&mut self, wip: bool) -> &mut Self {
        self.check_wip = wip;
        self
    }

    /// Check for rebase commands
    ///
    /// Rebase commands include commits which begin with `fixup! ` or `squash! `. These subjects
    /// are used to indicate that the commit belongs somewhere else in the branch and should be
    /// completed before merging.
    pub fn check_rebase_commands(&mut self, rebase: bool) -> &mut Self {
        self.check_rebase_commands = rebase;
        self
    }

    fn is_generated_subject(summary: &str) -> bool {
        false ||
            summary.starts_with("Merge ") ||
            summary.starts_with("Revert ")
    }
}

impl Check for CommitSubject {
    fn name(&self) -> &str {
        "commit-subject"
    }

    fn check(&self, _: &CheckGitContext, commit: &Commit) -> Result<CheckResult> {
        let mut result = CheckResult::new();
        let lines = commit.message.trim().lines().collect::<Vec<_>>();

        if lines.is_empty() {
            result.add_error(format!("commit {} has an invalid commit subject; it is empty.",
                                     commit.sha1_short));
            return Ok(result);
        }

        let summary = &lines[0];
        let summary_len = summary.len();

        if summary_len < self.min_summary {
            result.add_error(format!("commit {} has an invalid commit subject; the first line \
                                      must be at least {} characters.",
                                     commit.sha1_short,
                                     self.min_summary));
        }

        if !Self::is_generated_subject(summary) && self.max_summary < summary_len {
            result.add_error(format!("commit {} has an invalid commit subject; the first line \
                                      must be no longer than {} characters.",
                                     commit.sha1_short,
                                     self.max_summary));
        }

        if lines.len() >= 2 {
            if lines.len() >= 2 && !lines[1].is_empty() {
                result.add_error(format!("commit {} has an invalid commit subject; the second \
                                          line must be empty.",
                                         commit.sha1_short));
            }

            if lines.len() == 2 {
                result.add_error(format!("commit {} has an invalid commit subject; it cannot be \
                                          exactly two lines.",
                                         commit.sha1_short));
            } else if lines[2].is_empty() {
                result.add_error(format!("commit {} has an invalid commit subject; the third \
                                          line must not be empty.",
                                         commit.sha1_short));
            }
        }

        if self.check_wip && (summary.starts_with("WIP") || summary.starts_with("wip")) {
            result.add_error(format!("commit {} cannot be merged; it is marked as a \
                                      work-in-progress (WIP).",
                                     commit.sha1_short));
        }

        if self.check_rebase_commands {
            if summary.starts_with("fixup! ") {
                result.add_error(format!("commit {} cannot be merged; it is marked as a fixup \
                                          commit.",
                                         commit.sha1_short));
            } else if summary.starts_with("squash! ") {
                result.add_error(format!("commit {} cannot be merged; it is marked as a commit \
                                          to be squashed.",
                                         commit.sha1_short));
            }
        }

        Ok(result)
    }
}

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

    static BAD_TOPIC: &'static str = "5f7284fe1599265c90550b681a4bf0763bc1de21";

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

        conf.add_check(&check);

        let result = test_check("test_check_subject", BAD_TOPIC, &conf);

        assert_eq!(result.warnings().len(), 0);
        assert_eq!(result.alerts().len(), 0);
        assert_eq!(result.errors().len(), 11);
        assert_eq!(result.errors()[0],
                   "commit 5f7284f cannot be merged; it is marked as a commit to be squashed.");
        assert_eq!(result.errors()[1],
                   "commit 54d673f cannot be merged; it is marked as a fixup commit.");
        assert_eq!(result.errors()[2],
                   "commit 11dbbbf cannot be merged; it is marked as a work-in-progress (WIP).");
        assert_eq!(result.errors()[3],
                   "commit 9039b9a cannot be merged; it is marked as a work-in-progress (WIP).");
        assert_eq!(result.errors()[4],
                   "commit e478f63 cannot be merged; it is marked as a work-in-progress (WIP).");
        assert_eq!(result.errors()[5],
                   "commit 3a6fe6d has an invalid commit subject; the second line must be \
                    empty.");
        assert_eq!(result.errors()[6],
                   "commit 3a6fe6d has an invalid commit subject; the third line must not be \
                    empty.");
        assert_eq!(result.errors()[7],
                   "commit b1ca628 has an invalid commit subject; the second line must be \
                    empty.");
        assert_eq!(result.errors()[8],
                   "commit b1ca628 has an invalid commit subject; it cannot be exactly two \
                    lines.");
        assert_eq!(result.errors()[9],
                   "commit 1afc6b3 has an invalid commit subject; the first line must be no \
                    longer than 78 characters.");
        assert_eq!(result.errors()[10],
                   "commit 234de3c has an invalid commit subject; the first line must be at \
                    least 8 characters.");
        assert_eq!(result.allowed(), false);
        assert_eq!(result.pass(), false);
    }
}