git.rs

  1#![allow(clippy::disallowed_methods, reason = "This is only used in xtasks")]
  2use std::{
  3    fmt::{self, Debug},
  4    ops::Not,
  5    process::Command,
  6    str::FromStr,
  7    sync::LazyLock,
  8};
  9
 10use anyhow::{Context, Result, anyhow};
 11use derive_more::{Deref, DerefMut, FromStr};
 12
 13use itertools::Itertools;
 14use regex::Regex;
 15use semver::Version;
 16use serde::Deserialize;
 17
 18pub(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]";
 19pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
 20
 21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
 22pub enum AutomatedChangeKind {
 23    VersionBump,
 24    ReleaseChannelUpdate,
 25}
 26
 27impl AutomatedChangeKind {
 28    pub(crate) fn expected_files(&self) -> &'static [&'static str] {
 29        match self {
 30            Self::VersionBump => &["Cargo.lock", "crates/zed/Cargo.toml"],
 31            Self::ReleaseChannelUpdate => &["crates/zed/RELEASE_CHANNEL"],
 32        }
 33    }
 34
 35    pub(crate) fn expected_loc(&self) -> u64 {
 36        match self {
 37            Self::VersionBump => 2,
 38            Self::ReleaseChannelUpdate => 1,
 39        }
 40    }
 41}
 42
 43impl fmt::Display for AutomatedChangeKind {
 44    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
 45        match self {
 46            Self::VersionBump => formatter.write_str("version bump"),
 47            Self::ReleaseChannelUpdate => formatter.write_str("release channel update"),
 48        }
 49    }
 50}
 51
 52pub trait Subcommand {
 53    type ParsedOutput: FromStr<Err = anyhow::Error>;
 54
 55    fn args(&self) -> impl IntoIterator<Item = String>;
 56}
 57
 58#[derive(Deref, DerefMut)]
 59pub struct GitCommand<G: Subcommand> {
 60    #[deref]
 61    #[deref_mut]
 62    subcommand: G,
 63}
 64
 65impl<G: Subcommand> GitCommand<G> {
 66    #[must_use]
 67    pub fn run(subcommand: G) -> Result<G::ParsedOutput> {
 68        Self { subcommand }.run_impl()
 69    }
 70
 71    fn run_impl(self) -> Result<G::ParsedOutput> {
 72        let command_output = Command::new("git")
 73            .args(self.subcommand.args())
 74            .output()
 75            .context("Failed to spawn command")?;
 76
 77        if command_output.status.success() {
 78            String::from_utf8(command_output.stdout)
 79                .map_err(|_| anyhow!("Invalid UTF8"))
 80                .and_then(|s| {
 81                    G::ParsedOutput::from_str(s.trim())
 82                        .map_err(|e| anyhow!("Failed to parse from string: {e:?}"))
 83                })
 84        } else {
 85            anyhow::bail!(
 86                "Command failed with exit code {}, stderr: {}",
 87                command_output.status.code().unwrap_or_default(),
 88                String::from_utf8(command_output.stderr).unwrap_or_default()
 89            )
 90        }
 91    }
 92}
 93
 94#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
 95pub enum ReleaseChannel {
 96    Stable,
 97    Preview,
 98}
 99
