git-checks 2.2.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 crates::itertools::{Itertools, multizip};
use crates::git_workarea::{CommitId, GitContext, Identity};
use crates::regex::Regex;

use error::*;

use std::ffi::OsStr;
use std::fmt::{self, Display};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;

macro_rules! char_byte {
    ($name: ident, $value: expr) => {
        const $name: u8 = $value as u8;
    }
}

char_byte!(BACKSLASH_ESCAPE, '\\');
char_byte!(BACKSLASH, '\\');
char_byte!(TAB_ESCAPE, 't');
char_byte!(TAB, '\t');
char_byte!(NEWLINE_ESCAPE, 'n');
char_byte!(NEWLINE, '\n');
char_byte!(QUOTE_ESCAPE, '"');
char_byte!(QUOTE, '"');
char_byte!(ZERO, '0');
char_byte!(SEVEN, '7');

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Ways a file can be changed in a commit.
pub enum StatusChange {
    /// The file was added in this commit.
    Added,
    /// The file was copied in this commit with the given similarity index.
    Copied(u8),
    /// The file was deleted in this commit.
    Deleted,
    /// The file was copied in this commit with an optional similarity index.
    Modified(Option<u8>),
    /// The file was renamed in this commit with the given similarity index.
    Renamed(u8),
    /// The path changed type (types include directory, symlinks, and files).
    TypeChanged,
    /// The file is unmerged in the working tree.
    Unmerged,
    /// Git doesn't know what's going on.
    Unknown,
}

impl From<char> for StatusChange {
    fn from(c: char) -> Self {
        match c {
            'A' => StatusChange::Added,
            'C' => StatusChange::Copied(0),
            'D' => StatusChange::Deleted,
            'M' => StatusChange::Modified(None),
            'R' => StatusChange::Renamed(0),
            'T' => StatusChange::TypeChanged,
            'U' => StatusChange::Unmerged,
            'X' => StatusChange::Unknown,
            _ => unreachable!("the regex does not match any other characters"),
        }
    }
}

#[derive(Debug, Clone, Eq)]
/// A representation of filenames as given by Git.
///
/// Git supports filenames with control characters and other non-Unicode byte sequence which are
/// quoted when listed in certain Git command outputs. This enumeration smooths over these
/// differences and offers accessors to the file name in different representations.
///
/// Generally, the `as_` methods should be preferred to pattern matching on this enumeration.
pub enum FileName {
    #[doc(hidden)]
    Normal(String),
    #[doc(hidden)]
    Quoted {
        raw: Vec<u8>,
        name: String,
    },
}

impl FileName {
    /// The file name as a `str`.
    pub fn as_str(&self) -> &str {
        self.as_ref()
    }

    /// The file name as a `Path`.
    pub fn as_path(&self) -> &Path {
        self.as_ref()
    }

    /// The raw bytes of the file name.
    pub fn as_bytes(&self) -> &[u8] {
        self.as_ref()
    }
}

impl Display for FileName {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.as_str())
    }
}

impl PartialEq for FileName {
    fn eq(&self, rhs: &Self) -> bool {
        self.as_bytes() == rhs.as_bytes()
    }
}

impl AsRef<str> for FileName {
    fn as_ref(&self) -> &str {
        match *self {
            FileName::Normal(ref name) |
            FileName::Quoted { ref name, .. } => name,
        }
    }
}

impl AsRef<[u8]> for FileName {
    fn as_ref(&self) -> &[u8] {
        match *self {
            FileName::Normal(ref name) => name.as_bytes(),
            FileName::Quoted { ref raw, .. } => raw,
        }
    }
}

impl AsRef<OsStr> for FileName {
    fn as_ref(&self) -> &OsStr {
        match *self {
            FileName::Normal(ref name) => name.as_ref(),
            FileName::Quoted { ref raw, .. } => OsStr::from_bytes(raw),
        }
    }
}

