git_checks_core/
commit.rs

1// Copyright Kitware, Inc.
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9use std::ffi::OsStr;
10use std::fmt::{self, Display};
11use std::os::unix::ffi::OsStrExt;
12use std::path::{Path, PathBuf};
13
14use git_workarea::{CommitId, GitContext, GitError, GitWorkArea, Identity, WorkAreaError};
15use itertools::Itertools;
16use lazy_static::lazy_static;
17use regex::Regex;
18use thiserror::Error;
19
20/// Errors which can occur when working with a commit for a check.
21#[derive(Debug, Error)]
22#[non_exhaustive]
23pub enum CommitError {
24    /// Command preparation failure.
25    #[error("git error: {}", source)]
26    Git {
27        /// The cause of the error.
28        #[from]
29        source: GitError,
30    },
31    /// File name parsing failure.
32    #[error("filename error: {}", source)]
33    FileName {
34        /// The cause of the error.
35        #[from]
36        source: FileNameError,
37    },
38    /// Failure to extract commit metadata.
39    #[error("failed to fetch metadata on the {} commit: {}", ref_, output)]
40    CommitMetadata {
41        /// The commit that was being queried.
42        ref_: CommitId,
43        /// Git's output for the error.
44        output: String,
45    },
46    /// The commit metadata output did not match the expected format.
47    #[error("unexpected output from `git log --pretty=<metadata>`: {}", output)]
48    CommitMetadataOutput {
49        /// Git's actual output.
50        output: String,
51    },
52    /// Failure to extract commit message.
53    #[error("failed to fetch message on the {} commit: {}", ref_, output)]
54    CommitMessage {
55        /// The commit that was being queried.
56        ref_: CommitId,
57        /// Git's output for the error.
58        output: String,
59    },
60    /// Failure to list revisions in a branch.
61    #[error(
62        "failed to list revisions of the {} commit (merge base parent: {}): {}",
63        base_rev,
64        merge_base.as_ref().map_or("<none>", CommitId::as_str),
65        output
66    )]
67    RevList {
68        /// The commit being queried.
69        base_rev: CommitId,
70        /// The merge base being tested.
71        merge_base: Option<CommitId>,
72        /// Git's output for the error.
73        output: String,
74    },
75    /// Failure to check the ancestory in a branch.
76    #[error(
77        "failed to determine if the {} commit is an ancestor of {}: {}",
78        best_rev,
79        merge_base,
80        output
81    )]
82    AncestorCheck {
83        /// The commit being queried.
84        best_rev: CommitId,
85        /// The merge base being tested.
86        merge_base: CommitId,
87        /// Git's output for the error.
88        output: String,
89    },
90    /// Failure to determine the merge base between two commits.
91    #[error(
92        "failed to find the merge-base between {} and {}: {}",
93        base,
94        head,
95        output
96    )]
97    MergeBase {
98        /// The base commit.
99        base: CommitId,
100        /// The head of the topic.
101        head: CommitId,
102        /// Git's output for the error.
103        output: String,
104    },
105    /// Failure to tree diff of a commit.
106    #[error(
107        "failed to get the tree diff information for the {} commit (against {}): {}",
108        commit,
109        base.as_ref().map(CommitId::as_str).unwrap_or("parent"),
110        output
111    )]
112    DiffTree {
113        /// The commit being queried.
114        commit: CommitId,
115        /// The "old" commit of the branch (the parent of the commit if not set).
116        base: Option<CommitId>,
117        /// Git's output for the error.
118        output: String,
119    },
120    /// Failure to get the patch of a commit.
121    #[error(
122        "failed to get the diff patch for the {} commit (against {}) for {}: {}",
123        commit,
124        base.as_ref().map(CommitId::as_str).unwrap_or("parent"),
125        path.display(),
126        output
127    )]
128    DiffPatch {
129        /// The commit being queried.
130        commit: CommitId,
131        /// The "old" commit of the branch (the parent of the commit if not set).
132        base: Option<CommitId>,
133        /// The path being queried.
134        path: PathBuf,
135        /// Git's output for the error.
136        output: String,
137    },
138}
139
140impl CommitError {
141    fn commit_metadata(ref_: CommitId, output: &[u8]) -> Self {
142        CommitError::CommitMetadata {
143            ref_,
144            output: String::from_utf8_lossy(output).into(),
145        }
146    }
147
148    fn commit_metadata_output(output: String) -> Self {
149        CommitError::CommitMetadataOutput {
150            output,
151        }
152    }
153
154    fn commit_message(ref_: CommitId, output: &[u8]) -> Self {
155        CommitError::CommitMessage {
156            ref_,
157            output: String::from_utf8_lossy(output).into(),
158        }
159    }
160
161    fn rev_list(base_rev: CommitId, merge_base: Option<CommitId>, output: &[u8]) -> Self {
162        CommitError::RevList {
163            base_rev,
164            merge_base,
165            output: String::from_utf8_lossy(output).into(),
166        }
167    }
168
169    fn ancestor_check(best_rev: CommitId, merge_base: CommitId, output: &[u8]) -> Self {
170        CommitError::AncestorCheck {
171            best_rev,
172            merge_base,
173            output: String::from_utf8_lossy(output).into(),
174        }
175    }
176
177    fn merge_base(base: CommitId, head: CommitId, output: &[u8]) -> Self {
178        CommitError::MergeBase {
179            base,
180            head,
181            output: String::from_utf8_lossy(output).into(),
182        }
183    }
184
185    fn diff_tree(commit: CommitId, base: Option<CommitId>, output: &[u8]) -> Self {
186        CommitError::DiffTree {
187            commit,
188            base,
189            output: String::from_utf8_lossy(output).into(),
190        }
191    }
192
193    fn diff_patch(commit: CommitId, base: Option<CommitId>, path: PathBuf, output: &[u8]) -> Self {
194        CommitError::DiffPatch {
195            commit,
196            base,
197            path,
198            output: String::from_utf8_lossy(output).into(),
199        }
200    }
201}
202
203/// Ways a file can be changed in a commit.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum StatusChange {
206    /// The file was added in this commit.
207    Added,
208    /// The file was copied in this commit with the given similarity index.
209    Copied(u8),
210    /// The file was deleted in this commit.
211    Deleted,
212    /// The file was copied in this commit with an optional similarity index.
213    Modified(Option<u8>),
214    /// The file was renamed in this commit with the given similarity index.
215    Renamed(u8),
216    /// The path changed type (types include directory, symlinks, and files).
217    TypeChanged,
218    /// The file is unmerged in the working tree.
219    Unmerged,
220    /// Git doesn't know what's going on.
221    Unknown,
222}
223
224impl From<char> for StatusChange {
225    fn from(c: char) -> Self {
226        match c {
227            'A' => StatusChange::Added,
228            'C' => StatusChange::Copied(0),
229            'D' => StatusChange::Deleted,
230            'M' => StatusChange::Modified(None),
231            'R' => StatusChange::Renamed(0),
232            'T' => StatusChange::TypeChanged,
233            'U' => StatusChange::Unmerged,
234            'X' => StatusChange::Unknown,
235            _ => unreachable!("the regex does not match any other characters"),
236        }
237    }
238}
239
240/// Errors which may occur dring filename parsing.
241#[derive(Debug, Error)]
242#[non_exhaustive]
243pub enum FileNameError {
244    #[doc(hidden)]
245    #[error("invalid leading octal digit: {}", _0)]
246    InvalidLeadingOctalDigit(u8),
247    #[doc(hidden)]
248    #[error("invalid octal digit: {}", _0)]
249    InvalidOctalDigit(u8),
250    #[doc(hidden)]
251    #[error("invalid escape character: {}", _0)]
252    InvalidEscape(u8),
253    #[doc(hidden)]
254    #[error("trailing backslash in file name")]
255    TrailingBackslash,
256    #[doc(hidden)]
257    #[error("missing eights digit in octal escape")]
258    MissingEightsDigit,
259    #[doc(hidden)]
260    #[error("missing ones digit in octal escape")]
261    MissingOnesDigit,
262}
263
264/// A representation of filenames as given by Git.
265///
266/// Git supports filenames with control characters and other non-Unicode byte sequence which are
267/// quoted when listed in certain Git command outputs. This enumeration smooths over these
268/// differences and offers accessors to the file name in different representations.
269///
270/// Generally, the `as_` methods should be preferred to pattern matching on this enumeration.
271#[derive(Debug, Clone, Eq)]
272pub enum FileName {
273    #[doc(hidden)]
274    Normal(String),
275    #[doc(hidden)]
276    Quoted { raw: Vec<u8>, name: String },
277}
278
279impl FileName {
280    /// Parse a path from Git.
281    pub fn new<P>(path: P) -> Result<Self, FileNameError>
282    where
283        P: AsRef<str>,
284    {
285        Self::new_impl(path.as_ref())
286    }
287
288    /// The implementation of the `new` method.
289    fn new_impl(path: &str) -> Result<Self, FileNameError> {
290        if path.starts_with('"') {
291            let raw = path
292                // Operate on bytes.
293                .bytes()
294                // Strip the quotes from the path.
295                .skip(1)
296                .dropping_back(1)
297                // Parse escaped characters.
298                .batching(|iter| {
299                    let n = iter.next();
300                    if let Some(b'\\') = n {
301                        match iter.next() {
302                            Some(b'\\') => Some(Ok(b'\\')),
303                            Some(b't') => Some(Ok(b'\t')),
304                            Some(b'n') => Some(Ok(b'\n')),
305                            Some(b'"') => Some(Ok(b'"')),
306                            // The first character in an octal sequence cannot have its
307                            // high-order bit set.
308                            Some(sfd @ b'0') | Some(sfd @ b'1') | Some(sfd @ b'2')
309                            | Some(sfd @ b'3') => Some(Self::parse_octal(iter, sfd)),
310                            Some(sfd @ b'4') | Some(sfd @ b'5') | Some(sfd @ b'6')
311                            | Some(sfd @ b'7') => {
312                                Some(Err(FileNameError::InvalidLeadingOctalDigit(sfd)))
313                            },
314                            Some(c) => Some(Err(FileNameError::InvalidEscape(c))),
315                            None => Some(Err(FileNameError::TrailingBackslash)),
316                        }
317                    } else {
318                        n.map(Ok)
319                    }
320                })
321                .collect::<Result<_, _>>()?;
322            Ok(FileName::Quoted {
323                raw,
324                name: path.into(),
325            })
326        } else {
327            Ok(FileName::Normal(path.into()))
328        }
329    }
330
331    /// Parse the octal digit from an ASCII value.
332    fn parse_octal_digit(ch_digit: u8) -> Result<u8, FileNameError> {
333        if !(b'0'..=b'7').contains(&ch_digit) {
334            Err(FileNameError::InvalidOctalDigit(ch_digit))
335        } else {
336            Ok(ch_digit - b'0')
337        }
338    }
339
340    /// Parse an octal-escaped byte from a bytestream.
341    fn parse_octal<I>(iter: &mut I, digit: u8) -> Result<u8, FileNameError>
342    where
343        I: Iterator<Item = u8>,
344    {
345        let sfd = Self::parse_octal_digit(digit)?;
346        let ed = Self::parse_octal_digit(iter.next().ok_or(FileNameError::MissingEightsDigit)?)?;
347        let od = Self::parse_octal_digit(iter.next().ok_or(FileNameError::MissingOnesDigit)?)?;
348
349        Ok(64 * sfd + 8 * ed + od)
350    }
351
352    /// The file name as a `str`.
353    pub fn as_str(&self) -> &str {
354        self.as_ref()
355    }
356
357    /// The file name as a `Path`.
358    pub fn as_path(&self) -> &Path {
359        self.as_ref()
360    }
361
362    /// The raw bytes of the file name.
363    pub fn as_bytes(&self) -> &[u8] {
364        self.as_ref()
365    }
366}
367
368impl Display for FileName {
369    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
370        write!(f, "{}", self.as_str())
371    }
372}
373
374impl PartialEq for FileName {
375    fn eq(&self, rhs: &Self) -> bool {
376        self.as_bytes() == rhs.as_bytes()
377    }
378}
379
380impl AsRef<str> for FileName {
381    fn as_ref(&self) -> &str {
382        match *self {
383            FileName::Normal(ref name)
384            | FileName::Quoted {
385                ref name, ..
386            } => name,
387        }
388    }
389}
390
391impl AsRef<[u8]> for FileName {
392    fn as_ref(&self) -> &[u8] {
393        match *self {
394            FileName::Normal(ref name) => name.as_bytes(),
395            FileName::Quoted {
396                ref raw, ..
397            } => raw,
398        }
399    }
400}
401
402impl AsRef<OsStr> for FileName {
403    fn as_ref(&self) -> &OsStr {
404        match *self {
405            FileName::Normal(ref name) => name.as_ref(),
406            FileName::Quoted {
407                ref raw, ..
408            } => OsStr::from_bytes(raw),
409        }
410    }
411}
412
413impl AsRef<Path> for FileName {
414    fn as_ref(&self) -> &Path {
415        Path::new(self)
416    }
417}
418
419/// Information about a file that changed in a commit.
420#[derive(Debug)]
421pub struct DiffInfo {
422    /// The mode of the file in the parent commit.
423    pub old_mode: String,
424    /// The blob object of the file in the parent commit.
425    pub old_blob: CommitId,
426    /// The mode of the file in the current commit.
427    pub new_mode: String,
428    /// The blob object of the file in the current commit.
429    pub new_blob: CommitId,
430
431    /// The name of the file in the current commit.
432    pub name: FileName,
433    /// The status mode of the file.
434    pub status: StatusChange,
435}
436
437/// A trait for representing the content of a diff.
438///
439/// This may be used for checks which do not require any metadata.
440pub trait Content {
441    /// A workarea which may be used to work on the content.
442    fn workarea(&self, ctx: &GitContext) -> Result<GitWorkArea, WorkAreaError>;
443
444    /// The SHA1 of the commit (if the content is of a commit).
445    fn sha1(&self) -> Option<&CommitId>;
446
447    /// The differences in the content.
448    fn diffs(&self) -> &Vec<DiffInfo>;
449
450    /// Return a vector of files modified in a diff.
451    ///
452    /// This excludes paths which are either not actually files (e.g., submodules and symlinks), as
453    /// well as paths which were deleted in this commit.
454    fn modified_files(&self) -> Vec<&FileName> {
455        modified_files(self.diffs())
456    }
457
458    /// Get the patch difference for the given path.
459    fn path_diff(&self, path: &FileName) -> Result<String, CommitError>;
460}
461
462/// Representation of a commit with information useful for commit checks.
463#[derive(Debug)]
464pub struct Commit {
465    /// The SHA1 of the commit.
466    pub sha1: CommitId,
467
468    /// The commit message.
469    pub message: String,
470
471    /// The parents of the commit.
472    pub parents: Vec<CommitId>,
473    /// Information about files that changed in this commit.
474    pub diffs: Vec<DiffInfo>,
475
476    /// The identity of the author.
477    pub author: Identity,
478    /// The identity of the commiter.
479    pub committer: Identity,
480
481    /// The git context which contains the commit.
482    ctx: GitContext,
483}
484
485lazy_static! {
486    static ref DIFF_TREE_LINE_RE: Regex = Regex::new(
487        "^:+\
488         (?P<old_modes>[0-7]{6}( [0-7]{6})*) \
489         (?P<new_mode>[0-7]{6}) \
490         (?P<old_blobs>[0-9a-f]{40}( [0-9a-f]{40})*) \
491         (?P<new_blob>[0-9a-f]{40}) \
492         (?P<status>[ACDMRTUX]+)\
493         \t(?P<name>.*)$",
494    )
495    .unwrap();
496}
497
498impl Commit {
499    /// Create a new commit from the given context for the SHA1.
500    pub fn new(ctx: &GitContext, sha1: &CommitId) -> Result<Self, CommitError> {
501        let commit_info = ctx
502            .git()
503            .arg("log")
504            .arg("--pretty=%P%n%an%n%ae%n%cn%n%ce")
505            .arg("--max-count=1")
506            .arg(sha1.as_str())
507            .output()
508            .map_err(|err| GitError::subcommand("log --pretty=<metadata>", err))?;
509        if !commit_info.status.success() {
510            return Err(CommitError::commit_metadata(
511                sha1.clone(),
512                &commit_info.stderr,
513            ));
514        }
515        let commit_info_output = String::from_utf8_lossy(&commit_info.stdout);
516        let lines = commit_info_output.lines().collect::<Vec<_>>();
517
518        if lines.len() != 5 {
519            return Err(CommitError::commit_metadata_output(
520                commit_info_output.into(),
521            ));
522        }
523
524        let commit_message = ctx
525            .git()
526            .arg("log")
527            .arg("--pretty=%B")
528            .arg("--max-count=1")
529            .arg(sha1.as_str())
530            .output()
531            .map_err(|err| GitError::subcommand("log --pretty=<message>", err))?;
532        if !commit_message.status.success() {
533            return Err(CommitError::commit_message(
534                sha1.clone(),
535                &commit_message.stderr,
536            ));
537        }
538        let mut commit_message = String::from_utf8_lossy(&commit_message.stdout).into_owned();
539        // Remove the newline that `git log` adds to the message.
540        commit_message.pop();
541
542        let parents = lines[0].split_whitespace().map(CommitId::new).collect();
543
544        Ok(Self {
545            sha1: sha1.clone(),
546            message: commit_message,
547
548            parents,
549            diffs: extract_diffs(ctx, None, sha1)?,
550
551            author: Identity::new(lines[1], lines[2]),
552            committer: Identity::new(lines[3], lines[4]),
553
554            ctx: ctx.clone(),
555        })
556    }
557
558    /// Get the patch difference for the given path.
559    pub fn file_patch<P>(&self, path: P) -> Result<String, CommitError>
560    where
561        P: AsRef<OsStr>,
562    {
563        file_patch(&self.ctx, None, &self.sha1, path.as_ref())
564    }
565}
566
567impl Content for Commit {
568    fn workarea(&self, ctx: &GitContext) -> Result<GitWorkArea, WorkAreaError> {
569        ctx.prepare(&self.sha1)
570    }
571
572    fn sha1(&self) -> Option<&CommitId> {
573        Some(&self.sha1)
574    }
575
576    fn diffs(&self) -> &Vec<DiffInfo> {
577        &self.diffs
578    }
579
580    fn path_diff(&self, path: &FileName) -> Result<String, CommitError> {
581        self.file_patch(path)
582    }
583}
584
585/// Representation of a topic with information useful for commit checks.
586#[derive(Debug)]
587pub struct Topic {
588    /// The SHA1 of the base commit.
589    pub base: CommitId,
590    /// The SHA1 of the commit.
591    pub sha1: CommitId,
592
593    /// Information about files that changed in this commit.
594    pub diffs: Vec<DiffInfo>,
595
596    /// The git context which contains the commit.
597    ctx: GitContext,
598}
599
600impl Topic {
601    /// Create a new commit from the given context for the SHA1.
602    pub fn new(ctx: &GitContext, base: &CommitId, sha1: &CommitId) -> Result<Self, CommitError> {
603        let merge_base = Self::best_merge_base(ctx, base, sha1)?;
604
605        Ok(Self {
606            diffs: extract_diffs(ctx, Some(&merge_base), sha1)?,
607
608            base: merge_base,
609            sha1: sha1.clone(),
610
611            ctx: ctx.clone(),
612        })
613    }
614
615    fn best_merge_base(
616        ctx: &GitContext,
617        base: &CommitId,
618        sha1: &CommitId,
619    ) -> Result<CommitId, CommitError> {
620        let merge_base = ctx
621            .git()
622            .arg("merge-base")
623            .arg("--all")
624            .arg(base.as_str())
625            .arg(sha1.as_str())
626            .output()
627            .map_err(|err| GitError::subcommand("merge-base --all", err))?;
628
629        Ok(if let Some(1) = merge_base.status.code() {
630            // We failed to find a merge base; this topic creates a new root commit. To allow this,
631            // just create a topic setup which uses the base as the merge base.
632            base.clone()
633        } else if merge_base.status.success() {
634            let merge_bases = String::from_utf8_lossy(&merge_base.stdout);
635
636            merge_bases.lines().map(CommitId::new).fold(
637                Ok(base.clone()) as Result<CommitId, CommitError>,
638                |best_mb, merge_base| {
639                    // Pass errors through the rest of the merge bases.
640                    let best = match best_mb {
641                        Ok(best) => best,
642                        err => return err,
643                    };
644
645                    // Try and parse the parent of the merge base; if this fails, we have a root
646                    // commit as a potential merge base.
647                    let rev_parse = ctx
648                        .git()
649                        .arg("rev-parse")
650                        .arg("--verify")
651                        .arg("--quiet")
652                        .arg(format!("{}~", merge_base))
653                        .output()
654                        .map_err(|err| GitError::subcommand("rev-parse", err))?;
655                    let merge_base_parent = if rev_parse.status.success() {
656                        Some(String::from(
657                            String::from_utf8_lossy(&rev_parse.stdout).trim(),
658                        ))
659                    } else {
660                        None
661                    };
662
663                    // Determine if the merge base is a first-parent ancestor.
664                    let mut refs = ctx.git();
665                    refs.arg("rev-list")
666                        .arg("--first-parent") // only look at first-parent history
667                        .arg("--reverse") // start with oldest commits
668                        .arg(base.as_str());
669                    if let Some(merge_base_parent) = merge_base_parent.as_ref() {
670                        refs.arg(format!("^{}", merge_base_parent));
671                    };
672
673                    let refs = refs
674                        .output()
675                        .map_err(|err| GitError::subcommand("rev-list", err))?;
676                    if !refs.status.success() {
677                        return Err(CommitError::rev_list(
678                            base.clone(),
679                            merge_base_parent.map(CommitId::new),
680                            &refs.stderr,
681                        ));
682                    }
683                    let refs = String::from_utf8_lossy(&refs.stdout);
684
685                    // If it is not a first-parent ancestor of base and our best is not already the
686                    // base itself, it is not preferred.
687                    if !refs.lines().any(|rev| rev == merge_base.as_str()) && &best != base {
688                        return Ok(best);
689                    }
690
691                    // If the best so far is an ancestor of this commit, use this commit instead.
692                    let is_ancestor = ctx
693                        .git()
694                        .arg("merge-base")
695                        .arg("--is-ancestor")
696                        .arg(best.as_str())
697                        .arg(merge_base.as_str())
698                        .output()
699                        .map_err(|err| GitError::subcommand("merge-base --is-ancestor", err))?;
700                    if let Some(1) = is_ancestor.status.code() {
701                        Ok(merge_base)
702                    } else if is_ancestor.status.success() {
703                        Ok(best)
704                    } else {
705                        Err(CommitError::ancestor_check(
706                            best,
707                            merge_base,
708                            &is_ancestor.stderr,
709                        ))
710                    }
711                },
712            )?
713        } else {
714            return Err(CommitError::merge_base(
715                base.clone(),
716                sha1.clone(),
717                &merge_base.stderr,
718            ));
719        })
720    }
721
722    /// Get the patch difference for the given path.
723    pub fn file_patch<P>(&self, path: P) -> Result<String, CommitError>
724    where
725        P: AsRef<OsStr>,
726    {
727        file_patch(&self.ctx, Some(&self.base), &self.sha1, path.as_ref())
728    }
729}
730
731impl Content for Topic {
732    fn workarea(&self, ctx: &GitContext) -> Result<GitWorkArea, WorkAreaError> {
733        ctx.prepare(&self.sha1)
734    }
735
736    fn sha1(&self) -> Option<&CommitId> {
737        None
738    }
739
740    fn diffs(&self) -> &Vec<DiffInfo> {
741        &self.diffs
742    }
743
744    fn path_diff(&self, path: &FileName) -> Result<String, CommitError> {
745        self.file_patch(path)
746    }
747}
748
749fn modified_files(diffs: &[DiffInfo]) -> Vec<&FileName> {
750    diffs
751        .iter()
752        .filter_map(|diff| {
753            // Ignore submodules.
754            if diff.new_mode == "160000" {
755                return None;
756            }
757
758            // Ignore symlinks.
759            if diff.new_mode == "120000" {
760                return None;
761            }
762
763            match diff.status {
764                StatusChange::Added | StatusChange::Modified(_) => Some(&diff.name),
765                // Ignore paths which are not either added or modified.
766                _ => None,
767            }
768        })
769        .unique_by(|diff| diff.as_bytes())
770        .collect::<Vec<_>>()
771}
772
773/// Gather the diff information from the commit.
774fn extract_diffs(
775    ctx: &GitContext,
776    base: Option<&CommitId>,
777    sha1: &CommitId,
778) -> Result<Vec<DiffInfo>, CommitError> {
779    let mut diff_tree_cmd = ctx.git();
780    diff_tree_cmd
781        .arg("diff-tree")
782        .arg("--no-commit-id")
783        .arg("--root")
784        .arg("-c") // Show merge commit diffs.
785        .arg("-r"); // Recurse into sub-trees.
786    if let Some(base) = base {
787        diff_tree_cmd.arg(base.as_str());
788    }
789    let diff_tree = diff_tree_cmd
790        .arg(sha1.as_str())
791        .output()
792        .map_err(|err| GitError::subcommand("diff-tree", err))?;
793    if !diff_tree.status.success() {
794        return Err(CommitError::diff_tree(
795            sha1.clone(),
796            base.cloned(),
797            &diff_tree.stderr,
798        ));
799    }
800    let diffs = String::from_utf8_lossy(&diff_tree.stdout);
801    let diff_lines = diffs.lines().filter_map(|l| DIFF_TREE_LINE_RE.captures(l));
802
803    Ok(diff_lines
804        .map(|diff| {
805            let old_modes = diff
806                .name("old_modes")
807                .expect("the diff regex should have a 'old_modes' group");
808            let new_mode = diff
809                .name("new_mode")
810                .expect("the diff regex should have a 'new_mode' group");
811            let old_blobs = diff
812                .name("old_blobs")
813                .expect("the diff regex should have a 'old_blobs' group");
814            let new_blob = diff
815                .name("new_blob")
816                .expect("the diff regex should have a 'new_blob' group");
817            let status = diff
818                .name("status")
819                .expect("the diff regex should have a 'status' group");
820            let raw_name = diff
821                .name("name")
822                .expect("the diff regex should have a 'name' group");
823
824            let name = FileName::new(raw_name.as_str())?;
825            let zip = itertools::multizip((
826                old_modes.as_str().split_whitespace(),
827                old_blobs.as_str().split_whitespace(),
828                status.as_str().chars(),
829            ));
830
831            Ok(zip.map(move |(m, b, s)| {
832                DiffInfo {
833                    old_mode: m.into(),
834                    new_mode: new_mode.as_str().into(),
835                    old_blob: CommitId::new(b),
836                    new_blob: CommitId::new(new_blob.as_str()),
837                    status: s.into(),
838                    name: name.clone(),
839                }
840            }))
841        })
842        .collect::<Result<Vec<_>, FileNameError>>()?
843        .into_iter()
844        .flatten()
845        .collect())
846}
847
848fn file_patch(
849    ctx: &GitContext,
850    base: Option<&CommitId>,
851    sha1: &CommitId,
852    path: &OsStr,
853) -> Result<String, CommitError> {
854    let mut diff_tree_cmd = ctx.git();
855    diff_tree_cmd
856        .arg("diff-tree")
857        .arg("--no-commit-id")
858        .arg("--root")
859        .arg("-p");
860    if let Some(base) = base {
861        diff_tree_cmd.arg(base.as_str());
862    }
863    let diff_tree = diff_tree_cmd
864        .arg(sha1.as_str())
865        .arg("--")
866        .arg(path)
867        .output()
868        .map_err(|err| GitError::subcommand("diff-tree -p", err))?;
869    if !diff_tree.status.success() {
870        return Err(CommitError::diff_patch(
871            sha1.clone(),
872            base.cloned(),
873            path.into(),
874            &diff_tree.stderr,
875        ));
876    }
877
878    Ok(String::from_utf8_lossy(&diff_tree.stdout).into_owned())
879}
880
881#[cfg(test)]
882mod tests {
883    use std::ffi::OsStr;
884    use std::path::Path;
885
886    use git_workarea::{CommitId, GitContext, Identity};
887
888    use crate::commit::{
889        self, Commit, CommitError, Content, DiffInfo, FileName, FileNameError, StatusChange, Topic,
890    };
891    use crate::test;
892
893    fn make_context() -> GitContext {
894        let gitdir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.git"));
895        if !gitdir.exists() {
896            panic!("The tests must be run from a git checkout.");
897        }
898
899        GitContext::new(gitdir)
900    }
901
902    fn compare_diffs(actual: &DiffInfo, expected: &DiffInfo) {
903        assert_eq!(actual.old_mode, expected.old_mode);
904        assert_eq!(actual.old_blob, expected.old_blob);
905        assert_eq!(actual.new_mode, expected.new_mode);
906        assert_eq!(actual.new_blob, expected.new_blob);
907        assert_eq!(actual.name, expected.name);
908        assert_eq!(actual.status, expected.status);
909    }
910
911    #[test]
912    fn test_status_characters() {
913        assert_eq!(StatusChange::from('A'), StatusChange::Added);
914        assert_eq!(StatusChange::from('C'), StatusChange::Copied(0));
915        assert_eq!(StatusChange::from('D'), StatusChange::Deleted);
916        assert_eq!(StatusChange::from('M'), StatusChange::Modified(None));
917        assert_eq!(StatusChange::from('R'), StatusChange::Renamed(0));
918        assert_eq!(StatusChange::from('T'), StatusChange::TypeChanged);
919        assert_eq!(StatusChange::from('U'), StatusChange::Unmerged);
920        assert_eq!(StatusChange::from('X'), StatusChange::Unknown);
921    }
922
923    #[test]
924    fn test_filename_parse_quoted() {
925        let name = FileName::new(r#""quoted-with-backslash-\\""#).unwrap();
926        assert_eq!(name.as_str(), r#""quoted-with-backslash-\\""#);
927        assert_eq!(name.as_path(), Path::new("quoted-with-backslash-\\"));
928        assert_eq!(name.as_bytes(), b"quoted-with-backslash-\\");
929        assert_eq!(name.to_string(), r#""quoted-with-backslash-\\""#);
930
931        let name = FileName::new(r#""quoted-with-tab-\t""#).unwrap();
932        assert_eq!(name.as_str(), r#""quoted-with-tab-\t""#);
933        assert_eq!(name.as_path(), Path::new("quoted-with-tab-\t"));
934        assert_eq!(name.as_bytes(), b"quoted-with-tab-\t");
935        assert_eq!(name.to_string(), r#""quoted-with-tab-\t""#);
936
937        let name = FileName::new(r#""quoted-with-newline-\n""#).unwrap();
938        assert_eq!(name.as_str(), r#""quoted-with-newline-\n""#);
939        assert_eq!(name.as_path(), Path::new("quoted-with-newline-\n"));
940        assert_eq!(name.as_bytes(), b"quoted-with-newline-\n");
941        assert_eq!(name.to_string(), r#""quoted-with-newline-\n""#);
942
943        let name = FileName::new(r#""quoted-with-quote-\"""#).unwrap();
944        assert_eq!(name.as_str(), r#""quoted-with-quote-\"""#);
945        assert_eq!(name.as_path(), Path::new("quoted-with-quote-\""));
946        assert_eq!(name.as_bytes(), b"quoted-with-quote-\"");
947        assert_eq!(name.to_string(), r#""quoted-with-quote-\"""#);
948
949        let name = FileName::new(r#""quoted-with-octal-\001""#).unwrap();
950        assert_eq!(name.as_str(), r#""quoted-with-octal-\001""#);
951        assert_eq!(name.as_path(), Path::new("quoted-with-octal-\x01"));
952        assert_eq!(name.as_bytes(), b"quoted-with-octal-\x01");
953        assert_eq!(name.to_string(), r#""quoted-with-octal-\001""#);
954    }
955
956    #[test]
957    fn test_filename_parse_quoted_invalid() {
958        let err = FileName::new(r#""quoted-with-trailing-backslash-\""#).unwrap_err();
959        if let FileNameError::TrailingBackslash = err {
960            // expected
961        } else {
962            panic!("unexpected error: {:?}", err);
963        }
964
965        let err = FileName::new(r#""quoted-with-bad-escape-\x""#).unwrap_err();
966        if let FileNameError::InvalidEscape(ch) = err {
967            assert_eq!(ch, b'x');
968        } else {
969            panic!("unexpected error: {:?}", err);
970        }
971
972        let err = FileName::new(r#""quoted-with-bad-leading-octal-\400""#).unwrap_err();
973        if let FileNameError::InvalidLeadingOctalDigit(ch) = err {
974            assert_eq!(ch, b'4');
975        } else {
976            panic!("unexpected error: {:?}", err);
977        }
978
979        let err = FileName::new(r#""quoted-with-bad-leading-octal-\500""#).unwrap_err();
980        if let FileNameError::InvalidLeadingOctalDigit(ch) = err {
981            assert_eq!(ch, b'5');
982        } else {
983            panic!("unexpected error: {:?}", err);
984        }
985
986        let err = FileName::new(r#""quoted-with-bad-leading-octal-\600""#).unwrap_err();
987        if let FileNameError::InvalidLeadingOctalDigit(ch) = err {
988            assert_eq!(ch, b'6');
989        } else {
990            panic!("unexpected error: {:?}", err);
991        }
992
993        let err = FileName::new(r#""quoted-with-bad-leading-octal-\700""#).unwrap_err();
994        if let FileNameError::InvalidLeadingOctalDigit(ch) = err {
995            assert_eq!(ch, b'7');
996        } else {
997            panic!("unexpected error: {:?}", err);
998        }
999
1000        let err = FileName::new(r#""quoted-with-bad-octal-\009""#).unwrap_err();
1001        if let FileNameError::InvalidOctalDigit(ch) = err {
1002            assert_eq!(ch, b'9');
1003        } else {
1004            panic!("unexpected error: {:?}", err);
1005        }
1006    }
1007
1008    #[test]
1009    fn test_filename_partial_eq() {
1010        let fn_a1 = FileName::new("a").unwrap();
1011        let fn_a2 = FileName::new("a").unwrap();
1012        let fn_b = FileName::new("b").unwrap();
1013
1014        assert_eq!(fn_a1, fn_a2);
1015        assert_ne!(fn_a1, fn_b);
1016        assert_ne!(fn_a2, fn_b);
1017    }
1018
1019    const BAD_COMMIT: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
1020
1021    #[test]
1022    fn test_bad_commit() {
1023        let ctx = make_context();
1024
1025        let err = Commit::new(&ctx, &CommitId::new(BAD_COMMIT)).unwrap_err();
1026
1027        if let CommitError::CommitMetadata {
1028            ref_,
1029            output,
1030        } = err
1031        {
1032            assert_eq!(ref_.as_str(), BAD_COMMIT);
1033            assert!(
1034                output.contains("bad object deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
1035                "unexpected error output: {}",
1036                output,
1037            );
1038        } else {
1039            panic!("unexpected error: {:?}", err);
1040        }
1041    }
1042
1043    #[test]
1044    fn test_bad_commit_extract_diffs() {
1045        let ctx = make_context();
1046
1047        let err = commit::extract_diffs(&ctx, None, &CommitId::new(BAD_COMMIT)).unwrap_err();
1048
1049        if let CommitError::DiffTree {
1050            commit,
1051            base,
1052            output,
1053        } = err
1054        {
1055            assert_eq!(commit.as_str(), BAD_COMMIT);
1056            assert_eq!(base, None);
1057            assert!(
1058                output.contains("bad object deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
1059                "unexpected error output: {}",
1060                output,
1061            );
1062        } else {
1063            panic!("unexpected error: {:?}", err);
1064        }
1065    }
1066
1067    #[test]
1068    fn test_bad_commit_extract_diff_patch() {
1069        let ctx = make_context();
1070        let file_path = Path::new("LICENSE-MIT");
1071
1072        let err = commit::file_patch(&ctx, None, &CommitId::new(BAD_COMMIT), file_path.as_ref())
1073            .unwrap_err();
1074
1075        if let CommitError::DiffPatch {
1076            commit,
1077            base,
1078            path,
1079            output,
1080        } = err
1081        {
1082            assert_eq!(commit.as_str(), BAD_COMMIT);
1083            assert_eq!(base, None);
1084            assert_eq!(path, file_path);
1085            assert!(
1086                output.contains("bad object deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
1087                "unexpected error output: {}",
1088                output,
1089            );
1090        } else {
1091            panic!("unexpected error: {:?}", err);
1092        }
1093    }
1094
1095    const ROOT_COMMIT: &str = "7531e6df007ca1130e5d64b8627b3288844e38a4";
1096
1097    #[test]
1098    fn test_bad_path_extract_diff_patch() {
1099        let ctx = make_context();
1100        let file_path = Path::new("noexist");
1101
1102        let patch = commit::file_patch(&ctx, None, &CommitId::new(ROOT_COMMIT), file_path.as_ref())
1103            .unwrap();
1104
1105        assert_eq!(patch, "");
1106    }
1107
1108    #[test]
1109    fn test_commit_root() {
1110        let ctx = make_context();
1111
1112        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1113        let commit = Commit::new(&ctx, &CommitId::new(ROOT_COMMIT)).unwrap();
1114
1115        assert_eq!(commit.sha1.as_str(), ROOT_COMMIT);
1116        assert_eq!(commit.message, "license: add Apache v2 + MIT licenses\n");
1117        assert_eq!(commit.parents.len(), 0);
1118        assert_eq!(commit.author, ben);
1119        assert_eq!(commit.committer, ben);
1120        assert_eq!(commit.sha1(), Some(&CommitId::new(ROOT_COMMIT)));
1121
1122        assert_eq!(commit.diffs.len(), 2);
1123
1124        compare_diffs(
1125            &commit.diffs[0],
1126            &DiffInfo {
1127                old_mode: "000000".into(),
1128                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1129                new_mode: "100644".into(),
1130                new_blob: CommitId::new("16fe87b06e802f094b3fbb0894b137bca2b16ef1"),
1131                name: FileName::Normal("LICENSE-APACHE".into()),
1132                status: StatusChange::Added,
1133            },
1134        );
1135        compare_diffs(
1136            &commit.diffs[1],
1137            &DiffInfo {
1138                old_mode: "000000".into(),
1139                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1140                new_mode: "100644".into(),
1141                new_blob: CommitId::new("8f6f4b4a936616349e1f2846632c95cff33a3c5d"),
1142                name: FileName::Normal("LICENSE-MIT".into()),
1143                status: StatusChange::Added,
1144            },
1145        );
1146    }
1147
1148    const SECOND_COMMIT: &str = "7f0d58bf407b0c80a9daadae88e7541f152ef371";
1149
1150    #[test]
1151    fn test_commit_regular() {
1152        let ctx = make_context();
1153
1154        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1155        let commit = Commit::new(&ctx, &CommitId::new(SECOND_COMMIT)).unwrap();
1156
1157        assert_eq!(commit.sha1.as_str(), SECOND_COMMIT);
1158        assert_eq!(commit.message, "cargo: add boilerplate\n");
1159        assert_eq!(commit.parents.len(), 1);
1160        assert_eq!(commit.parents[0].as_str(), ROOT_COMMIT);
1161        assert_eq!(commit.author, ben);
1162        assert_eq!(commit.committer, ben);
1163        assert_eq!(commit.sha1(), Some(&CommitId::new(SECOND_COMMIT)));
1164
1165        assert_eq!(commit.diffs.len(), 4);
1166
1167        compare_diffs(
1168            &commit.diffs[0],
1169            &DiffInfo {
1170                old_mode: "000000".into(),
1171                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1172                new_mode: "100644".into(),
1173                new_blob: CommitId::new("fa8d85ac52f19959d6fc9942c265708b4b3c2b04"),
1174                name: FileName::Normal(".gitignore".into()),
1175                status: StatusChange::Added,
1176            },
1177        );
1178
1179        compare_diffs(
1180            &commit.diffs[1],
1181            &DiffInfo {
1182                old_mode: "000000".into(),
1183                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1184                new_mode: "100644".into(),
1185                new_blob: CommitId::new("e31460ae61b1d69c6d0a58f9b74ae94d463c49bb"),
1186                name: FileName::Normal("Cargo.toml".into()),
1187                status: StatusChange::Added,
1188            },
1189        );
1190
1191        compare_diffs(
1192            &commit.diffs[2],
1193            &DiffInfo {
1194                old_mode: "000000".into(),
1195                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1196                new_mode: "100644".into(),
1197                new_blob: CommitId::new("b3c9194ad2d95d1498361f70a5480f0433fad1bb"),
1198                name: FileName::Normal("rustfmt.toml".into()),
1199                status: StatusChange::Added,
1200            },
1201        );
1202
1203        compare_diffs(
1204            &commit.diffs[3],
1205            &DiffInfo {
1206                old_mode: "000000".into(),
1207                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1208                new_mode: "100644".into(),
1209                new_blob: CommitId::new("584691748770380d5b70deb5f489b0c09229404e"),
1210                name: FileName::Normal("src/lib.rs".into()),
1211                status: StatusChange::Added,
1212            },
1213        );
1214    }
1215
1216    const CHANGES_COMMIT: &str = "5fa80c2ab7df3c1d18c5ff7840a1ef051a1a1f21";
1217    const CHANGES_COMMIT_PARENT: &str = "89e44cb662b4a2e6b8f0333f84e5cc4ac818cc96";
1218
1219    #[test]
1220    fn test_commit_regular_with_changes() {
1221        let ctx = make_context();
1222
1223        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1224        let commit = Commit::new(&ctx, &CommitId::new(CHANGES_COMMIT)).unwrap();
1225
1226        assert_eq!(commit.sha1.as_str(), CHANGES_COMMIT);
1227        assert_eq!(
1228            commit.message,
1229            "Identity: use over strings where it matters\n",
1230        );
1231        assert_eq!(commit.parents.len(), 1);
1232        assert_eq!(commit.parents[0].as_str(), CHANGES_COMMIT_PARENT);
1233        assert_eq!(commit.author, ben);
1234        assert_eq!(commit.committer, ben);
1235        assert_eq!(commit.sha1(), Some(&CommitId::new(CHANGES_COMMIT)));
1236
1237        assert_eq!(commit.diffs.len(), 2);
1238
1239        compare_diffs(
1240            &commit.diffs[0],
1241            &DiffInfo {
1242                old_mode: "100644".into(),
1243                old_blob: CommitId::new("5710fe019e3de5e07f2bd1a5bcfd2d462834e9d8"),
1244                new_mode: "100644".into(),
1245                new_blob: CommitId::new("db2811cc69a6fda16f6ef5d08cfc7780b46331d7"),
1246                name: FileName::Normal("src/context.rs".into()),
1247                status: StatusChange::Modified(None),
1248            },
1249        );
1250
1251        compare_diffs(
1252            &commit.diffs[1],
1253            &DiffInfo {
1254                old_mode: "100644".into(),
1255                old_blob: CommitId::new("2acafbc5acd2e7b70260a3e39a1a752cc46cf953"),
1256                new_mode: "100644".into(),
1257                new_blob: CommitId::new("af3a45a85ac6ba0d026279adac509c56308a3e26"),
1258                name: FileName::Normal("src/run.rs".into()),
1259                status: StatusChange::Modified(None),
1260            },
1261        );
1262    }
1263
1264    #[test]
1265    fn test_topic_regular_with_changes() {
1266        let ctx = make_context();
1267
1268        let topic = Topic::new(
1269            &ctx,
1270            &CommitId::new(ROOT_COMMIT),
1271            &CommitId::new(CHANGES_COMMIT),
1272        )
1273        .unwrap();
1274
1275        assert_eq!(topic.base.as_str(), ROOT_COMMIT);
1276        assert_eq!(topic.sha1.as_str(), CHANGES_COMMIT);
1277        assert_eq!(topic.sha1(), None);
1278
1279        assert_eq!(topic.diffs.len(), 8);
1280
1281        compare_diffs(
1282            &topic.diffs[0],
1283            &DiffInfo {
1284                old_mode: "000000".into(),
1285                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1286                new_mode: "100644".into(),
1287                new_blob: CommitId::new("fa8d85ac52f19959d6fc9942c265708b4b3c2b04"),
1288                name: FileName::Normal(".gitignore".into()),
1289                status: StatusChange::Added,
1290            },
1291        );
1292
1293        compare_diffs(
1294            &topic.diffs[1],
1295            &DiffInfo {
1296                old_mode: "000000".into(),
1297                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1298                new_mode: "100644".into(),
1299                new_blob: CommitId::new("eadf0e09676dae16dbdf2c51d12f237e0ec663c6"),
1300                name: FileName::Normal("Cargo.toml".into()),
1301                status: StatusChange::Added,
1302            },
1303        );
1304
1305        compare_diffs(
1306            &topic.diffs[2],
1307            &DiffInfo {
1308                old_mode: "000000".into(),
1309                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1310                new_mode: "100644".into(),
1311                new_blob: CommitId::new("b3c9194ad2d95d1498361f70a5480f0433fad1bb"),
1312                name: FileName::Normal("rustfmt.toml".into()),
1313                status: StatusChange::Added,
1314            },
1315        );
1316
1317        compare_diffs(
1318            &topic.diffs[3],
1319            &DiffInfo {
1320                old_mode: "000000".into(),
1321                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1322                new_mode: "100644".into(),
1323                new_blob: CommitId::new("b43524e95861f3dccb3b1b2ce42e004ff8ba53cc"),
1324                name: FileName::Normal("src/commit.rs".into()),
1325                status: StatusChange::Added,
1326            },
1327        );
1328
1329        compare_diffs(
1330            &topic.diffs[4],
1331            &DiffInfo {
1332                old_mode: "000000".into(),
1333                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1334                new_mode: "100644".into(),
1335                new_blob: CommitId::new("db2811cc69a6fda16f6ef5d08cfc7780b46331d7"),
1336                name: FileName::Normal("src/context.rs".into()),
1337                status: StatusChange::Added,
1338            },
1339        );
1340
1341        compare_diffs(
1342            &topic.diffs[5],
1343            &DiffInfo {
1344                old_mode: "000000".into(),
1345                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1346                new_mode: "100644".into(),
1347                new_blob: CommitId::new("ea5b00b30d7caaf2ceb7396788b4f0493d84d282"),
1348                name: FileName::Normal("src/hook.rs".into()),
1349                status: StatusChange::Added,
1350            },
1351        );
1352
1353        compare_diffs(
1354            &topic.diffs[6],
1355            &DiffInfo {
1356                old_mode: "000000".into(),
1357                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1358                new_mode: "100644".into(),
1359                new_blob: CommitId::new("1784b5969f5948334d6c29b7050bb97c54f3fe8f"),
1360                name: FileName::Normal("src/lib.rs".into()),
1361                status: StatusChange::Added,
1362            },
1363        );
1364
1365        compare_diffs(
1366            &topic.diffs[7],
1367            &DiffInfo {
1368                old_mode: "000000".into(),
1369                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1370                new_mode: "100644".into(),
1371                new_blob: CommitId::new("af3a45a85ac6ba0d026279adac509c56308a3e26"),
1372                name: FileName::Normal("src/run.rs".into()),
1373                status: StatusChange::Added,
1374            },
1375        );
1376    }
1377
1378    const QUOTED_PATHS: &str = "f536f44cf96b82e479d4973d5ea1cf78058bd1fb";
1379    const QUOTED_PATHS_PARENT: &str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";
1380
1381    fn quoted_filename(raw: &[u8], readable: &str) -> FileName {
1382        FileName::Quoted {
1383            raw: raw.into(),
1384            name: readable.into(),
1385        }
1386    }
1387
1388    #[test]
1389    fn test_commit_quoted_paths() {
1390        let ctx = make_context();
1391
1392        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1393        let commit = Commit::new(&ctx, &CommitId::new(QUOTED_PATHS)).unwrap();
1394
1395        assert_eq!(commit.sha1.as_str(), QUOTED_PATHS);
1396        assert_eq!(
1397            commit.message,
1398            "commit with invalid characters in path names\n",
1399        );
1400        assert_eq!(commit.parents.len(), 1);
1401        assert_eq!(commit.parents[0].as_str(), QUOTED_PATHS_PARENT);
1402        assert_eq!(commit.author, ben);
1403        assert_eq!(commit.committer, ben);
1404        assert_eq!(commit.sha1(), Some(&CommitId::new(QUOTED_PATHS)));
1405
1406        assert_eq!(commit.diffs.len(), 6);
1407
1408        compare_diffs(
1409            &commit.diffs[0],
1410            &DiffInfo {
1411                old_mode: "000000".into(),
1412                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1413                new_mode: "100644".into(),
1414                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1415                name: quoted_filename(b"control-character-\x03", r#""control-character-\003""#),
1416                status: StatusChange::Added,
1417            },
1418        );
1419
1420        compare_diffs(
1421            &commit.diffs[1],
1422            &DiffInfo {
1423                old_mode: "000000".into(),
1424                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1425                new_mode: "100644".into(),
1426                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1427                name: quoted_filename(b"invalid-utf8-\x80", r#""invalid-utf8-\200""#),
1428                status: StatusChange::Added,
1429            },
1430        );
1431
1432        compare_diffs(
1433            &commit.diffs[2],
1434            &DiffInfo {
1435                old_mode: "000000".into(),
1436                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1437                new_mode: "100644".into(),
1438                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1439                name: quoted_filename("non-ascii-é".as_bytes(), r#""non-ascii-\303\251""#),
1440                status: StatusChange::Added,
1441            },
1442        );
1443
1444        compare_diffs(
1445            &commit.diffs[3],
1446            &DiffInfo {
1447                old_mode: "000000".into(),
1448                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1449                new_mode: "100644".into(),
1450                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1451                name: quoted_filename(b"with whitespace", "with whitespace"),
1452                status: StatusChange::Added,
1453            },
1454        );
1455
1456        compare_diffs(
1457            &commit.diffs[4],
1458            &DiffInfo {
1459                old_mode: "000000".into(),
1460                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1461                new_mode: "100644".into(),
1462                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1463                name: quoted_filename(b"with-dollar-$", "with-dollar-$"),
1464                status: StatusChange::Added,
1465            },
1466        );
1467
1468        compare_diffs(
1469            &commit.diffs[5],
1470            &DiffInfo {
1471                old_mode: "000000".into(),
1472                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1473                new_mode: "100644".into(),
1474                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1475                name: quoted_filename(b"with-quote-\"", r#""with-quote-\"""#),
1476                status: StatusChange::Added,
1477            },
1478        );
1479    }
1480
1481    #[test]
1482    fn test_commit_quoted_paths_with_bad_config() {
1483        let raw_ctx = make_context();
1484        let tempdir = test::make_temp_dir();
1485        let config_path = tempdir.path().join("config");
1486        let config = raw_ctx
1487            .git()
1488            .arg("config")
1489            .args([OsStr::new("-f"), config_path.as_ref()])
1490            .arg("core.quotePath")
1491            .arg("false")
1492            .output()
1493            .unwrap();
1494        if !config.status.success() {
1495            panic!(
1496                "setting core.quotePath failed: {}",
1497                String::from_utf8_lossy(&config.stderr),
1498            );
1499        }
1500        let ctx = GitContext::new_with_config(raw_ctx.gitdir(), config_path);
1501
1502        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1503        let commit = Commit::new(&ctx, &CommitId::new(QUOTED_PATHS)).unwrap();
1504
1505        assert_eq!(commit.sha1.as_str(), QUOTED_PATHS);
1506        assert_eq!(
1507            commit.message,
1508            "commit with invalid characters in path names\n",
1509        );
1510        assert_eq!(commit.parents.len(), 1);
1511        assert_eq!(commit.parents[0].as_str(), QUOTED_PATHS_PARENT);
1512        assert_eq!(commit.author, ben);
1513        assert_eq!(commit.committer, ben);
1514        assert_eq!(commit.sha1(), Some(&CommitId::new(QUOTED_PATHS)));
1515
1516        assert_eq!(commit.diffs.len(), 6);
1517
1518        compare_diffs(
1519            &commit.diffs[0],
1520            &DiffInfo {
1521                old_mode: "000000".into(),
1522                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1523                new_mode: "100644".into(),
1524                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1525                name: quoted_filename(b"control-character-\x03", r#""control-character-\003""#),
1526                status: StatusChange::Added,
1527            },
1528        );
1529
1530        compare_diffs(
1531            &commit.diffs[1],
1532            &DiffInfo {
1533                old_mode: "000000".into(),
1534                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1535                new_mode: "100644".into(),
1536                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1537                name: quoted_filename(b"invalid-utf8-\x80", r#""invalid-utf8-\200""#),
1538                status: StatusChange::Added,
1539            },
1540        );
1541
1542        compare_diffs(
1543            &commit.diffs[2],
1544            &DiffInfo {
1545                old_mode: "000000".into(),
1546                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1547                new_mode: "100644".into(),
1548                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1549                name: quoted_filename("non-ascii-é".as_bytes(), r#""non-ascii-\303\251""#),
1550                status: StatusChange::Added,
1551            },
1552        );
1553
1554        compare_diffs(
1555            &commit.diffs[3],
1556            &DiffInfo {
1557                old_mode: "000000".into(),
1558                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1559                new_mode: "100644".into(),
1560                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1561                name: quoted_filename(b"with whitespace", "with whitespace"),
1562                status: StatusChange::Added,
1563            },
1564        );
1565
1566        compare_diffs(
1567            &commit.diffs[4],
1568            &DiffInfo {
1569                old_mode: "000000".into(),
1570                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1571                new_mode: "100644".into(),
1572                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1573                name: quoted_filename(b"with-dollar-$", "with-dollar-$"),
1574                status: StatusChange::Added,
1575            },
1576        );
1577
1578        compare_diffs(
1579            &commit.diffs[5],
1580            &DiffInfo {
1581                old_mode: "000000".into(),
1582                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1583                new_mode: "100644".into(),
1584                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1585                name: quoted_filename(b"with-quote-\"", r#""with-quote-\"""#),
1586                status: StatusChange::Added,
1587            },
1588        );
1589    }
1590
1591    const REGULAR_MERGE_COMMIT: &str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";
1592    const REGULAR_MERGE_PARENT1: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
1593    const REGULAR_MERGE_PARENT2: &str = "d02f015907371738253a22b9a7fec78607a969b2";
1594
1595    #[test]
1596    fn test_commit_merge() {
1597        let ctx = make_context();
1598
1599        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1600        let commit = Commit::new(&ctx, &CommitId::new(REGULAR_MERGE_COMMIT)).unwrap();
1601
1602        assert_eq!(commit.sha1.as_str(), REGULAR_MERGE_COMMIT);
1603        assert_eq!(
1604            commit.message,
1605            "Merge commit 'd02f015907371738253a22b9a7fec78607a969b2' into \
1606             tests/commit/merge\n\n* commit 'd02f015907371738253a22b9a7fec78607a969b2':\n  \
1607             test-refs: non-root commit\n",
1608        );
1609        assert_eq!(commit.parents.len(), 2);
1610        assert_eq!(commit.parents[0].as_str(), REGULAR_MERGE_PARENT1);
1611        assert_eq!(commit.parents[1].as_str(), REGULAR_MERGE_PARENT2);
1612        assert_eq!(commit.author, ben);
1613        assert_eq!(commit.committer, ben);
1614        assert_eq!(commit.sha1(), Some(&CommitId::new(REGULAR_MERGE_COMMIT)));
1615
1616        assert_eq!(commit.diffs.len(), 0);
1617    }
1618
1619    const EVIL_MERGE_DIFFS: &str = "add18e5ab9a67303337cb2754c675fb2e0a45a79";
1620    const EVIL_MERGE_DIFFS_PARENT1: &str = "543322ad2b14bc455e4d10f3aac9b8c1c7aca3bd";
1621    const EVIL_MERGE_DIFFS_PARENT2: &str = "3c1ca95f1ac7cdef19dc87b9984f76c1761ac7c6";
1622
1623    #[test]
1624    fn test_evil_merge_diffs() {
1625        let ctx = make_context();
1626
1627        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1628        let commit = Commit::new(&ctx, &CommitId::new(EVIL_MERGE_DIFFS)).unwrap();
1629
1630        assert_eq!(commit.sha1.as_str(), EVIL_MERGE_DIFFS);
1631        assert_eq!(
1632            commit.message,
1633            "Merge branch 'upstream-check_size' into third-party-base-update-evil\n\n\
1634             * upstream-check_size:\n  check_size 2016-07-09 (112e9b34)\n",
1635        );
1636        assert_eq!(commit.parents.len(), 2);
1637        assert_eq!(commit.parents[0].as_str(), EVIL_MERGE_DIFFS_PARENT1);
1638        assert_eq!(commit.parents[1].as_str(), EVIL_MERGE_DIFFS_PARENT2);
1639        assert_eq!(commit.author, ben);
1640        assert_eq!(commit.committer, ben);
1641        assert_eq!(commit.sha1(), Some(&CommitId::new(EVIL_MERGE_DIFFS)));
1642
1643        assert_eq!(commit.diffs.len(), 6);
1644
1645        compare_diffs(
1646            &commit.diffs[0],
1647            &DiffInfo {
1648                old_mode: "100644".into(),
1649                old_blob: CommitId::new("431f4196e62bb1ae394469c6ddd7c8f3d3e8dbd6"),
1650                new_mode: "100644".into(),
1651                new_blob: CommitId::new("8047698eb7556bd35bb1667de3e64243bb601eaa"),
1652                name: FileName::Normal("check_size/.gitattributes".into()),
1653                status: StatusChange::Modified(None),
1654            },
1655        );
1656
1657        compare_diffs(
1658            &commit.diffs[1],
1659            &DiffInfo {
1660                old_mode: "000000".into(),
1661                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1662                new_mode: "100644".into(),
1663                new_blob: CommitId::new("8047698eb7556bd35bb1667de3e64243bb601eaa"),
1664                name: FileName::Normal("check_size/.gitattributes".into()),
1665                status: StatusChange::Added,
1666            },
1667        );
1668
1669        compare_diffs(
1670            &commit.diffs[2],
1671            &DiffInfo {
1672                old_mode: "000000".into(),
1673                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1674                new_mode: "100644".into(),
1675                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1676                name: FileName::Normal("check_size/bad-attr-value".into()),
1677                status: StatusChange::Added,
1678            },
1679        );
1680
1681        compare_diffs(
1682            &commit.diffs[3],
1683            &DiffInfo {
1684                old_mode: "000000".into(),
1685                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1686                new_mode: "100644".into(),
1687                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
1688                name: FileName::Normal("check_size/bad-attr-value".into()),
1689                status: StatusChange::Added,
1690            },
1691        );
1692
1693        compare_diffs(
1694            &commit.diffs[4],
1695            &DiffInfo {
1696                old_mode: "100644".into(),
1697                old_blob: CommitId::new("289aa5399387180f356b4beab60fb2f16207a7d7"),
1698                new_mode: "100644".into(),
1699                new_blob: CommitId::new("7b5c7a2fe126cd3476d993c40525736c45796a60"),
1700                name: FileName::Normal("check_size/increased-size".into()),
1701                status: StatusChange::Modified(None),
1702            },
1703        );
1704
1705        compare_diffs(
1706            &commit.diffs[5],
1707            &DiffInfo {
1708                old_mode: "000000".into(),
1709                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1710                new_mode: "100644".into(),
1711                new_blob: CommitId::new("7b5c7a2fe126cd3476d993c40525736c45796a60"),
1712                name: FileName::Normal("check_size/increased-size".into()),
1713                status: StatusChange::Added,
1714            },
1715        );
1716    }
1717
1718    #[test]
1719    fn test_evil_merge_modified_files() {
1720        let ctx = make_context();
1721
1722        let commit = Commit::new(&ctx, &CommitId::new(EVIL_MERGE_DIFFS)).unwrap();
1723        let modified_files = commit.modified_files();
1724
1725        assert_eq!(modified_files.len(), 3);
1726        assert_eq!(modified_files[0].as_str(), "check_size/.gitattributes");
1727        assert_eq!(modified_files[1].as_str(), "check_size/bad-attr-value");
1728        assert_eq!(modified_files[2].as_str(), "check_size/increased-size");
1729    }
1730
1731    const NO_HISTORY_MERGE_COMMIT: &str = "018ef4b25b978e194712e57a8f71d67427ecc065";
1732    const NO_HISTORY_MERGE_PARENT1: &str = "969b794ea82ce51d1555852de33bfcb63dfec969";
1733    const NO_HISTORY_MERGE_PARENT2: &str = "8805c7ae76329a937960448908f4618214fd3546";
1734
1735    #[test]
1736    fn test_commit_merge_no_history() {
1737        let ctx = make_context();
1738
1739        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1740        let commit = Commit::new(&ctx, &CommitId::new(NO_HISTORY_MERGE_COMMIT)).unwrap();
1741
1742        assert_eq!(commit.sha1.as_str(), NO_HISTORY_MERGE_COMMIT);
1743        assert_eq!(
1744            commit.message,
1745            "Merge branch 'tests/commit/no_history' into tests/commit/merge\n\n* \
1746             tests/commit/no_history:\n  tests: a commit with new history\n",
1747        );
1748        assert_eq!(commit.parents.len(), 2);
1749        assert_eq!(commit.parents[0].as_str(), NO_HISTORY_MERGE_PARENT1);
1750        assert_eq!(commit.parents[1].as_str(), NO_HISTORY_MERGE_PARENT2);
1751        assert_eq!(commit.author, ben);
1752        assert_eq!(commit.committer, ben);
1753        assert_eq!(commit.sha1(), Some(&CommitId::new(NO_HISTORY_MERGE_COMMIT)));
1754
1755        assert_eq!(commit.diffs.len(), 0);
1756    }
1757
1758    const CONFLICT_MERGE_COMMIT: &str = "969b794ea82ce51d1555852de33bfcb63dfec969";
1759    const CONFLICT_MERGE_PARENT1: &str = "fb11c6970f4aff1e16ea85ba17529377b62310b7";
1760    const CONFLICT_MERGE_PARENT2: &str = "bed4760e60c8276e6db53c6c2b641933d2938510";
1761
1762    #[test]
1763    fn test_commit_merge_conflict() {
1764        let ctx = make_context();
1765
1766        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1767        let commit = Commit::new(&ctx, &CommitId::new(CONFLICT_MERGE_COMMIT)).unwrap();
1768
1769        assert_eq!(commit.sha1.as_str(), CONFLICT_MERGE_COMMIT);
1770        assert_eq!(
1771            commit.message,
1772            "Merge branch 'test/commit/merge/conflict' into tests/commit/merge\n\n* \
1773             test/commit/merge/conflict:\n  content: cause some conflicts\n",
1774        );
1775        assert_eq!(commit.parents.len(), 2);
1776        assert_eq!(commit.parents[0].as_str(), CONFLICT_MERGE_PARENT1);
1777        assert_eq!(commit.parents[1].as_str(), CONFLICT_MERGE_PARENT2);
1778        assert_eq!(commit.author, ben);
1779        assert_eq!(commit.committer, ben);
1780        assert_eq!(commit.sha1(), Some(&CommitId::new(CONFLICT_MERGE_COMMIT)));
1781
1782        assert_eq!(commit.diffs.len(), 2);
1783
1784        compare_diffs(
1785            &commit.diffs[0],
1786            &DiffInfo {
1787                old_mode: "100644".into(),
1788                old_blob: CommitId::new("829636d299136b6651fd19aa6ac3500c7d50bf10"),
1789                new_mode: "100644".into(),
1790                new_blob: CommitId::new("195cc9d0aeb7324584700f2d17940d6a218a36f2"),
1791                name: FileName::Normal("content".into()),
1792                status: StatusChange::Modified(None),
1793            },
1794        );
1795
1796        compare_diffs(
1797            &commit.diffs[1],
1798            &DiffInfo {
1799                old_mode: "100644".into(),
1800                old_blob: CommitId::new("925b35841fdd59e002bc5b3e685dc158bdbe6ebf"),
1801                new_mode: "100644".into(),
1802                new_blob: CommitId::new("195cc9d0aeb7324584700f2d17940d6a218a36f2"),
1803                name: FileName::Normal("content".into()),
1804                status: StatusChange::Modified(None),
1805            },
1806        );
1807    }
1808
1809    const EVIL_MERGE_COMMIT: &str = "fb11c6970f4aff1e16ea85ba17529377b62310b7";
1810    const EVIL_MERGE_PARENT1: &str = "c58dd19d9976722d82aa6bc6a52a2a01a52bd9e8";
1811    const EVIL_MERGE_PARENT2: &str = "27ff3ef5532d76afa046f76f4dd8f588dc3e83c3";
1812
1813    #[test]
1814    fn test_commit_merge_evil() {
1815        let ctx = make_context();
1816
1817        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1818        let commit = Commit::new(&ctx, &CommitId::new(EVIL_MERGE_COMMIT)).unwrap();
1819
1820        assert_eq!(commit.sha1.as_str(), EVIL_MERGE_COMMIT);
1821        assert_eq!(
1822            commit.message,
1823            "Merge commit '27ff3ef5532d76afa046f76f4dd8f588dc3e83c3' into \
1824             tests/commit/merge\n\n* commit '27ff3ef5532d76afa046f76f4dd8f588dc3e83c3':\n  \
1825             test-refs: test target commit\n",
1826        );
1827        assert_eq!(commit.parents.len(), 2);
1828        assert_eq!(commit.parents[0].as_str(), EVIL_MERGE_PARENT1);
1829        assert_eq!(commit.parents[1].as_str(), EVIL_MERGE_PARENT2);
1830        assert_eq!(commit.author, ben);
1831        assert_eq!(commit.committer, ben);
1832        assert_eq!(commit.sha1(), Some(&CommitId::new(EVIL_MERGE_COMMIT)));
1833
1834        assert_eq!(commit.diffs.len(), 2);
1835
1836        compare_diffs(
1837            &commit.diffs[0],
1838            &DiffInfo {
1839                old_mode: "100644".into(),
1840                old_blob: CommitId::new("2ef267e25bd6c6a300bb473e604b092b6a48523b"),
1841                new_mode: "100644".into(),
1842                new_blob: CommitId::new("829636d299136b6651fd19aa6ac3500c7d50bf10"),
1843                name: FileName::Normal("content".into()),
1844                status: StatusChange::Modified(None),
1845            },
1846        );
1847
1848        compare_diffs(
1849            &commit.diffs[1],
1850            &DiffInfo {
1851                old_mode: "000000".into(),
1852                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1853                new_mode: "100644".into(),
1854                new_blob: CommitId::new("829636d299136b6651fd19aa6ac3500c7d50bf10"),
1855                name: FileName::Normal("content".into()),
1856                status: StatusChange::Added,
1857            },
1858        );
1859    }
1860
1861    const SMALL_DIFF_COMMIT: &str = "22324be5cac6a797a3c5f3633e4409840e02eae9";
1862    const SMALL_DIFF_PARENT: &str = "1ff04953f1b8dd7f01ecfe51ee962c47cea50e28";
1863
1864    #[test]
1865    fn test_commit_file_patch() {
1866        let ctx = make_context();
1867
1868        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1869        let commit = Commit::new(&ctx, &CommitId::new(SMALL_DIFF_COMMIT)).unwrap();
1870
1871        assert_eq!(commit.sha1.as_str(), SMALL_DIFF_COMMIT);
1872        assert_eq!(commit.message, "commit: use %n rather than \\n\n");
1873        assert_eq!(commit.parents.len(), 1);
1874        assert_eq!(commit.parents[0].as_str(), SMALL_DIFF_PARENT);
1875        assert_eq!(commit.author, ben);
1876        assert_eq!(commit.committer, ben);
1877        assert_eq!(commit.sha1(), Some(&CommitId::new(SMALL_DIFF_COMMIT)));
1878
1879        assert_eq!(commit.diffs.len(), 1);
1880
1881        assert_eq!(
1882            commit.file_patch("src/commit.rs").unwrap(),
1883            concat!(
1884                "diff --git a/src/commit.rs b/src/commit.rs\n",
1885                "index 2cb15f6..6303de0 100644\n",
1886                "--- a/src/commit.rs\n",
1887                "+++ b/src/commit.rs\n",
1888                "@@ -109,7 +109,7 @@ impl Commit {\n",
1889                "     pub fn new(ctx: &GitContext, sha1: &str) -> Result<Self, Error> {\n",
1890                "         let commit_info = try!(ctx.git()\n",
1891                "             .arg(\"log\")\n",
1892                "-            .arg(\"--pretty=%P\\n%an\\n%ae\\n%cn\\n%ce\\n%h\")\n",
1893                "+            .arg(\"--pretty=%P%n%an%n%ae%n%cn%n%ce%n%h\")\n",
1894                "             .arg(\"-n\").arg(\"1\")\n",
1895                "             .arg(sha1)\n",
1896                "             .output());\n",
1897            ),
1898        );
1899    }
1900
1901    #[test]
1902    fn test_topic_file_patch() {
1903        let ctx = make_context();
1904
1905        let topic = Topic::new(
1906            &ctx,
1907            &CommitId::new(ROOT_COMMIT),
1908            &CommitId::new(CHANGES_COMMIT),
1909        )
1910        .unwrap();
1911
1912        assert_eq!(topic.base.as_str(), ROOT_COMMIT);
1913        assert_eq!(topic.sha1.as_str(), CHANGES_COMMIT);
1914        assert_eq!(topic.sha1(), None);
1915
1916        assert_eq!(topic.diffs.len(), 8);
1917
1918        assert_eq!(
1919            topic.file_patch(".gitignore").unwrap(),
1920            concat!(
1921                "diff --git a/.gitignore b/.gitignore\n",
1922                "new file mode 100644\n",
1923                "index 0000000..fa8d85a\n",
1924                "--- /dev/null\n",
1925                "+++ b/.gitignore\n",
1926                "@@ -0,0 +1,2 @@\n",
1927                "+Cargo.lock\n",
1928                "+target\n",
1929            ),
1930        );
1931    }
1932
1933    const ADD_BINARY_COMMIT: &str = "ff7ee6b5c822440613a01bfcf704e18cff4def72";
1934    const CHANGE_BINARY_COMMIT: &str = "8f25adf0f878bdf88b609ca7ef67f235c5237602";
1935    const ADD_BINARY_PARENT: &str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";
1936
1937    #[test]
1938    fn test_commit_file_patch_binary_add() {
1939        let ctx = make_context();
1940
1941        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
1942        let commit = Commit::new(&ctx, &CommitId::new(ADD_BINARY_COMMIT)).unwrap();
1943
1944        assert_eq!(commit.sha1.as_str(), ADD_BINARY_COMMIT);
1945        assert_eq!(commit.message, "binary-data: add some binary data\n");
1946        assert_eq!(commit.parents.len(), 1);
1947        assert_eq!(commit.parents[0].as_str(), ADD_BINARY_PARENT);
1948        assert_eq!(commit.author, ben);
1949        assert_eq!(commit.committer, ben);
1950        assert_eq!(commit.sha1(), Some(&CommitId::new(ADD_BINARY_COMMIT)));
1951
1952        assert_eq!(commit.diffs.len(), 1);
1953
1954        compare_diffs(
1955            &commit.diffs[0],
1956            &DiffInfo {
1957                old_mode: "000000".into(),
1958                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1959                new_mode: "100644".into(),
1960                new_blob: CommitId::new("7624577a217a08f1103d6c190eb11beab1081744"),
1961                name: FileName::Normal("binary-data".into()),
1962                status: StatusChange::Added,
1963            },
1964        );
1965
1966        assert_eq!(
1967            commit.file_patch("binary-data").unwrap(),
1968            concat!(
1969                "diff --git a/binary-data b/binary-data\n",
1970                "new file mode 100644\n",
1971                "index 0000000..7624577\n",
1972                "Binary files /dev/null and b/binary-data differ\n",
1973            ),
1974        );
1975    }
1976
1977    #[test]
1978    fn test_topic_file_patch_binary_add() {
1979        let ctx = make_context();
1980
1981        let topic = Topic::new(
1982            &ctx,
1983            &CommitId::new(ADD_BINARY_PARENT),
1984            &CommitId::new(CHANGE_BINARY_COMMIT),
1985        )
1986        .unwrap();
1987
1988        assert_eq!(topic.base.as_str(), ADD_BINARY_PARENT);
1989        assert_eq!(topic.sha1.as_str(), CHANGE_BINARY_COMMIT);
1990        assert_eq!(topic.sha1(), None);
1991
1992        assert_eq!(topic.diffs.len(), 1);
1993
1994        compare_diffs(
1995            &topic.diffs[0],
1996            &DiffInfo {
1997                old_mode: "000000".into(),
1998                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
1999                new_mode: "100644".into(),
2000                new_blob: CommitId::new("53c235a627abeda83e26d6d53cdebf72b52f3c3d"),
2001                name: FileName::Normal("binary-data".into()),
2002                status: StatusChange::Added,
2003            },
2004        );
2005
2006        assert_eq!(
2007            topic.file_patch("binary-data").unwrap(),
2008            concat!(
2009                "diff --git a/binary-data b/binary-data\n",
2010                "new file mode 100644\n",
2011                "index 0000000..53c235a\n",
2012                "Binary files /dev/null and b/binary-data differ\n",
2013            ),
2014        );
2015    }
2016
2017    #[test]
2018    fn test_commit_file_patch_binary_change() {
2019        let ctx = make_context();
2020
2021        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
2022        let commit = Commit::new(&ctx, &CommitId::new(CHANGE_BINARY_COMMIT)).unwrap();
2023
2024        assert_eq!(commit.sha1.as_str(), CHANGE_BINARY_COMMIT);
2025        assert_eq!(commit.message, "binary-data: change some binary data\n");
2026        assert_eq!(commit.parents.len(), 1);
2027        assert_eq!(commit.parents[0].as_str(), ADD_BINARY_COMMIT);
2028        assert_eq!(commit.author, ben);
2029        assert_eq!(commit.committer, ben);
2030        assert_eq!(commit.sha1(), Some(&CommitId::new(CHANGE_BINARY_COMMIT)));
2031
2032        assert_eq!(commit.diffs.len(), 1);
2033
2034        compare_diffs(
2035            &commit.diffs[0],
2036            &DiffInfo {
2037                old_mode: "100644".into(),
2038                old_blob: CommitId::new("7624577a217a08f1103d6c190eb11beab1081744"),
2039                new_mode: "100644".into(),
2040                new_blob: CommitId::new("53c235a627abeda83e26d6d53cdebf72b52f3c3d"),
2041                name: FileName::Normal("binary-data".into()),
2042                status: StatusChange::Modified(None),
2043            },
2044        );
2045
2046        assert_eq!(
2047            commit.file_patch("binary-data").unwrap(),
2048            concat!(
2049                "diff --git a/binary-data b/binary-data\n",
2050                "index 7624577..53c235a 100644\n",
2051                "Binary files a/binary-data and b/binary-data differ\n",
2052            ),
2053        );
2054    }
2055
2056    // Test the merge base detection logic of the `Topic` structure. The test is set up as:
2057    //
2058    // ```
2059    // R - F - T - M
2060    //  \   \   \ /
2061    //   \   \   X____
2062    //    \   \ /     \
2063    //     N   S - E - C
2064    //
2065    // U
2066    // ```
2067    //
2068    // where `R` is a root commit.
2069
2070    // R
2071    const TOPIC_ROOT_COMMIT: &str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";
2072    // F
2073    const FORK_POINT: &str = "c65b124adb2876f3ce3f328fd22fbc2726607030";
2074    // T
2075    const EXTEND_TARGET: &str = "cc8fa49b9e349250e05967dd6a6be1093da3ac43";
2076    // M
2077    const MERGE_INTO_TARGET: &str = "e590269220be798435848d8428390c980493cc2c";
2078    // S
2079    const SIMPLE_TOPIC: &str = "7876da5555b4efbc32c7df123d5c2171477a6771";
2080    // E
2081    const EXTEND_MERGED_TOPIC: &str = "38d963092fddfd3ef0b38558996c015d01830354";
2082    // C
2083    const CROSS_MERGE_TOPIC: &str = "f194e9af0d6864298e5159152c064d25abaaf858";
2084    // U
2085    const SHARE_ROOT_TOPIC: &str = "3a22ca19fda09183da2faab60819ff6807568acd";
2086
2087    fn test_best_merge_base(target: &str, topic: &str, merge_base: &str) {
2088        let ctx = make_context();
2089
2090        assert_eq!(
2091            Topic::best_merge_base(&ctx, &CommitId::new(target), &CommitId::new(topic)).unwrap(),
2092            CommitId::new(merge_base),
2093        );
2094    }
2095
2096    #[test]
2097    fn test_best_merge_base_extended_target() {
2098        test_best_merge_base(EXTEND_TARGET, SIMPLE_TOPIC, FORK_POINT)
2099    }
2100
2101    #[test]
2102    fn test_best_merge_base_extend_merged_topic() {
2103        test_best_merge_base(MERGE_INTO_TARGET, EXTEND_MERGED_TOPIC, SIMPLE_TOPIC)
2104    }
2105
2106    #[test]
2107    fn test_best_merge_base_share_root() {
2108        test_best_merge_base(EXTEND_TARGET, SHARE_ROOT_TOPIC, TOPIC_ROOT_COMMIT)
2109    }
2110
2111    #[test]
2112    fn test_best_merge_base_cross_merge() {
2113        test_best_merge_base(MERGE_INTO_TARGET, CROSS_MERGE_TOPIC, EXTEND_TARGET)
2114    }
2115
2116    #[test]
2117    fn test_best_merge_base_no_common_commits() {
2118        test_best_merge_base(EXTEND_TARGET, ROOT_COMMIT, EXTEND_TARGET)
2119    }
2120
2121    #[test]
2122    fn test_topic_extended_target() {
2123        let ctx = make_context();
2124
2125        let topic = Topic::new(
2126            &ctx,
2127            &CommitId::new(EXTEND_TARGET),
2128            &CommitId::new(SIMPLE_TOPIC),
2129        )
2130        .unwrap();
2131
2132        assert_eq!(topic.base.as_str(), FORK_POINT);
2133        assert_eq!(topic.sha1.as_str(), SIMPLE_TOPIC);
2134        assert_eq!(topic.sha1(), None);
2135
2136        assert_eq!(topic.diffs.len(), 1);
2137
2138        compare_diffs(
2139            &topic.diffs[0],
2140            &DiffInfo {
2141                old_mode: "000000".into(),
2142                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2143                new_mode: "100644".into(),
2144                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
2145                name: FileName::Normal("simple-topic".into()),
2146                status: StatusChange::Added,
2147            },
2148        );
2149
2150        assert_eq!(
2151            topic.file_patch("simple-topic").unwrap(),
2152            concat!(
2153                "diff --git a/simple-topic b/simple-topic\n",
2154                "new file mode 100644\n",
2155                "index 0000000..e69de29\n",
2156            ),
2157        );
2158    }
2159
2160    #[test]
2161    fn test_topic_extended_merged_target() {
2162        let ctx = make_context();
2163
2164        let topic = Topic::new(
2165            &ctx,
2166            &CommitId::new(MERGE_INTO_TARGET),
2167            &CommitId::new(EXTEND_MERGED_TOPIC),
2168        )
2169        .unwrap();
2170
2171        assert_eq!(topic.base.as_str(), SIMPLE_TOPIC);
2172        assert_eq!(topic.sha1.as_str(), EXTEND_MERGED_TOPIC);
2173
2174        assert_eq!(topic.diffs.len(), 1);
2175
2176        compare_diffs(
2177            &topic.diffs[0],
2178            &DiffInfo {
2179                old_mode: "000000".into(),
2180                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2181                new_mode: "100644".into(),
2182                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
2183                name: FileName::Normal("pre-merge-topic".into()),
2184                status: StatusChange::Added,
2185            },
2186        );
2187
2188        assert_eq!(
2189            topic.file_patch("pre-merge-topic").unwrap(),
2190            concat!(
2191                "diff --git a/pre-merge-topic b/pre-merge-topic\n",
2192                "new file mode 100644\n",
2193                "index 0000000..e69de29\n",
2194            ),
2195        );
2196    }
2197
2198    #[test]
2199    fn test_topic_share_root() {
2200        let ctx = make_context();
2201
2202        let topic = Topic::new(
2203            &ctx,
2204            &CommitId::new(EXTEND_TARGET),
2205            &CommitId::new(SHARE_ROOT_TOPIC),
2206        )
2207        .unwrap();
2208
2209        assert_eq!(topic.base.as_str(), TOPIC_ROOT_COMMIT);
2210        assert_eq!(topic.sha1.as_str(), SHARE_ROOT_TOPIC);
2211
2212        assert_eq!(topic.diffs.len(), 1);
2213
2214        compare_diffs(
2215            &topic.diffs[0],
2216            &DiffInfo {
2217                old_mode: "000000".into(),
2218                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2219                new_mode: "100644".into(),
2220                new_blob: CommitId::new("2ef267e25bd6c6a300bb473e604b092b6a48523b"),
2221                name: FileName::Normal("content".into()),
2222                status: StatusChange::Added,
2223            },
2224        );
2225
2226        assert_eq!(
2227            topic.file_patch("content").unwrap(),
2228            concat!(
2229                "diff --git a/content b/content\n",
2230                "new file mode 100644\n",
2231                "index 0000000..2ef267e\n",
2232                "--- /dev/null\n",
2233                "+++ b/content\n",
2234                "@@ -0,0 +1 @@\n",
2235                "+some content\n",
2236            ),
2237        );
2238    }
2239
2240    #[test]
2241    fn test_topic_cross_merge() {
2242        let ctx = make_context();
2243
2244        let topic = Topic::new(
2245            &ctx,
2246            &CommitId::new(MERGE_INTO_TARGET),
2247            &CommitId::new(CROSS_MERGE_TOPIC),
2248        )
2249        .unwrap();
2250
2251        assert_eq!(topic.base.as_str(), EXTEND_TARGET);
2252        assert_eq!(topic.sha1.as_str(), CROSS_MERGE_TOPIC);
2253
2254        assert_eq!(topic.diffs.len(), 2);
2255
2256        compare_diffs(
2257            &topic.diffs[0],
2258            &DiffInfo {
2259                old_mode: "000000".into(),
2260                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2261                new_mode: "100644".into(),
2262                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
2263                name: FileName::Normal("pre-merge-topic".into()),
2264                status: StatusChange::Added,
2265            },
2266        );
2267
2268        compare_diffs(
2269            &topic.diffs[1],
2270            &DiffInfo {
2271                old_mode: "000000".into(),
2272                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2273                new_mode: "100644".into(),
2274                new_blob: CommitId::new("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"),
2275                name: FileName::Normal("simple-topic".into()),
2276                status: StatusChange::Added,
2277            },
2278        );
2279
2280        assert_eq!(
2281            topic.file_patch("pre-merge-topic").unwrap(),
2282            concat!(
2283                "diff --git a/pre-merge-topic b/pre-merge-topic\n",
2284                "new file mode 100644\n",
2285                "index 0000000..e69de29\n",
2286            ),
2287        );
2288        assert_eq!(
2289            topic.file_patch("simple-topic").unwrap(),
2290            concat!(
2291                "diff --git a/simple-topic b/simple-topic\n",
2292                "new file mode 100644\n",
2293                "index 0000000..e69de29\n",
2294            ),
2295        );
2296    }
2297
2298    #[test]
2299    fn test_topic_no_common_commits() {
2300        let ctx = make_context();
2301
2302        let topic = Topic::new(
2303            &ctx,
2304            &CommitId::new(EXTEND_TARGET),
2305            &CommitId::new(ROOT_COMMIT),
2306        )
2307        .unwrap();
2308
2309        assert_eq!(topic.base.as_str(), EXTEND_TARGET);
2310        assert_eq!(topic.sha1.as_str(), ROOT_COMMIT);
2311
2312        assert_eq!(topic.diffs.len(), 3);
2313
2314        compare_diffs(
2315            &topic.diffs[0],
2316            &DiffInfo {
2317                old_mode: "000000".into(),
2318                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2319                new_mode: "100644".into(),
2320                new_blob: CommitId::new("16fe87b06e802f094b3fbb0894b137bca2b16ef1"),
2321                name: FileName::Normal("LICENSE-APACHE".into()),
2322                status: StatusChange::Added,
2323            },
2324        );
2325
2326        compare_diffs(
2327            &topic.diffs[1],
2328            &DiffInfo {
2329                old_mode: "000000".into(),
2330                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2331                new_mode: "100644".into(),
2332                new_blob: CommitId::new("8f6f4b4a936616349e1f2846632c95cff33a3c5d"),
2333                name: FileName::Normal("LICENSE-MIT".into()),
2334                status: StatusChange::Added,
2335            },
2336        );
2337
2338        compare_diffs(
2339            &topic.diffs[2],
2340            &DiffInfo {
2341                old_mode: "100644".into(),
2342                old_blob: CommitId::new("e4b0cf6006925052f1172d5dea15d9a7aec373f3"),
2343                new_mode: "000000".into(),
2344                new_blob: CommitId::new("0000000000000000000000000000000000000000"),
2345                name: FileName::Normal("content".into()),
2346                status: StatusChange::Deleted,
2347            },
2348        );
2349    }
2350
2351    // Test the merge base detection logic of the `Topic` structure. The test is set up as:
2352    //
2353    // ```
2354    // R - A - A'
2355    //  \   \ /
2356    //   \   X
2357    //    \ / \
2358    //     B - B'
2359    // ```
2360    //
2361    // where `R` is a root commit.
2362
2363    // A
2364    const ADD_A_COMMIT: &str = "80480d74af8d8a0d3de594606917da06ae518f49";
2365    // B
2366    const ADD_B_COMMIT: &str = "0094392bd07fb7064a98b39791c5aefbcbd5a1e8";
2367    // A'
2368    const MERGE_B_INTO_A: &str = "2274b3051218fda7e2efb06ee23d3187a9217941";
2369    // B'
2370    const MERGE_A_INTO_B: &str = "6d460df5f502aa4cd855108ec54dcda3b4e86a32";
2371
2372    #[test]
2373    fn test_topic_multiple_merge_base_a_as_base() {
2374        let ctx = make_context();
2375
2376        let topic = Topic::new(
2377            &ctx,
2378            &CommitId::new(MERGE_B_INTO_A),
2379            &CommitId::new(MERGE_A_INTO_B),
2380        )
2381        .unwrap();
2382
2383        assert_eq!(topic.base.as_str(), ADD_A_COMMIT);
2384        assert_eq!(topic.sha1.as_str(), MERGE_A_INTO_B);
2385
2386        assert_eq!(topic.diffs.len(), 1);
2387
2388        compare_diffs(
2389            &topic.diffs[0],
2390            &DiffInfo {
2391                old_mode: "000000".into(),
2392                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2393                new_mode: "100644".into(),
2394                new_blob: CommitId::new("d95f3ad14dee633a758d2e331151e950dd13e4ed"),
2395                name: FileName::Normal("b".into()),
2396                status: StatusChange::Added,
2397            },
2398        );
2399    }
2400
2401    #[test]
2402    fn test_topic_multiple_merge_base_b_as_base() {
2403        let ctx = make_context();
2404
2405        let topic = Topic::new(
2406            &ctx,
2407            &CommitId::new(MERGE_A_INTO_B),
2408            &CommitId::new(MERGE_B_INTO_A),
2409        )
2410        .unwrap();
2411
2412        assert_eq!(topic.base.as_str(), ADD_B_COMMIT);
2413        assert_eq!(topic.sha1.as_str(), MERGE_B_INTO_A);
2414
2415        assert_eq!(topic.diffs.len(), 1);
2416
2417        compare_diffs(
2418            &topic.diffs[0],
2419            &DiffInfo {
2420                old_mode: "000000".into(),
2421                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2422                new_mode: "100644".into(),
2423                new_blob: CommitId::new("d95f3ad14dee633a758d2e331151e950dd13e4ed"),
2424                name: FileName::Normal("a".into()),
2425                status: StatusChange::Added,
2426            },
2427        );
2428    }
2429    // Test the merge base detection logic of the `Topic` structure with root commits. The test is
2430    // set up as:
2431    //
2432    // ```
2433    // A - A' (topic)
2434    //  \ /
2435    //   X
2436    //  / \
2437    // B - B' (base)
2438    // ```
2439    //
2440    // where `A` and `B` are a root commits.
2441
2442    // A
2443    const ROOT_COMMIT_A: &str = "1b2c7b3dad2fb7d14fbf0b619bf7faca4dd01243";
2444    // B
2445    const ROOT_COMMIT_B: &str = "7531e6df007ca1130e5d64b8627b3288844e38a4";
2446    // A'
2447    const MERGE_B_ROOT_INTO_A: &str = "e93b017d9284492e485a53fb5c7566cd4f0a8e6f";
2448    // B'
2449    const MERGE_A_ROOT_INTO_B: &str = "c1637e2d837ce657aa54df2d62b19b7e46249eb8";
2450
2451    #[test]
2452    fn test_topic_multiple_merge_base_a_root_as_base() {
2453        let ctx = make_context();
2454
2455        let topic = Topic::new(
2456            &ctx,
2457            &CommitId::new(MERGE_B_ROOT_INTO_A),
2458            &CommitId::new(MERGE_A_ROOT_INTO_B),
2459        )
2460        .unwrap();
2461
2462        assert_eq!(topic.base.as_str(), ROOT_COMMIT_A);
2463        assert_eq!(topic.sha1.as_str(), MERGE_A_ROOT_INTO_B);
2464
2465        assert_eq!(topic.diffs.len(), 2);
2466
2467        compare_diffs(
2468            &topic.diffs[0],
2469            &DiffInfo {
2470                old_mode: "000000".into(),
2471                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2472                new_mode: "100644".into(),
2473                new_blob: CommitId::new("16fe87b06e802f094b3fbb0894b137bca2b16ef1"),
2474                name: FileName::Normal("LICENSE-APACHE".into()),
2475                status: StatusChange::Added,
2476            },
2477        );
2478        compare_diffs(
2479            &topic.diffs[1],
2480            &DiffInfo {
2481                old_mode: "000000".into(),
2482                old_blob: CommitId::new("0000000000000000000000000000000000000000"),
2483                new_mode: "100644".into(),
2484                new_blob: CommitId::new("8f6f4b4a936616349e1f2846632c95cff33a3c5d"),
2485                name: FileName::Normal("LICENSE-MIT".into()),
2486                status: StatusChange::Added,
2487            },
2488        );
2489    }
2490
2491    #[test]
2492    fn test_topic_multiple_merge_base_b_root_as_base() {
2493        let ctx = make_context();
2494
2495        let topic = Topic::new(
2496            &ctx,
2497            &CommitId::new(MERGE_A_ROOT_INTO_B),
2498            &CommitId::new(MERGE_B_ROOT_INTO_A),
2499        )
2500        .unwrap();
2501
2502        assert_eq!(topic.base.as_str(), ROOT_COMMIT_B);
2503        assert_eq!(topic.sha1.as_str(), MERGE_B_ROOT_INTO_A);
2504
2505        assert_eq!(topic.diffs.len(), 0);
2506    }
2507
2508    #[test]
2509    fn test_impl_content_for_commit_path_diff() {
2510        let ctx = make_context();
2511
2512        let commit = Commit::new(&ctx, &CommitId::new(CHANGES_COMMIT)).unwrap();
2513        let file = FileName::Normal("src/context.rs".into());
2514
2515        assert_eq!(
2516            Content::path_diff(&commit, &file).unwrap(),
2517            commit.path_diff(&file).unwrap(),
2518        );
2519    }
2520
2521    #[test]
2522    fn test_impl_content_for_topic_path_diff() {
2523        let ctx = make_context();
2524
2525        let topic = Topic::new(
2526            &ctx,
2527            &CommitId::new(ROOT_COMMIT),
2528            &CommitId::new(CHANGES_COMMIT),
2529        )
2530        .unwrap();
2531        let file = FileName::Normal("src/context.rs".into());
2532
2533        assert_eq!(
2534            Content::path_diff(&topic, &file).unwrap(),
2535            topic.path_diff(&file).unwrap(),
2536        );
2537    }
2538}