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