impl AsRef<Path> for FileName {
    fn as_ref(&self) -> &Path {
        Path::new(self)
    }
}

#[derive(Debug)]
/// Information about a file that changed in a commit.
pub struct DiffInfo {
    /// The mode of the file in the parent commit.
    pub old_mode: String,
    /// The blob object of the file in the parent commit.
    pub old_blob: CommitId,
    /// The mode of the file in the current commit.
    pub new_mode: String,
    /// The blob object of the file in the current commit.
    pub new_blob: CommitId,

    /// The name of the file in the current commit.
    pub name: FileName,
    /// The status mode of the file.
    pub status: StatusChange,
}

#[derive(Debug)]
/// Representation of a commit with information useful for commit checks.
pub struct Commit {
    /// The SHA1 of the commit.
    pub sha1: CommitId,

    /// The commit message.
    pub message: String,

    /// The parents of the commit.
    pub parents: Vec<CommitId>,
    /// Information about files that changed in this commit.
    pub diffs: Vec<DiffInfo>,

    /// The identity of the author.
    pub author: Identity,
    /// The identity of the commiter.
    pub committer: Identity,

    /// The git context which contains the commit.
    ctx: GitContext,
}

lazy_static! {
    static ref DIFF_TREE_LINE_RE: Regex =
        Regex::new("^:+\
                    (?P<old_modes>[0-7]{6}( [0-7]{6})*) \
                    (?P<new_mode>[0-7]{6}) \
                    (?P<old_blobs>[0-9a-f]{40}( [0-9a-f]{40})*) \
                    (?P<new_blob>[0-9a-f]{40}) \
                    (?P<status>[ACDMRTUX]+)\
                    \t(?P<name>.*)$").unwrap();
}

impl Commit {
    /// Create a new commit from the given context for the SHA1.
    pub fn new(ctx: &GitContext, sha1: &CommitId) -> Result<Self> {
        let commit_info = ctx.git()
            .arg("log")
            .arg("--pretty=%P%n%an%n%ae%n%cn%n%ce")
            .arg("--max-count=1")
            .arg(sha1.as_str())
            .output()
            .chain_err(|| "failed to construct log command for information query")?;
        if !commit_info.status.success() {
            bail!(ErrorKind::Git(format!("failed to fetch information on the {} commit: {}",
                                         sha1,
                                         String::from_utf8_lossy(&commit_info.stderr))));
        }
        let lines = String::from_utf8_lossy(&commit_info.stdout);
        let lines = lines.lines().collect::<Vec<_>>();

        assert!(lines.len() == 5,
                "got {} rather than 5 lines when logging a commit: {:?}",
                lines.len(),
                lines);

        let commit_message = ctx.git()
            .arg("log")
            .arg("--pretty=%B")
            .arg("--max-count=1")
            .arg(sha1.as_str())
            .output()
            .chain_err(|| "failed to construct log command for the commit message")?;
        if !commit_message.status.success() {
            bail!(ErrorKind::Git(format!("failed to fetch the commit message on the {} commit: \
                                          {}",
                                         sha1,
                                         String::from_utf8_lossy(&commit_message.stderr))));
        }
        let mut commit_message = String::from_utf8_lossy(&commit_message.stdout).into_owned();
        // Remove the newline that `git log` adds to the message.
        commit_message.pop();

        let parents = lines[0]
            .split_whitespace()
            .map(CommitId::new)
            .collect();

        Ok(Commit {
            sha1: sha1.clone(),
            message: commit_message,

            parents: parents,
            diffs: Self::_mkdiffs(ctx, sha1)?,

            author: Identity::new(lines[1], lines[2]),
            committer: Identity::new(lines[3], lines[4]),

            ctx: ctx.clone(),
        })
    }

