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