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}