    /// Return a vector of files changed by the commit.
    ///
    /// This excludes paths which are either not actually files (e.g., submodules and symlinks), as
    /// well as paths which were deleted in this commit.
    pub fn changed_files(&self) -> Vec<&FileName> {
        self.diffs
            .iter()
            .filter_map(|diff| {
                // Ignore submodules.
                if diff.new_mode == "160000" {
                    return None;
                }

                // Ignore symlinks.
                if diff.new_mode == "120000" {
                    return None;
                }

                match diff.status {
                    StatusChange::Added |
                    StatusChange::Modified(_) => Some(&diff.name),
                    // Ignore paths which are not either added or modified.
                    _ => None,
                }
            })
            .collect::<Vec<_>>()
    }

    /// Gather the diff information from the commit.
    fn _mkdiffs(ctx: &GitContext, sha1: &CommitId) -> Result<Vec<DiffInfo>> {
        let diff_tree = ctx.git()
            .arg("diff-tree")
            .arg("--no-commit-id")
            .arg("--root")
            .arg("-c")  // Show merge commit diffs.
            .arg("-r")  // Recurse into sub-trees.
            .arg(sha1.as_str())
            .output()
            .chain_err(|| "failed to construct diff-tree command")?;
        if !diff_tree.status.success() {
            bail!(ErrorKind::Git(format!("failed to get the diff information for the {} \
                                          commit: {}",
                                         sha1,
                                         String::from_utf8_lossy(&diff_tree.stderr))));
        }
        let diffs = String::from_utf8_lossy(&diff_tree.stdout);
        let diff_lines = diffs.lines()
            .filter_map(|l| DIFF_TREE_LINE_RE.captures(l));

        Ok(diff_lines.map(|diff| {
                let old_modes = diff.name("old_modes")
                    .expect("the diff regex should have a 'old_modes' group");
                let new_mode = diff.name("new_mode")
                    .expect("the diff regex should have a 'new_mode' group");
                let old_blobs = diff.name("old_blobs")
                    .expect("the diff regex should have a 'old_blobs' group");
                let new_blob = diff.name("new_blob")
                    .expect("the diff regex should have a 'new_blob' group");
                let status = diff.name("status")
                    .expect("the diff regex should have a 'status' group");
                let raw_name = diff.name("name")
                    .expect("the diff regex should have a 'name' group");

                let name = if raw_name.as_str().starts_with('"') {
                    let raw = raw_name.as_str()
                        // Operate on bytes.
                        .bytes()
                        // Strip the quotes from the path.
                        .skip(1)
                        .dropping_back(1)
                        // Parse escaped characters.
                        .batching(|mut iter| {
                            match iter.next() {
                                Some(BACKSLASH) => {
                                    match iter.next() {
                                        Some(BACKSLASH_ESCAPE) => Some(BACKSLASH),
                                        Some(TAB_ESCAPE) => Some(TAB),
                                        Some(NEWLINE_ESCAPE) => Some(NEWLINE),
                                        Some(QUOTE_ESCAPE) => Some(QUOTE),
                                        Some(sfd) => Some(parse_octal(iter, sfd)),
                                        None => unreachable!(),
                                    }
                                },
                                n => n,
                            }
                        })
                        .collect();
                    FileName::Quoted {
                        raw: raw,
                        name: raw_name.as_str().to_string(),
                    }
                } else {
                    FileName::Normal(raw_name.as_str().to_string())
                };

                let zip = multizip((old_modes.as_str().split_whitespace(),
                                    old_blobs.as_str().split_whitespace(),
                                    status.as_str().chars()));

                zip.map(move |(m, b, s)| {
                    DiffInfo {
                        old_mode: m.to_string(),
                        new_mode: new_mode.as_str().to_string(),
                        old_blob: CommitId::new(b),
                        new_blob: CommitId::new(new_blob.as_str()),
                        status: s.into(),
                        name: name.clone(),
                    }
                })
            })
            .flatten()
            .collect())
    }