100impl ReleaseChannel {
101    pub(crate) fn tag_suffix(&self) -> &'static str {
102        match self {
103            ReleaseChannel::Stable => "",
104            ReleaseChannel::Preview => "-pre",
105        }
106    }
107}
108
109#[derive(Debug, Clone)]
110pub struct VersionTag(Version, ReleaseChannel);
111
112impl VersionTag {
113    pub fn parse(input: &str) -> Result<Self, anyhow::Error> {
114        // Being a bit more lenient for human inputs
115        let version = input.strip_prefix('v').unwrap_or(input);
116
117        let (version_str, channel) = version
118            .strip_suffix("-pre")
119            .map_or((version, ReleaseChannel::Stable), |version_str| {
120                (version_str, ReleaseChannel::Preview)
121            });
122
123        Version::parse(version_str)
124            .map(|version| Self(version, channel))
125            .map_err(|_| anyhow::anyhow!("Failed to parse version from tag!"))
126    }
127
128    pub fn version(&self) -> &Version {
129        &self.0
130    }
131}
132
133impl ToString for VersionTag {
134    fn to_string(&self) -> String {
135        format!(
136            "v{version}{channel_suffix}",
137            version = self.0,
138            channel_suffix = self.1.tag_suffix()
139        )
140    }
141}
142
143#[derive(Debug, Deref, FromStr, PartialEq, Eq, Hash, Deserialize)]
144pub struct CommitSha(pub(crate) String);
145
146impl CommitSha {
147    pub fn new(sha: String) -> Self {
148        Self(sha)
149    }
150
151    pub fn short(&self) -> &str {
152        self.0.as_str().split_at(8).0
153    }
154}
155
156#[derive(Debug)]
157pub struct CommitDetails {
158    sha: CommitSha,
159    author: Committer,
160    title: String,
161    body: String,
162}
163
164impl CommitDetails {
165    pub fn new(sha: CommitSha, author: Committer, title: String, body: String) -> Self {
166        Self {
167            sha,
168            author,
169            title,
170            body,
171        }
172    }
173}
174
175impl FromStr for CommitDetails {
176    type Err = anyhow::Error;
177
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        CommitList::from_str(s).and_then(|list| {
180            list.into_iter()
181                .next()
182                .ok_or_else(|| anyhow!("No commit found"))
183        })
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Committer {
189    name: String,
190    email: String,
191}
192
193impl Committer {
194    pub fn new(name: &str, email: &str) -> Self {
195        Self {
196            name: name.to_owned(),
197            email: email.to_owned(),
198        }
199    }
200
201    pub(crate) fn is_zed_zippy(&self) -> bool {
202        self.email == ZED_ZIPPY_EMAIL
203    }
204}
205
206impl fmt::Display for Committer {
207    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
208        write!(formatter, "{} ({})", self.name, self.email)
209    }
210}
211
212impl CommitDetails {
213    const BODY_DELIMITER: &str = "|body-delimiter|";
214    const COMMIT_DELIMITER: &str = "|commit-delimiter|";
215    const FIELD_DELIMITER: &str = "|field-delimiter|";
216    const FORMAT_STRING: &str = "%H|field-delimiter|%an|field-delimiter|%ae|field-delimiter|%s|body-delimiter|%b|commit-delimiter|";
217
218    fn parse(line: &str, body: &str) -> Result<Self, anyhow::Error> {
219        let Some([sha, author_name, author_email, title]) =
220            line.splitn(4, Self::FIELD_DELIMITER).collect_array()
221        else {
222            return Err(anyhow!("Failed to parse commit fields from input {line}"));
223        };
224
225        Ok(CommitDetails {
226            sha: CommitSha(sha.to_owned()),
227            author: Committer::new(author_name, author_email),
228            title: title.to_owned(),
229            body: body.to_owned(),
230        })
231    }
232
233    pub fn pr_number(&self) -> Option<u64> {
234        // Since we use squash merge, all commit titles end with the '(#12345)' pattern.
235        // While we could strictly speaking index into this directly, go for a slightly
236        // less prone approach to errors
237        const PATTERN: &str = " (#";
238        self.title
239            .rfind(PATTERN)
240            .and_then(|location| {
241                self.title[location..]
242                    .find(')')
243                    .map(|relative_end| location + PATTERN.len()..location + relative_end)
244            })
245            .and_then(|range| self.title[range].parse().ok())
246    }
247
248    pub(crate) fn co_authors(&self) -> Option<Vec<Committer>> {
249        static CO_AUTHOR_REGEX: LazyLock<Regex> =
250            LazyLock::new(|| Regex::new(r"Co-authored-by: (.+) <(.+)>").unwrap());
251
252        let mut co_authors = Vec::new();
253
254        for cap in CO_AUTHOR_REGEX.captures_iter(&self.body.as_ref()) {
255            let Some((name, email)) = cap
256                .get(1)
257                .map(|m| m.as_str())
258                .zip(cap.get(2).map(|m| m.as_str()))
259            else {
260                continue;
261            };
262            co_authors.push(Committer::new(name, email));
263        }
264
265        co_authors.is_empty().not().then_some(co_authors)
266    }
267
268    pub(crate) fn author(&self) -> &Committer {
269        &self.author
270    }
271
272    pub(crate) fn title(&self) -> &str {
273        &self.title
274    }
275
276    pub fn sha(&self) -> &CommitSha {
277        &self.sha
278    }
279
280    pub(crate) fn detect_automated_change(&self) -> Option<(AutomatedChangeKind, &str)> {
281        static VERSION_BUMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
282            Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap()
283        });
284        static RELEASE_CHANNEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
285            Regex::new(r"^v[0-9]+\.[0-9]+\.x (?:stable|preview) for @([a-zA-Z0-9][a-zA-Z0-9-]*)$")
286                .unwrap()
287        });
288
289        VERSION_BUMP_REGEX
290            .captures(&self.title)
291            .and_then(|capture| capture.get(1))
292            .map(|r#match| (AutomatedChangeKind::VersionBump, r#match.as_str()))
293            .or_else(|| {
294                RELEASE_CHANNEL_REGEX
295                    .captures(&self.title)
296                    .and_then(|capture| capture.get(1))
297                    .map(|r#match| (AutomatedChangeKind::ReleaseChannelUpdate, r#match.as_str()))
298            })
299    }
300}
301
302#[derive(Debug, Deref, Default, DerefMut)]
303pub struct CommitList(Vec<CommitDetails>);
304
305impl CommitList {
306    pub fn range(&self) -> Option<String> {
307        self.0
308            .first()
309            .zip(self.0.last())
310            .map(|(first, last)| format!("{}..{}", last.sha().0, first.sha().0))
311    }
312}
313
314impl IntoIterator for CommitList {
315    type IntoIter = std::vec::IntoIter<CommitDetails>;
316    type Item = CommitDetails;
317
318    fn into_iter(self) -> std::vec::IntoIter<Self::Item> {
319        self.0.into_iter()
320    }
321}
322
323impl FromStr for CommitList {
324    type Err = anyhow::Error;
325
326    fn from_str(input: &str) -> Result<Self, Self::Err> {
327        Ok(CommitList(
328            input
329                .split(CommitDetails::COMMIT_DELIMITER)
330                .filter(|commit_details| !commit_details.is_empty())
331                .map(|commit_details| {
332                    let (line, body) = commit_details
333                        .trim()
334                        .split_once(CommitDetails::BODY_DELIMITER)
335                        .expect("Missing body delimiter");
336                    CommitDetails::parse(line, body)
337                        .expect("Parsing from the output should succeed")
338                })
339                .collect(),
340        ))
341    }
342}
343
344pub struct GetVersionTags;
345
346impl Subcommand for GetVersionTags {
347    type ParsedOutput = VersionTagList;
348
349    fn args(&self) -> impl IntoIterator<Item = String> {
350        ["tag", "-l", "v*"].map(ToOwned::to_owned)
351    }
352}
353
354pub struct VersionTagList(Vec<VersionTag>);
355
356impl VersionTagList {
357    pub fn sorted(mut self) -> Self {
358        self.0.sort_by(|a, b| a.version().cmp(b.version()));
359        self
360    }
361
362    pub fn find_previous_minor_version(&self, version_tag: &VersionTag) -> Option<&VersionTag> {
363        self.0
364            .iter()
365            .take_while(|tag| tag.version() < version_tag.version())
366            .collect_vec()
367            .into_iter()
368            .rev()
369            .find(|tag| {
370                (tag.version().major < version_tag.version().major
371                    || (tag.version().major == version_tag.version().major
372                        && tag.version().minor < version_tag.version().minor))
373                    && tag.version().patch == 0
374            })
375    }
376}
377
378impl FromStr for VersionTagList {
379    type Err = anyhow::Error;
380
381    fn from_str(s: &str) -> Result<Self, Self::Err> {
382        let version_tags = s.lines().flat_map(VersionTag::parse).collect_vec();
383
384        version_tags
385            .is_empty()
386            .not()
387            .then_some(Self(version_tags))
388            .ok_or_else(|| anyhow::anyhow!("No version tags found"))
389    }
390}
391
392pub struct InfoForCommit {
393    sha: String,
394}
395
396impl InfoForCommit {
397    pub fn new(sha: impl ToString) -> Self {
398        Self {
399            sha: sha.to_string(),
400        }
401    }
402}
403
404impl Subcommand for InfoForCommit {
405    type ParsedOutput = CommitDetails;
406
407    fn args(&self) -> impl IntoIterator<Item = String> {
408        [
409            "log".to_string(),
410            format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
411            format!("{sha}~1..{sha}", sha = self.sha),
412        ]
413    }
414}
415
416pub struct CommitsFromVersionToVersion {
417    version_tag: VersionTag,
418    branch: String,
419}
420
421impl CommitsFromVersionToVersion {
422    pub fn new(version_tag: VersionTag, branch: String) -> Self {
423        Self {
424            version_tag,
425            branch,
426        }
427    }
428}
429
430impl Subcommand for CommitsFromVersionToVersion {
431    type ParsedOutput = CommitList;
432
433    fn args(&self) -> impl IntoIterator<Item = String> {
434        [
435            "log".to_string(),
436            format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
437            format!(
438                "{version}..{branch}",
439                version = self.version_tag.to_string(),
440                branch = self.branch
441            ),
442        ]
443    }
444}
445
446pub struct NoOutput;
447
448impl FromStr for NoOutput {
449    type Err = anyhow::Error;
450
451    fn from_str(_: &str) -> Result<Self, Self::Err> {
452        Ok(NoOutput)
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use indoc::indoc;
460
461    #[test]
462    fn parse_stable_version_tag() {
463        let tag = VersionTag::parse("v0.172.8").unwrap();
464        assert_eq!(tag.version().major, 0);
465        assert_eq!(tag.version().minor, 172);
466        assert_eq!(tag.version().patch, 8);
467        assert_eq!(tag.1, ReleaseChannel::Stable);
468    }
469
470    #[test]
471    fn parse_preview_version_tag() {
472        let tag = VersionTag::parse("v0.172.1-pre").unwrap();
473        assert_eq!(tag.version().major, 0);
474        assert_eq!(tag.version().minor, 172);
475        assert_eq!(tag.version().patch, 1);
476        assert_eq!(tag.1, ReleaseChannel::Preview);
477    }
478
479    #[test]
480    fn parse_version_tag_without_v_prefix() {
481        let tag = VersionTag::parse("0.172.8").unwrap();
482        assert_eq!(tag.version().major, 0);
483        assert_eq!(tag.version().minor, 172);
484        assert_eq!(tag.version().patch, 8);
485    }
486
487    #[test]
488    fn parse_invalid_version_tag() {
489        let result = VersionTag::parse("vConradTest");
490        assert!(result.is_err());
491    }
492
493    #[test]
494    fn version_tag_stable_roundtrip() {
495        let tag = VersionTag::parse("v0.172.8").unwrap();
496        assert_eq!(tag.to_string(), "v0.172.8");
497    }
498
499    #[test]
500    fn version_tag_preview_roundtrip() {
501        let tag = VersionTag::parse("v0.172.1-pre").unwrap();
502        assert_eq!(tag.to_string(), "v0.172.1-pre");
503    }
504
505    #[test]
506    fn sorted_orders_by_semver() {
507        let input = indoc! {"
508            v0.172.8
509            v0.170.1
510            v0.171.4
511            v0.170.2
512            v0.172.11
513            v0.171.3
514            v0.172.9
515        "};
516        let list = VersionTagList::from_str(input).unwrap().sorted();
517        for window in list.0.windows(2) {
518            assert!(
519                window[0].version() <= window[1].version(),
520                "{} should come before {}",
521                window[0].to_string(),
522                window[1].to_string()
523            );
524        }
525        assert_eq!(list.0[0].to_string(), "v0.170.1");
526        assert_eq!(list.0[list.0.len() - 1].to_string(), "v0.172.11");
527    }
528
529    #[test]
530    fn find_previous_minor_for_173_returns_172() {
531        let input = indoc! {"
532            v0.170.1
533            v0.170.2
534            v0.171.3
535            v0.171.4
536            v0.172.0
537            v0.172.8
538            v0.172.9
539            v0.172.11
540        "};
541        let list = VersionTagList::from_str(input).unwrap().sorted();
542        let target = VersionTag::parse("v0.173.0").unwrap();
543        let previous = list.find_previous_minor_version(&target).unwrap();
544        assert_eq!(previous.version().major, 0);
545        assert_eq!(previous.version().minor, 172);
546        assert_eq!(previous.version().patch, 0);
547    }
548
549    #[test]
550    fn find_previous_minor_skips_same_minor() {
551        let input = indoc! {"
552            v0.172.8
553            v0.172.9
554            v0.172.11
555        "};
556        let list = VersionTagList::from_str(input).unwrap().sorted();
557        let target = VersionTag::parse("v0.172.8").unwrap();
558        assert!(list.find_previous_minor_version(&target).is_none());
559    }
560
561    #[test]
562    fn find_previous_minor_with_major_version_gap() {
563        let input = indoc! {"
564            v0.172.0
565            v0.172.9
566            v0.172.11
567        "};
568        let list = VersionTagList::from_str(input).unwrap().sorted();
569        let target = VersionTag::parse("v1.0.0").unwrap();
570        let previous = list.find_previous_minor_version(&target).unwrap();
571        assert_eq!(previous.to_string(), "v0.172.0");
572    }
573
574    #[test]
575    fn find_previous_minor_requires_zero_patch_version() {
576        let input = indoc! {"
577            v0.172.1
578            v0.172.9
579            v0.172.11
580        "};
581        let list = VersionTagList::from_str(input).unwrap().sorted();
582        let target = VersionTag::parse("v1.0.0").unwrap();
583        assert!(list.find_previous_minor_version(&target).is_none());
584    }
585
586    #[test]
587    fn parse_tag_list_from_real_tags() {
588        let input = indoc! {"
589            v0.9999-temporary
590            vConradTest
591            v0.172.8
592        "};
593        let list = VersionTagList::from_str(input).unwrap();
594        assert_eq!(list.0.len(), 1);
595        assert_eq!(list.0[0].to_string(), "v0.172.8");
596    }
597
598    #[test]
599    fn parse_empty_tag_list_fails() {
600        let result = VersionTagList::from_str("");
601        assert!(result.is_err());
602    }
603
604    #[test]
605    fn pr_number_from_squash_merge_title() {
606        let line = format!(
607            "abc123{d}Author Name{d}author@email.com{d}Add cool feature (#12345)",
608            d = CommitDetails::FIELD_DELIMITER
609        );
610        let commit = CommitDetails::parse(&line, "").unwrap();
611        assert_eq!(commit.pr_number(), Some(12345));
612    }
613
614    #[test]
615    fn pr_number_missing() {
616        let line = format!(
617            "abc123{d}Author Name{d}author@email.com{d}Some commit without PR ref",
618            d = CommitDetails::FIELD_DELIMITER
619        );
620        let commit = CommitDetails::parse(&line, "").unwrap();
621        assert_eq!(commit.pr_number(), None);
622    }
623
624    #[test]
625    fn pr_number_takes_last_match() {
626        let line = format!(
627            "abc123{d}Author Name{d}author@email.com{d}Fix (#123) and refactor (#456)",
628            d = CommitDetails::FIELD_DELIMITER
629        );
630        let commit = CommitDetails::parse(&line, "").unwrap();
631        assert_eq!(commit.pr_number(), Some(456));
632    }
633
634    #[test]
635    fn co_authors_parsed_from_body() {
636        let line = format!(
637            "abc123{d}Author Name{d}author@email.com{d}Some title",
638            d = CommitDetails::FIELD_DELIMITER
639        );
640        let body = indoc! {"
641            Co-authored-by: Alice Smith <alice@example.com>
642            Co-authored-by: Bob Jones <bob@example.com>
643        "};
644        let commit = CommitDetails::parse(&line, body).unwrap();
645        let co_authors = commit.co_authors().unwrap();
646        assert_eq!(co_authors.len(), 2);
647        assert_eq!(
648            co_authors[0],
649            Committer::new("Alice Smith", "alice@example.com")
650        );
651        assert_eq!(
652            co_authors[1],
653            Committer::new("Bob Jones", "bob@example.com")
654        );
655    }
656
657    #[test]
658    fn no_co_authors_returns_none() {
659        let line = format!(
660            "abc123{d}Author Name{d}author@email.com{d}Some title",
661            d = CommitDetails::FIELD_DELIMITER
662        );
663        let commit = CommitDetails::parse(&line, "").unwrap();
664        assert!(commit.co_authors().is_none());
665    }
666
667    #[test]
668    fn commit_sha_short_returns_first_8_chars() {
669        let sha = CommitSha("abcdef1234567890abcdef1234567890abcdef12".into());
670        assert_eq!(sha.short(), "abcdef12");
671    }
672
673    #[test]
674    fn automated_change_detects_version_bump() {
675        let line = format!(
676            "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller",
677            d = CommitDetails::FIELD_DELIMITER
678        );
679        let commit = CommitDetails::parse(&line, "").unwrap();
680        let (kind, actor) = commit.detect_automated_change().unwrap();
681        assert_eq!(kind, AutomatedChangeKind::VersionBump);
682        assert_eq!(actor, "cole-miller");
683    }
684
685    #[test]
686    fn automated_change_detects_stable_release_channel() {
687        let line = format!(
688            "abc123{d}Zed Zippy{d}bot@test.com{d}v0.233.x stable for @cole-miller",
689            d = CommitDetails::FIELD_DELIMITER
690        );
691        let commit = CommitDetails::parse(&line, "").unwrap();
692        let (kind, actor) = commit.detect_automated_change().unwrap();
693        assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
694        assert_eq!(actor, "cole-miller");
695    }
696
697    #[test]
698    fn automated_change_detects_preview_release_channel() {
699        let line = format!(
700            "abc123{d}Zed Zippy{d}bot@test.com{d}v0.234.x preview for @cole-miller",
701            d = CommitDetails::FIELD_DELIMITER
702        );
703        let commit = CommitDetails::parse(&line, "").unwrap();
704        let (kind, actor) = commit.detect_automated_change().unwrap();
705        assert_eq!(kind, AutomatedChangeKind::ReleaseChannelUpdate);
706        assert_eq!(actor, "cole-miller");
707    }
708
709    #[test]
710    fn automated_change_returns_none_for_regular_commit() {
711        let line = format!(
712            "abc123{d}Alice{d}alice@test.com{d}Fix a bug",
713            d = CommitDetails::FIELD_DELIMITER
714        );
715        let commit = CommitDetails::parse(&line, "").unwrap();
716        assert!(commit.detect_automated_change().is_none());
717    }
718
719    #[test]
720    fn automated_change_rejects_wrong_prefix() {
721        let line = format!(
722            "abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller",
723            d = CommitDetails::FIELD_DELIMITER
724        );
725        let commit = CommitDetails::parse(&line, "").unwrap();
726        assert!(commit.detect_automated_change().is_none());
727    }
728
729    #[test]
730    fn automated_change_rejects_trailing_text() {
731        let line = format!(
732            "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra",
733            d = CommitDetails::FIELD_DELIMITER
734        );
735        let commit = CommitDetails::parse(&line, "").unwrap();
736        assert!(commit.detect_automated_change().is_none());
737    }
738
739    #[test]
740    fn committer_is_zed_zippy() {
741        let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL);
742        assert!(committer.is_zed_zippy());
743    }
744
745    #[test]
746    fn committer_is_not_zed_zippy() {
747        let committer = Committer::new("Alice", "alice@test.com");
748        assert!(!committer.is_zed_zippy());
749    }
750
751    #[test]
752    fn parse_commit_list_from_git_log_format() {
753        let fd = CommitDetails::FIELD_DELIMITER;
754        let bd = CommitDetails::BODY_DELIMITER;
755        let cd = CommitDetails::COMMIT_DELIMITER;
756
757        let input = format!(
758            "sha111{fd}Alice{fd}alice@test.com{fd}First commit (#100){bd}First body{cd}sha222{fd}Bob{fd}bob@test.com{fd}Second commit (#200){bd}Second body{cd}"
759        );
760
761        let list = CommitList::from_str(&input).unwrap();
762        assert_eq!(list.0.len(), 2);
763
764        assert_eq!(list.0[0].sha().0, "sha111");
765        assert_eq!(
766            list.0[0].author(),
767            &Committer::new("Alice", "alice@test.com")
768        );
769        assert_eq!(list.0[0].title(), "First commit (#100)");
770        assert_eq!(list.0[0].pr_number(), Some(100));
771        assert_eq!(list.0[0].body, "First body");
772
773        assert_eq!(list.0[1].sha().0, "sha222");
774        assert_eq!(list.0[1].author(), &Committer::new("Bob", "bob@test.com"));
775        assert_eq!(list.0[1].title(), "Second commit (#200)");
776        assert_eq!(list.0[1].pr_number(), Some(200));
777        assert_eq!(list.0[1].body, "Second body");
778    }
779}