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