    /// Get the patch difference for the given path.
    pub fn file_patch<P>(&self, path: P) -> Result<String>
        where P: AsRef<OsStr>,
    {
        let diff_tree = self.ctx
            .git()
            .arg("diff-tree")
            .arg("--no-commit-id")
            .arg("--root")
            .arg("-p")
            .arg(self.sha1.as_str())
            .arg("--")
            .arg(path.as_ref())
            .output()
            .chain_err(|| "failed to construct diff-tree command")?;
        if !diff_tree.status.success() {
            bail!(ErrorKind::Git(format!("failed to get the diff for {} in the {} commit: {}",
                                         path.as_ref().to_string_lossy(),
                                         self.sha1,
                                         String::from_utf8_lossy(&diff_tree.stderr))));
        }

        Ok(String::from_utf8_lossy(&diff_tree.stdout).into_owned())
    }
}

/// Parse the octal digit from an ASCII value.
fn parse_octal_digit(ch_digit: u8) -> u8 {
    assert!(ZERO <= ch_digit,
            "octal character out of range: {}",
            ch_digit);
    assert!(ch_digit <= SEVEN,
            "octal character out of range: {}",
            ch_digit);

    ch_digit - ZERO
}

/// Parse an octal-escaped byte from a bytestream.
fn parse_octal<I>(iter: &mut I, digit: u8) -> u8
    where I: Iterator<Item = u8>,
{
    let sfd = parse_octal_digit(digit);
    let ed = parse_octal_digit(iter.next().expect("expected an eights-digit for an octal escape"));
    let od = parse_octal_digit(iter.next().expect("expected a ones-digit for an octal escape"));

    64 * sfd + 8 * ed + od
}

#[cfg(test)]
mod tests {
    use crates::git_workarea::{CommitId, GitContext, Identity};

    use commit::{Commit, StatusChange};

    use std::path::Path;

    fn make_context() -> GitContext {
        let gitdir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/.git"));
        if !gitdir.exists() {
            panic!("The tests must be run from a git checkout.");
        }

        GitContext::new(gitdir)
    }

    static ROOT_COMMIT: &'static str = "7531e6df007ca1130e5d64b8627b3288844e38a4";

