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(crate) const ZED_ZIPPY_LOGIN: &str = "zed-zippy[bot]";
19pub(crate) const ZED_ZIPPY_EMAIL: &str = "234243425+zed-zippy[bot]@users.noreply.github.com";
20
21pub trait Subcommand {
22 type ParsedOutput: FromStr<Err = anyhow::Error>;
23
24 fn args(&self) -> impl IntoIterator<Item = String>;
25}
26
27#[derive(Deref, DerefMut)]
28pub struct GitCommand<G: Subcommand> {
29 #[deref]
30 #[deref_mut]
31 subcommand: G,
32}
33
34impl<G: Subcommand> GitCommand<G> {
35 #[must_use]
36 pub fn run(subcommand: G) -> Result<G::ParsedOutput> {
37 Self { subcommand }.run_impl()
38 }
39
40 fn run_impl(self) -> Result<G::ParsedOutput> {
41 let command_output = Command::new("git")
42 .args(self.subcommand.args())
43 .output()
44 .context("Failed to spawn command")?;
45
46 if command_output.status.success() {
47 String::from_utf8(command_output.stdout)
48 .map_err(|_| anyhow!("Invalid UTF8"))
49 .and_then(|s| {
50 G::ParsedOutput::from_str(s.trim())
51 .map_err(|e| anyhow!("Failed to parse from string: {e:?}"))
52 })
53 } else {
54 anyhow::bail!(
55 "Command failed with exit code {}, stderr: {}",
56 command_output.status.code().unwrap_or_default(),
57 String::from_utf8(command_output.stderr).unwrap_or_default()
58 )
59 }
60 }
61}
62
63#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
64pub enum ReleaseChannel {
65 Stable,
66 Preview,
67}
68
69impl ReleaseChannel {
70 pub(crate) fn tag_suffix(&self) -> &'static str {
71 match self {
72 ReleaseChannel::Stable => "",
73 ReleaseChannel::Preview => "-pre",
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct VersionTag(Version, ReleaseChannel);
80
81impl VersionTag {
82 pub fn parse(input: &str) -> Result<Self, anyhow::Error> {
83 // Being a bit more lenient for human inputs
84 let version = input.strip_prefix('v').unwrap_or(input);
85
86 let (version_str, channel) = version
87 .strip_suffix("-pre")
88 .map_or((version, ReleaseChannel::Stable), |version_str| {
89 (version_str, ReleaseChannel::Preview)
90 });
91
92 Version::parse(version_str)
93 .map(|version| Self(version, channel))
94 .map_err(|_| anyhow::anyhow!("Failed to parse version from tag!"))
95 }
96
97 pub fn version(&self) -> &Version {
98 &self.0
99 }
100}
101
102impl ToString for VersionTag {
103 fn to_string(&self) -> String {
104 format!(
105 "v{version}{channel_suffix}",
106 version = self.0,
107 channel_suffix = self.1.tag_suffix()
108 )
109 }
110}
111
112#[derive(Debug, Deref, FromStr, PartialEq, Eq, Hash, Deserialize)]
113pub struct CommitSha(pub(crate) String);
114
115impl CommitSha {
116 pub fn new(sha: String) -> Self {
117 Self(sha)
118 }
119
120 pub fn short(&self) -> &str {
121 self.0.as_str().split_at(8).0
122 }
123}
124
125#[derive(Debug)]
126pub struct CommitDetails {
127 sha: CommitSha,
128 author: Committer,
129 title: String,
130 body: String,
131}
132
133impl CommitDetails {
134 pub fn new(sha: CommitSha, author: Committer, title: String, body: String) -> Self {
135 Self {
136 sha,
137 author,
138 title,
139 body,
140 }
141 }
142}
143
144impl FromStr for CommitDetails {
145 type Err = anyhow::Error;
146
147 fn from_str(s: &str) -> Result<Self, Self::Err> {
148 CommitList::from_str(s).and_then(|list| {
149 list.into_iter()
150 .next()
151 .ok_or_else(|| anyhow!("No commit found"))
152 })
153 }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct Committer {
158 name: String,
159 email: String,
160}
161
162impl Committer {
163 pub fn new(name: &str, email: &str) -> Self {
164 Self {
165 name: name.to_owned(),
166 email: email.to_owned(),
167 }
168 }
169
170 pub(crate) fn is_zed_zippy(&self) -> bool {
171 self.email == ZED_ZIPPY_EMAIL
172 }
173}
174
175impl fmt::Display for Committer {
176 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
177 write!(formatter, "{} ({})", self.name, self.email)
178 }
179}
180
181impl CommitDetails {
182 const BODY_DELIMITER: &str = "|body-delimiter|";
183 const COMMIT_DELIMITER: &str = "|commit-delimiter|";
184 const FIELD_DELIMITER: &str = "|field-delimiter|";
185 const FORMAT_STRING: &str = "%H|field-delimiter|%an|field-delimiter|%ae|field-delimiter|%s|body-delimiter|%b|commit-delimiter|";
186
187 fn parse(line: &str, body: &str) -> Result<Self, anyhow::Error> {
188 let Some([sha, author_name, author_email, title]) =
189 line.splitn(4, Self::FIELD_DELIMITER).collect_array()
190 else {
191 return Err(anyhow!("Failed to parse commit fields from input {line}"));
192 };
193
194 Ok(CommitDetails {
195 sha: CommitSha(sha.to_owned()),
196 author: Committer::new(author_name, author_email),
197 title: title.to_owned(),
198 body: body.to_owned(),
199 })
200 }
201
202 pub fn pr_number(&self) -> Option<u64> {
203 // Since we use squash merge, all commit titles end with the '(#12345)' pattern.
204 // While we could strictly speaking index into this directly, go for a slightly
205 // less prone approach to errors
206 const PATTERN: &str = " (#";
207 self.title
208 .rfind(PATTERN)
209 .and_then(|location| {
210 self.title[location..]
211 .find(')')
212 .map(|relative_end| location + PATTERN.len()..location + relative_end)
213 })
214 .and_then(|range| self.title[range].parse().ok())
215 }
216
217 pub(crate) fn co_authors(&self) -> Option<Vec<Committer>> {
218 static CO_AUTHOR_REGEX: LazyLock<Regex> =
219 LazyLock::new(|| Regex::new(r"Co-authored-by: (.+) <(.+)>").unwrap());
220
221 let mut co_authors = Vec::new();
222
223 for cap in CO_AUTHOR_REGEX.captures_iter(&self.body.as_ref()) {
224 let Some((name, email)) = cap
225 .get(1)
226 .map(|m| m.as_str())
227 .zip(cap.get(2).map(|m| m.as_str()))
228 else {
229 continue;
230 };
231 co_authors.push(Committer::new(name, email));
232 }
233
234 co_authors.is_empty().not().then_some(co_authors)
235 }
236
237 pub(crate) fn author(&self) -> &Committer {
238 &self.author
239 }
240
241 pub(crate) fn title(&self) -> &str {
242 &self.title
243 }
244
245 pub(crate) fn sha(&self) -> &CommitSha {
246 &self.sha
247 }
248
249 pub(crate) fn version_bump_mention(&self) -> Option<&str> {
250 static VERSION_BUMP_REGEX: LazyLock<Regex> = LazyLock::new(|| {
251 Regex::new(r"^Bump to [0-9]+\.[0-9]+\.[0-9]+ for @([a-zA-Z0-9][a-zA-Z0-9-]*)$").unwrap()
252 });
253
254 VERSION_BUMP_REGEX
255 .captures(&self.title)
256 .and_then(|cap| cap.get(1))
257 .map(|m| m.as_str())
258 }
259}
260
261#[derive(Debug, Deref, Default, DerefMut)]
262pub struct CommitList(Vec<CommitDetails>);
263
264impl CommitList {
265 pub fn range(&self) -> Option<String> {
266 self.0
267 .first()
268 .zip(self.0.last())
269 .map(|(first, last)| format!("{}..{}", last.sha().0, first.sha().0))
270 }
271}
272
273impl IntoIterator for CommitList {
274 type IntoIter = std::vec::IntoIter<CommitDetails>;
275 type Item = CommitDetails;
276
277 fn into_iter(self) -> std::vec::IntoIter<Self::Item> {
278 self.0.into_iter()
279 }
280}
281
282impl FromStr for CommitList {
283 type Err = anyhow::Error;
284
285 fn from_str(input: &str) -> Result<Self, Self::Err> {
286 Ok(CommitList(
287 input
288 .split(CommitDetails::COMMIT_DELIMITER)
289 .filter(|commit_details| !commit_details.is_empty())
290 .map(|commit_details| {
291 let (line, body) = commit_details
292 .trim()
293 .split_once(CommitDetails::BODY_DELIMITER)
294 .expect("Missing body delimiter");
295 CommitDetails::parse(line, body)
296 .expect("Parsing from the output should succeed")
297 })
298 .collect(),
299 ))
300 }
301}
302
303pub struct GetVersionTags;
304
305impl Subcommand for GetVersionTags {
306 type ParsedOutput = VersionTagList;
307
308 fn args(&self) -> impl IntoIterator<Item = String> {
309 ["tag", "-l", "v*"].map(ToOwned::to_owned)
310 }
311}
312
313pub struct VersionTagList(Vec<VersionTag>);
314
315impl VersionTagList {
316 pub fn sorted(mut self) -> Self {
317 self.0.sort_by(|a, b| a.version().cmp(b.version()));
318 self
319 }
320
321 pub fn find_previous_minor_version(&self, version_tag: &VersionTag) -> Option<&VersionTag> {
322 self.0
323 .iter()
324 .take_while(|tag| tag.version() < version_tag.version())
325 .collect_vec()
326 .into_iter()
327 .rev()
328 .find(|tag| {
329 (tag.version().major < version_tag.version().major
330 || (tag.version().major == version_tag.version().major
331 && tag.version().minor < version_tag.version().minor))
332 && tag.version().patch == 0
333 })
334 }
335}
336
337impl FromStr for VersionTagList {
338 type Err = anyhow::Error;
339
340 fn from_str(s: &str) -> Result<Self, Self::Err> {
341 let version_tags = s.lines().flat_map(VersionTag::parse).collect_vec();
342
343 version_tags
344 .is_empty()
345 .not()
346 .then_some(Self(version_tags))
347 .ok_or_else(|| anyhow::anyhow!("No version tags found"))
348 }
349}
350
351pub struct InfoForCommit {
352 sha: String,
353}
354
355impl InfoForCommit {
356 pub fn new(sha: impl ToString) -> Self {
357 Self {
358 sha: sha.to_string(),
359 }
360 }
361}
362
363impl Subcommand for InfoForCommit {
364 type ParsedOutput = CommitDetails;
365
366 fn args(&self) -> impl IntoIterator<Item = String> {
367 [
368 "log".to_string(),
369 format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
370 format!("{sha}~1..{sha}", sha = self.sha),
371 ]
372 }
373}
374
375pub struct CommitsFromVersionToVersion {
376 version_tag: VersionTag,
377 branch: String,
378}
379
380impl CommitsFromVersionToVersion {
381 pub fn new(version_tag: VersionTag, branch: String) -> Self {
382 Self {
383 version_tag,
384 branch,
385 }
386 }
387}
388
389impl Subcommand for CommitsFromVersionToVersion {
390 type ParsedOutput = CommitList;
391
392 fn args(&self) -> impl IntoIterator<Item = String> {
393 [
394 "log".to_string(),
395 format!("--pretty=format:{}", CommitDetails::FORMAT_STRING),
396 format!(
397 "{version}..{branch}",
398 version = self.version_tag.to_string(),
399 branch = self.branch
400 ),
401 ]
402 }
403}
404
405pub struct NoOutput;
406
407impl FromStr for NoOutput {
408 type Err = anyhow::Error;
409
410 fn from_str(_: &str) -> Result<Self, Self::Err> {
411 Ok(NoOutput)
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use indoc::indoc;
419
420 #[test]
421 fn parse_stable_version_tag() {
422 let tag = VersionTag::parse("v0.172.8").unwrap();
423 assert_eq!(tag.version().major, 0);
424 assert_eq!(tag.version().minor, 172);
425 assert_eq!(tag.version().patch, 8);
426 assert_eq!(tag.1, ReleaseChannel::Stable);
427 }
428
429 #[test]
430 fn parse_preview_version_tag() {
431 let tag = VersionTag::parse("v0.172.1-pre").unwrap();
432 assert_eq!(tag.version().major, 0);
433 assert_eq!(tag.version().minor, 172);
434 assert_eq!(tag.version().patch, 1);
435 assert_eq!(tag.1, ReleaseChannel::Preview);
436 }
437
438 #[test]
439 fn parse_version_tag_without_v_prefix() {
440 let tag = VersionTag::parse("0.172.8").unwrap();
441 assert_eq!(tag.version().major, 0);
442 assert_eq!(tag.version().minor, 172);
443 assert_eq!(tag.version().patch, 8);
444 }
445
446 #[test]
447 fn parse_invalid_version_tag() {
448 let result = VersionTag::parse("vConradTest");
449 assert!(result.is_err());
450 }
451
452 #[test]
453 fn version_tag_stable_roundtrip() {
454 let tag = VersionTag::parse("v0.172.8").unwrap();
455 assert_eq!(tag.to_string(), "v0.172.8");
456 }
457
458 #[test]
459 fn version_tag_preview_roundtrip() {
460 let tag = VersionTag::parse("v0.172.1-pre").unwrap();
461 assert_eq!(tag.to_string(), "v0.172.1-pre");
462 }
463
464 #[test]
465 fn sorted_orders_by_semver() {
466 let input = indoc! {"
467 v0.172.8
468 v0.170.1
469 v0.171.4
470 v0.170.2
471 v0.172.11
472 v0.171.3
473 v0.172.9
474 "};
475 let list = VersionTagList::from_str(input).unwrap().sorted();
476 for window in list.0.windows(2) {
477 assert!(
478 window[0].version() <= window[1].version(),
479 "{} should come before {}",
480 window[0].to_string(),
481 window[1].to_string()
482 );
483 }
484 assert_eq!(list.0[0].to_string(), "v0.170.1");
485 assert_eq!(list.0[list.0.len() - 1].to_string(), "v0.172.11");
486 }
487
488 #[test]
489 fn find_previous_minor_for_173_returns_172() {
490 let input = indoc! {"
491 v0.170.1
492 v0.170.2
493 v0.171.3
494 v0.171.4
495 v0.172.0
496 v0.172.8
497 v0.172.9
498 v0.172.11
499 "};
500 let list = VersionTagList::from_str(input).unwrap().sorted();
501 let target = VersionTag::parse("v0.173.0").unwrap();
502 let previous = list.find_previous_minor_version(&target).unwrap();
503 assert_eq!(previous.version().major, 0);
504 assert_eq!(previous.version().minor, 172);
505 assert_eq!(previous.version().patch, 0);
506 }
507
508 #[test]
509 fn find_previous_minor_skips_same_minor() {
510 let input = indoc! {"
511 v0.172.8
512 v0.172.9
513 v0.172.11
514 "};
515 let list = VersionTagList::from_str(input).unwrap().sorted();
516 let target = VersionTag::parse("v0.172.8").unwrap();
517 assert!(list.find_previous_minor_version(&target).is_none());
518 }
519
520 #[test]
521 fn find_previous_minor_with_major_version_gap() {
522 let input = indoc! {"
523 v0.172.0
524 v0.172.9
525 v0.172.11
526 "};
527 let list = VersionTagList::from_str(input).unwrap().sorted();
528 let target = VersionTag::parse("v1.0.0").unwrap();
529 let previous = list.find_previous_minor_version(&target).unwrap();
530 assert_eq!(previous.to_string(), "v0.172.0");
531 }
532
533 #[test]
534 fn find_previous_minor_requires_zero_patch_version() {
535 let input = indoc! {"
536 v0.172.1
537 v0.172.9
538 v0.172.11
539 "};
540 let list = VersionTagList::from_str(input).unwrap().sorted();
541 let target = VersionTag::parse("v1.0.0").unwrap();
542 assert!(list.find_previous_minor_version(&target).is_none());
543 }
544
545 #[test]
546 fn parse_tag_list_from_real_tags() {
547 let input = indoc! {"
548 v0.9999-temporary
549 vConradTest
550 v0.172.8
551 "};
552 let list = VersionTagList::from_str(input).unwrap();
553 assert_eq!(list.0.len(), 1);
554 assert_eq!(list.0[0].to_string(), "v0.172.8");
555 }
556
557 #[test]
558 fn parse_empty_tag_list_fails() {
559 let result = VersionTagList::from_str("");
560 assert!(result.is_err());
561 }
562
563 #[test]
564 fn pr_number_from_squash_merge_title() {
565 let line = format!(
566 "abc123{d}Author Name{d}author@email.com{d}Add cool feature (#12345)",
567 d = CommitDetails::FIELD_DELIMITER
568 );
569 let commit = CommitDetails::parse(&line, "").unwrap();
570 assert_eq!(commit.pr_number(), Some(12345));
571 }
572
573 #[test]
574 fn pr_number_missing() {
575 let line = format!(
576 "abc123{d}Author Name{d}author@email.com{d}Some commit without PR ref",
577 d = CommitDetails::FIELD_DELIMITER
578 );
579 let commit = CommitDetails::parse(&line, "").unwrap();
580 assert_eq!(commit.pr_number(), None);
581 }
582
583 #[test]
584 fn pr_number_takes_last_match() {
585 let line = format!(
586 "abc123{d}Author Name{d}author@email.com{d}Fix (#123) and refactor (#456)",
587 d = CommitDetails::FIELD_DELIMITER
588 );
589 let commit = CommitDetails::parse(&line, "").unwrap();
590 assert_eq!(commit.pr_number(), Some(456));
591 }
592
593 #[test]
594 fn co_authors_parsed_from_body() {
595 let line = format!(
596 "abc123{d}Author Name{d}author@email.com{d}Some title",
597 d = CommitDetails::FIELD_DELIMITER
598 );
599 let body = indoc! {"
600 Co-authored-by: Alice Smith <alice@example.com>
601 Co-authored-by: Bob Jones <bob@example.com>
602 "};
603 let commit = CommitDetails::parse(&line, body).unwrap();
604 let co_authors = commit.co_authors().unwrap();
605 assert_eq!(co_authors.len(), 2);
606 assert_eq!(
607 co_authors[0],
608 Committer::new("Alice Smith", "alice@example.com")
609 );
610 assert_eq!(
611 co_authors[1],
612 Committer::new("Bob Jones", "bob@example.com")
613 );
614 }
615
616 #[test]
617 fn no_co_authors_returns_none() {
618 let line = format!(
619 "abc123{d}Author Name{d}author@email.com{d}Some title",
620 d = CommitDetails::FIELD_DELIMITER
621 );
622 let commit = CommitDetails::parse(&line, "").unwrap();
623 assert!(commit.co_authors().is_none());
624 }
625
626 #[test]
627 fn commit_sha_short_returns_first_8_chars() {
628 let sha = CommitSha("abcdef1234567890abcdef1234567890abcdef12".into());
629 assert_eq!(sha.short(), "abcdef12");
630 }
631
632 #[test]
633 fn version_bump_mention_extracts_username() {
634 let line = format!(
635 "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller",
636 d = CommitDetails::FIELD_DELIMITER
637 );
638 let commit = CommitDetails::parse(&line, "").unwrap();
639 assert_eq!(commit.version_bump_mention(), Some("cole-miller"));
640 }
641
642 #[test]
643 fn version_bump_mention_returns_none_without_mention() {
644 let line = format!(
645 "abc123{d}Alice{d}alice@test.com{d}Fix a bug",
646 d = CommitDetails::FIELD_DELIMITER
647 );
648 let commit = CommitDetails::parse(&line, "").unwrap();
649 assert!(commit.version_bump_mention().is_none());
650 }
651
652 #[test]
653 fn version_bump_mention_rejects_wrong_prefix() {
654 let line = format!(
655 "abc123{d}Zed Zippy{d}bot@test.com{d}Fix thing for @cole-miller",
656 d = CommitDetails::FIELD_DELIMITER
657 );
658 let commit = CommitDetails::parse(&line, "").unwrap();
659 assert!(commit.version_bump_mention().is_none());
660 }
661
662 #[test]
663 fn version_bump_mention_rejects_bare_mention() {
664 let line = format!(
665 "abc123{d}Zed Zippy{d}bot@test.com{d}@cole-miller bumped something",
666 d = CommitDetails::FIELD_DELIMITER
667 );
668 let commit = CommitDetails::parse(&line, "").unwrap();
669 assert!(commit.version_bump_mention().is_none());
670 }
671
672 #[test]
673 fn version_bump_mention_rejects_trailing_text() {
674 let line = format!(
675 "abc123{d}Zed Zippy{d}bot@test.com{d}Bump to 0.230.2 for @cole-miller extra",
676 d = CommitDetails::FIELD_DELIMITER
677 );
678 let commit = CommitDetails::parse(&line, "").unwrap();
679 assert!(commit.version_bump_mention().is_none());
680 }
681
682 #[test]
683 fn committer_is_zed_zippy() {
684 let committer = Committer::new("Zed Zippy", ZED_ZIPPY_EMAIL);
685 assert!(committer.is_zed_zippy());
686 }
687
688 #[test]
689 fn committer_is_not_zed_zippy() {
690 let committer = Committer::new("Alice", "alice@test.com");
691 assert!(!committer.is_zed_zippy());
692 }
693
694 #[test]
695 fn parse_commit_list_from_git_log_format() {
696 let fd = CommitDetails::FIELD_DELIMITER;
697 let bd = CommitDetails::BODY_DELIMITER;
698 let cd = CommitDetails::COMMIT_DELIMITER;
699
700 let input = format!(
701 "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}"
702 );
703
704 let list = CommitList::from_str(&input).unwrap();
705 assert_eq!(list.0.len(), 2);
706
707 assert_eq!(list.0[0].sha().0, "sha111");
708 assert_eq!(
709 list.0[0].author(),
710 &Committer::new("Alice", "alice@test.com")
711 );
712 assert_eq!(list.0[0].title(), "First commit (#100)");
713 assert_eq!(list.0[0].pr_number(), Some(100));
714 assert_eq!(list.0[0].body, "First body");
715
716 assert_eq!(list.0[1].sha().0, "sha222");
717 assert_eq!(list.0[1].author(), &Committer::new("Bob", "bob@test.com"));
718 assert_eq!(list.0[1].title(), "Second commit (#200)");
719 assert_eq!(list.0[1].pr_number(), Some(200));
720 assert_eq!(list.0[1].body, "Second body");
721 }
722}