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