    #[test]
    fn test_commit_root() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(ROOT_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), ROOT_COMMIT);
        assert_eq!(commit.message, "license: add Apache v2 + MIT licenses\n");
        assert_eq!(commit.parents.len(), 0);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 2);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "16fe87b06e802f094b3fbb0894b137bca2b16ef1");
        assert_eq!(diff.name.as_str(), "LICENSE-APACHE");
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "8f6f4b4a936616349e1f2846632c95cff33a3c5d");
        assert_eq!(diff.name.as_str(), "LICENSE-MIT");
        assert_eq!(diff.status, StatusChange::Added);
    }

    static SECOND_COMMIT: &'static str = "7f0d58bf407b0c80a9daadae88e7541f152ef371";

    #[test]
    fn test_commit_regular() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(SECOND_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), SECOND_COMMIT);
        assert_eq!(commit.message, "cargo: add boilerplate\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), ROOT_COMMIT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 4);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "fa8d85ac52f19959d6fc9942c265708b4b3c2b04");
        assert_eq!(diff.name.as_str(), ".gitignore");
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e31460ae61b1d69c6d0a58f9b74ae94d463c49bb");
        assert_eq!(diff.name.as_str(), "Cargo.toml");
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[2];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "b3c9194ad2d95d1498361f70a5480f0433fad1bb");
        assert_eq!(diff.name.as_str(), "rustfmt.toml");
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[3];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "584691748770380d5b70deb5f489b0c09229404e");
        assert_eq!(diff.name.as_str(), "src/lib.rs");
        assert_eq!(diff.status, StatusChange::Added);
    }

    static CHANGES_COMMIT: &'static str = "5fa80c2ab7df3c1d18c5ff7840a1ef051a1a1f21";
    static CHANGES_COMMIT_PARENT: &'static str = "89e44cb662b4a2e6b8f0333f84e5cc4ac818cc96";

    #[test]
    fn test_commit_regular_with_changes() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(CHANGES_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), CHANGES_COMMIT);
        assert_eq!(commit.message,
                   "Identity: use over strings where it matters\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), CHANGES_COMMIT_PARENT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 2);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "5710fe019e3de5e07f2bd1a5bcfd2d462834e9d8");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "db2811cc69a6fda16f6ef5d08cfc7780b46331d7");
        assert_eq!(diff.name.as_str(), "src/context.rs");
        assert_eq!(diff.status, StatusChange::Modified(None));

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "2acafbc5acd2e7b70260a3e39a1a752cc46cf953");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "af3a45a85ac6ba0d026279adac509c56308a3e26");
        assert_eq!(diff.name.as_str(), "src/run.rs");
        assert_eq!(diff.status, StatusChange::Modified(None));
    }

    static QUOTED_PATHS: &'static str = "f536f44cf96b82e479d4973d5ea1cf78058bd1fb";
    static QUOTED_PATHS_PARENT: &'static str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";

    #[test]
    fn test_commit_quoted_paths() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(QUOTED_PATHS)).unwrap();

        assert_eq!(commit.sha1.as_str(), QUOTED_PATHS);
        assert_eq!(commit.message,
                   "commit with invalid characters in path names\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), QUOTED_PATHS_PARENT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 6);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "\"control-character-\\003\"");
        assert_eq!(diff.name.as_bytes(), "control-character-\x03".as_bytes());
        assert_eq!(diff.status, StatusChange::Added);

        let mut invalid_utf8_raw = "invalid-utf8-".bytes().collect::<Vec<_>>();
        invalid_utf8_raw.push(128);

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "\"invalid-utf8-\\200\"");
        assert_eq!(diff.name.as_bytes(), invalid_utf8_raw.as_slice());
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[2];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "\"non-ascii-\\303\\251\"");
        assert_eq!(diff.name.as_bytes(), "non-ascii-é".as_bytes());
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[3];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "with whitespace");
        assert_eq!(diff.name.as_bytes(), "with whitespace".as_bytes());
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[4];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "with-dollar-$");
        assert_eq!(diff.name.as_bytes(), "with-dollar-$".as_bytes());
        assert_eq!(diff.status, StatusChange::Added);

        let diff = &commit.diffs[5];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391");
        assert_eq!(diff.name.as_str(), "\"with-quote-\\\"\"");
        assert_eq!(diff.name.as_bytes(), "with-quote-\"".as_bytes());
        assert_eq!(diff.status, StatusChange::Added);
    }

    static REGULAR_MERGE_COMMIT: &'static str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";
    static REGULAR_MERGE_PARENT1: &'static str = "3a22ca19fda09183da2faab60819ff6807568acd";
    static REGULAR_MERGE_PARENT2: &'static str = "d02f015907371738253a22b9a7fec78607a969b2";

    #[test]
    fn test_commit_merge() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(REGULAR_MERGE_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), REGULAR_MERGE_COMMIT);
        assert_eq!(commit.message,
                   "Merge commit 'd02f015907371738253a22b9a7fec78607a969b2' into \
                    tests/commit/merge\n\n* commit 'd02f015907371738253a22b9a7fec78607a969b2':\n  \
                    test-refs: non-root commit\n");
        assert_eq!(commit.parents.len(), 2);
        assert_eq!(commit.parents[0].as_str(), REGULAR_MERGE_PARENT1);
        assert_eq!(commit.parents[1].as_str(), REGULAR_MERGE_PARENT2);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 0);
    }

    static NO_HISTORY_MERGE_COMMIT: &'static str = "018ef4b25b978e194712e57a8f71d67427ecc065";
    static NO_HISTORY_MERGE_PARENT1: &'static str = "969b794ea82ce51d1555852de33bfcb63dfec969";
    static NO_HISTORY_MERGE_PARENT2: &'static str = "8805c7ae76329a937960448908f4618214fd3546";

    #[test]
    fn test_commit_merge_no_history() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(NO_HISTORY_MERGE_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), NO_HISTORY_MERGE_COMMIT);
        assert_eq!(commit.message,
                   "Merge branch 'tests/commit/no_history' into tests/commit/merge\n\n* \
                    tests/commit/no_history:\n  tests: a commit with new history\n");
        assert_eq!(commit.parents.len(), 2);
        assert_eq!(commit.parents[0].as_str(), NO_HISTORY_MERGE_PARENT1);
        assert_eq!(commit.parents[1].as_str(), NO_HISTORY_MERGE_PARENT2);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 0);
    }

    static CONFLICT_MERGE_COMMIT: &'static str = "969b794ea82ce51d1555852de33bfcb63dfec969";
    static CONFLICT_MERGE_PARENT1: &'static str = "fb11c6970f4aff1e16ea85ba17529377b62310b7";
    static CONFLICT_MERGE_PARENT2: &'static str = "bed4760e60c8276e6db53c6c2b641933d2938510";

    #[test]
    fn test_commit_merge_conflict() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(CONFLICT_MERGE_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), CONFLICT_MERGE_COMMIT);
        assert_eq!(commit.message,
                   "Merge branch 'test/commit/merge/conflict' into tests/commit/merge\n\n* \
                    test/commit/merge/conflict:\n  content: cause some conflicts\n");
        assert_eq!(commit.parents.len(), 2);
        assert_eq!(commit.parents[0].as_str(), CONFLICT_MERGE_PARENT1);
        assert_eq!(commit.parents[1].as_str(), CONFLICT_MERGE_PARENT2);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 2);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "829636d299136b6651fd19aa6ac3500c7d50bf10");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "195cc9d0aeb7324584700f2d17940d6a218a36f2");
        assert_eq!(diff.name.as_str(), "content");
        assert_eq!(diff.status, StatusChange::Modified(None));

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "925b35841fdd59e002bc5b3e685dc158bdbe6ebf");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "195cc9d0aeb7324584700f2d17940d6a218a36f2");
        assert_eq!(diff.name.as_str(), "content");
        assert_eq!(diff.status, StatusChange::Modified(None));
    }

    static EVIL_MERGE_COMMIT: &'static str = "fb11c6970f4aff1e16ea85ba17529377b62310b7";
    static EVIL_MERGE_PARENT1: &'static str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";
    static EVIL_MERGE_PARENT2: &'static str = "27ff3ef5532d76afa046f76f4dd8f588dc3e83c3";

    #[test]
    fn test_commit_merge_evil() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(EVIL_MERGE_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), EVIL_MERGE_COMMIT);
        assert_eq!(commit.message,
                   "Merge commit '27ff3ef5532d76afa046f76f4dd8f588dc3e83c3' into \
                    tests/commit/merge\n\n* commit '27ff3ef5532d76afa046f76f4dd8f588dc3e83c3':\n  \
                    test-refs: test target commit\n");
        assert_eq!(commit.parents.len(), 2);
        assert_eq!(commit.parents[0].as_str(), EVIL_MERGE_PARENT1);
        assert_eq!(commit.parents[1].as_str(), EVIL_MERGE_PARENT2);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 2);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "2ef267e25bd6c6a300bb473e604b092b6a48523b");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "829636d299136b6651fd19aa6ac3500c7d50bf10");
        assert_eq!(diff.name.as_str(), "content");
        assert_eq!(diff.status, StatusChange::Modified(None));

        let diff = &commit.diffs[1];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "829636d299136b6651fd19aa6ac3500c7d50bf10");
        assert_eq!(diff.name.as_str(), "content");
        assert_eq!(diff.status, StatusChange::Added);
    }

    static SMALL_DIFF_COMMIT: &'static str = "22324be5cac6a797a3c5f3633e4409840e02eae9";
    static SMALL_DIFF_PARENT: &'static str = "1ff04953f1b8dd7f01ecfe51ee962c47cea50e28";

    #[test]
    fn test_file_patch() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(SMALL_DIFF_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), SMALL_DIFF_COMMIT);
        assert_eq!(commit.message, "commit: use %n rather than \\n\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), SMALL_DIFF_PARENT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 1);

        assert_eq!(commit.file_patch("src/commit.rs").unwrap(),
                   concat!("diff --git a/src/commit.rs b/src/commit.rs\n",
                           "index 2cb15f6..6303de0 100644\n",
                           "--- a/src/commit.rs\n",
                           "+++ b/src/commit.rs\n",
                           "@@ -109,7 +109,7 @@ impl Commit {\n",
                           "     pub fn new(ctx: &GitContext, sha1: &str) -> Result<Self, \
                            Error> {\n",
                           "         let commit_info = try!(ctx.git()\n",
                           "             .arg(\"log\")\n",
                           "-            .arg(\"--pretty=%P\\n%an\\n%ae\\n%cn\\n%ce\\n%h\")\n",
                           "+            .arg(\"--pretty=%P%n%an%n%ae%n%cn%n%ce%n%h\")\n",
                           "             .arg(\"-n\").arg(\"1\")\n",
                           "             .arg(sha1)\n",
                           "             .output());\n"));
    }

    static ADD_BINARY_COMMIT: &'static str = "ff7ee6b5c822440613a01bfcf704e18cff4def72";
    static CHANGE_BINARY_COMMIT: &'static str = "8f25adf0f878bdf88b609ca7ef67f235c5237602";
    static ADD_BINARY_PARENT: &'static str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";

    #[test]
    fn test_file_patch_binary_add() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(ADD_BINARY_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), ADD_BINARY_COMMIT);
        assert_eq!(commit.message, "binary-data: add some binary data\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), ADD_BINARY_PARENT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 1);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "000000");
        assert_eq!(diff.old_blob.as_str(),
                   "0000000000000000000000000000000000000000");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "7624577a217a08f1103d6c190eb11beab1081744");
        assert_eq!(diff.name.as_str(), "binary-data");
        assert_eq!(diff.status, StatusChange::Added);

        assert_eq!(commit.file_patch("binary-data").unwrap(),
                   concat!("diff --git a/binary-data b/binary-data\n",
                           "new file mode 100644\n",
                           "index 0000000..7624577\n",
                           "Binary files /dev/null and b/binary-data differ\n"));
    }

    #[test]
    fn test_file_patch_binary_change() {
        let ctx = make_context();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let commit = Commit::new(&ctx, &CommitId::new(CHANGE_BINARY_COMMIT)).unwrap();

        assert_eq!(commit.sha1.as_str(), CHANGE_BINARY_COMMIT);
        assert_eq!(commit.message, "binary-data: change some binary data\n");
        assert_eq!(commit.parents.len(), 1);
        assert_eq!(commit.parents[0].as_str(), ADD_BINARY_COMMIT);
        assert_eq!(commit.author, ben);
        assert_eq!(commit.committer, ben);

        assert_eq!(commit.diffs.len(), 1);

        let diff = &commit.diffs[0];
        assert_eq!(diff.old_mode, "100644");
        assert_eq!(diff.old_blob.as_str(),
                   "7624577a217a08f1103d6c190eb11beab1081744");
        assert_eq!(diff.new_mode, "100644");
        assert_eq!(diff.new_blob.as_str(),
                   "53c235a627abeda83e26d6d53cdebf72b52f3c3d");
        assert_eq!(diff.name.as_str(), "binary-data");
        assert_eq!(diff.status, StatusChange::Modified(None));

        assert_eq!(commit.file_patch("binary-data").unwrap(),
                   concat!("diff --git a/binary-data b/binary-data\n",
                           "index 7624577..53c235a 100644\n",
                           "Binary files a/binary-data and b/binary-data differ\n"));
    }
}