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}