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