Skip to content

Commit 68c4289

Browse files
chore(xtask): add changelog check
1 parent 8eeb2e3 commit 68c4289

File tree

2 files changed

+410
-0
lines changed

2 files changed

+410
-0
lines changed

xtask/src/changelog.rs

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
use pico_args::Arguments;
2+
use xshell::Shell;
3+
4+
pub(crate) fn check_changelog(shell: Shell, mut args: Arguments) -> anyhow::Result<()> {
5+
const CHANGELOG_PATH_RELATIVE: &str = "./CHANGELOG.md";
6+
7+
let from_branch = args
8+
.free_from_str()
9+
.ok()
10+
.unwrap_or_else(|| "trunk".to_owned());
11+
let to_commit: Option<String> = args.free_from_str().ok();
12+
13+
let from_commit = shell
14+
.cmd("git")
15+
.args(["merge-base", "--fork-point", &from_branch])
16+
.args(to_commit.as_ref())
17+
.read()
18+
.unwrap();
19+
20+
let diff = shell
21+
.cmd("git")
22+
.args(["diff", &from_commit])
23+
// NOTE: If `to_commit` is not specified, we compare against the working tree, instead of
24+
// between commits.
25+
.args(to_commit.as_ref())
26+
.args(["--", CHANGELOG_PATH_RELATIVE])
27+
.read()
28+
.unwrap();
29+
30+
// NOTE: If `to_commit` is not specified, we need to fetch from the file system, instead of
31+
// `git show`.
32+
let changelog_contents = if let Some(to_commit) = to_commit.as_ref() {
33+
shell
34+
.cmd("git")
35+
.arg("show")
36+
.arg(format!("{to_commit}:{CHANGELOG_PATH_RELATIVE}"))
37+
.arg("--")
38+
.read()
39+
.unwrap()
40+
} else {
41+
shell.read_file(CHANGELOG_PATH_RELATIVE).unwrap()
42+
};
43+
44+
let mut failed = false;
45+
46+
let hunks_in_a_released_section = hunks_in_a_released_section(&changelog_contents, &diff);
47+
log::info!(
48+
"# of hunks in a released section of `{CHANGELOG_PATH_RELATIVE}`: {}",
49+
hunks_in_a_released_section.len()
50+
);
51+
if !hunks_in_a_released_section.is_empty() {
52+
failed = true;
53+
54+
eprintln!(
55+
"Found hunk(s) in released sections of `{}`, which we don't want:\n",
56+
CHANGELOG_PATH_RELATIVE,
57+
);
58+
59+
for hunk in &hunks_in_a_released_section {
60+
eprintln!("{hunk}");
61+
}
62+
}
63+
64+
if failed {
65+
Err(anyhow::Error::msg(format!(
66+
"one or more checks against `{}` failed; see above for details",
67+
CHANGELOG_PATH_RELATIVE,
68+
)))
69+
} else {
70+
Ok(())
71+
}
72+
}
73+
74+
/// Given some `changelog_contents` (in Markdown) containing the full end state of the provided
75+
/// `diff` (in [unified diff format]), return all hunks that are (1) below a `## Unreleased` section
76+
/// _and_ (2) above all other second-level (i.e., `## …`) headings.
77+
///
78+
/// [unified diff format]: https://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html
79+
///
80+
/// This function makes a few assumptions that are necessary to uphold for correctness, in the
81+
/// interest of a simple implementation:
82+
///
83+
/// - The provided `diff`'s end state _must_ correspond to `changelog_contents`.
84+
/// - The provided `diff` must _only_ contain a single entry for the file containing
85+
/// `changelog_contents`. using hunk information to compare against `changelog_contents`.
86+
///
87+
/// Failing to uphold these assumptons is not unsafe, but will yield incorrect results.
88+
fn hunks_in_a_released_section<'a>(changelog_contents: &str, diff: &'a str) -> Vec<&'a str> {
89+
let mut changelog_lines = changelog_contents.lines();
90+
91+
let changelog_unreleased_line_num =
92+
changelog_lines.position(|l| l == "## Unreleased").unwrap() as u64;
93+
94+
let changelog_first_release_section_line_num = changelog_unreleased_line_num
95+
+ 1
96+
+ changelog_lines.position(|l| l.starts_with("## ")).unwrap() as u64;
97+
98+
let hunks = {
99+
let first_hunk_match = diff.match_indices("\n@@").next();
100+
let Some((first_hunk_idx, _)) = first_hunk_match else {
101+
log::info!("no diff found");
102+
return vec![];
103+
};
104+
SplitPrefixInclusive::new("\n@@", &diff[first_hunk_idx..]).map(|s| &s['\n'.len_utf8()..])
105+
};
106+
let hunks_in_a_released_section = hunks
107+
.filter(|hunk| {
108+
let (hunk_header, hunk_contents) = hunk.split_once('\n').unwrap();
109+
110+
// Reference: This is of the format `@@ -86,6 +88,10 @@ …`.
111+
let post_change_hunk_start_offset = hunk_header
112+
.strip_prefix("@@ ")
113+
.unwrap()
114+
.split_once(" @@")
115+
.unwrap()
116+
.0
117+
.split_once(" +")
118+
.unwrap()
119+
.1
120+
.split_once(",")
121+
.unwrap()
122+
.0
123+
.parse::<u64>()
124+
.unwrap();
125+
126+
let lines_until_first_change = hunk_contents
127+
.lines()
128+
.take_while(|l| l.starts_with(' '))
129+
.count() as u64;
130+
131+
let first_hunk_change_start_offset =
132+
post_change_hunk_start_offset + lines_until_first_change;
133+
134+
first_hunk_change_start_offset >= changelog_first_release_section_line_num
135+
})
136+
.collect::<Vec<_>>();
137+
138+
hunks_in_a_released_section
139+
}
140+
141+
struct SplitPrefixInclusive<'haystack, 'prefix> {
142+
haystack: Option<&'haystack str>,
143+
prefix: &'prefix str,
144+
current_pos: usize,
145+
}
146+
147+
impl<'haystack, 'prefix> SplitPrefixInclusive<'haystack, 'prefix> {
148+
pub fn new(prefix: &'prefix str, haystack: &'haystack str) -> Self {
149+
assert!(haystack.starts_with(prefix));
150+
Self {
151+
haystack: Some(haystack),
152+
prefix,
153+
current_pos: 0,
154+
}
155+
}
156+
}
157+
158+
impl<'haystack> Iterator for SplitPrefixInclusive<'haystack, '_> {
159+
type Item = &'haystack str;
160+
161+
fn next(&mut self) -> Option<Self::Item> {
162+
let remaining = &self.haystack?[self.current_pos..];
163+
164+
let prefix_len = self.prefix.len();
165+
166+
// NOTE: We've guaranteed that the prefix is always at the start of what remains. So, skip
167+
// the first match manually, and adjust match indices by `prefix_len` later.
168+
let to_search = &remaining[prefix_len..];
169+
170+
match to_search.match_indices(self.prefix).next() {
171+
None => {
172+
self.haystack = None;
173+
Some(remaining)
174+
}
175+
Some((idx, _match)) => {
176+
let length = idx + prefix_len;
177+
self.current_pos += length;
178+
Some(&remaining[..length])
179+
}
180+
}
181+
}
182+
}
183+
184+
#[cfg(test)]
185+
mod test_split_prefix_inclusive {
186+
#[collapse_debuginfo(yes)]
187+
macro_rules! assert_chunks {
188+
($prefix: expr, $haystack: expr, $expected: expr $(,)?) => {
189+
assert_eq!(
190+
super::SplitPrefixInclusive::new($prefix, $haystack).collect::<Vec<_>>(),
191+
$expected.into_iter().collect::<Vec<_>>(),
192+
);
193+
};
194+
}
195+
196+
#[test]
197+
fn it_works() {
198+
assert_chunks! {
199+
"\n@@",
200+
"
201+
@@ -1,4 +1,5 @@
202+
<!--
203+
+ This change should be accepted.
204+
Pad out some changes here so we force multiple hunks in a diff.
205+
Pad out some changes here so we force multiple hunks in a diff.
206+
Pad out some changes here so we force multiple hunks in a diff.
207+
@@ -17,6 +18,7 @@
208+
- Pad out some changes here so we force multiple hunks in a diff.
209+
- Pad out some changes here so we force multiple hunks in a diff.
210+
- Pad out some changes here so we force multiple hunks in a diff.
211+
+- This change should be accepted.
212+
\u{0020}
213+
## Recently released
214+
\u{0020}
215+
@@ -26,6 +28,7 @@
216+
- Pad out some changes here so we force multiple hunks in a diff.
217+
- Pad out some changes here so we force multiple hunks in a diff.
218+
- Pad out some changes here so we force multiple hunks in a diff.
219+
+- This change was added after release, reject me!
220+
\u{0020}
221+
## An older release
222+
\u{0020}
223+
",
224+
[
225+
"
226+
@@ -1,4 +1,5 @@
227+
<!--
228+
+ This change should be accepted.
229+
Pad out some changes here so we force multiple hunks in a diff.
230+
Pad out some changes here so we force multiple hunks in a diff.
231+
Pad out some changes here so we force multiple hunks in a diff.",
232+
"
233+
@@ -17,6 +18,7 @@
234+
- Pad out some changes here so we force multiple hunks in a diff.
235+
- Pad out some changes here so we force multiple hunks in a diff.
236+
- Pad out some changes here so we force multiple hunks in a diff.
237+
+- This change should be accepted.
238+
\u{0020}
239+
## Recently released
240+
\u{0020}",
241+
"
242+
@@ -26,6 +28,7 @@
243+
- Pad out some changes here so we force multiple hunks in a diff.
244+
- Pad out some changes here so we force multiple hunks in a diff.
245+
- Pad out some changes here so we force multiple hunks in a diff.
246+
+- This change was added after release, reject me!
247+
\u{0020}
248+
## An older release
249+
\u{0020}
250+
",
251+
]
252+
}
253+
}
254+
}
255+
256+
#[cfg(test)]
257+
mod test_hunks_in_a_released_section {
258+
#[collapse_debuginfo(yes)]
259+
macro_rules! assert_released_section_changes {
260+
($changelog_contents: expr, $diff: expr, $expected: expr $(,)?) => {
261+
assert_eq!(
262+
super::hunks_in_a_released_section($changelog_contents, $diff),
263+
$expected
264+
.map(|h| h.to_owned())
265+
.into_iter()
266+
.collect::<Vec<_>>(),
267+
);
268+
};
269+
}
270+
271+
#[test]
272+
fn change_in_a_release_section_rejects() {
273+
assert_released_section_changes! {
274+
"\
275+
<!-- Some explanatory comment -->
276+
277+
## Unreleased
278+
279+
## Recently released
280+
281+
- This change actually went into the release.
282+
- This change was added after release, reject me!
283+
284+
## An older release
285+
286+
- Yada yada.
287+
",
288+
"\
289+
--- a/CHANGELOG.md
290+
+++ b/CHANGELOG.md
291+
@@ -5,6 +5,7 @@
292+
## Recently released
293+
\u{0020}
294+
- This change actually went into the release.
295+
+- This change was added after release, reject me!
296+
\u{0020}
297+
## An older release
298+
",
299+
[
300+
"\
301+
@@ -5,6 +5,7 @@
302+
## Recently released
303+
\u{0020}
304+
- This change actually went into the release.
305+
+- This change was added after release, reject me!
306+
\u{0020}
307+
## An older release
308+
",
309+
],
310+
}
311+
}
312+
313+
#[test]
314+
fn change_in_unreleased_not_rejected() {}
315+
316+
#[test]
317+
fn change_above_unreleased_not_rejected() {}
318+
319+
#[test]
320+
fn all_reject_and_not_reject_cases_at_once() {
321+
assert_released_section_changes! {
322+
"\
323+
<!--
324+
This change should be accepted.
325+
Pad out some changes here so we force multiple hunks in a diff.
326+
Pad out some changes here so we force multiple hunks in a diff.
327+
Pad out some changes here so we force multiple hunks in a diff.
328+
Pad out some changes here so we force multiple hunks in a diff.
329+
Pad out some changes here so we force multiple hunks in a diff.
330+
Pad out some changes here so we force multiple hunks in a diff.
331+
Pad out some changes here so we force multiple hunks in a diff.
332+
-->
333+
334+
## Unreleased
335+
336+
- Pad out some changes here so we force multiple hunks in a diff.
337+
- Pad out some changes here so we force multiple hunks in a diff.
338+
- Pad out some changes here so we force multiple hunks in a diff.
339+
- Pad out some changes here so we force multiple hunks in a diff.
340+
- Pad out some changes here so we force multiple hunks in a diff.
341+
- Pad out some changes here so we force multiple hunks in a diff.
342+
- Pad out some changes here so we force multiple hunks in a diff.
343+
- This change should be accepted.
344+
345+
## Recently released
346+
347+
- Pad out some changes here so we force multiple hunks in a diff.
348+
- Pad out some changes here so we force multiple hunks in a diff.
349+
- Pad out some changes here so we force multiple hunks in a diff.
350+
- Pad out some changes here so we force multiple hunks in a diff.
351+
- Pad out some changes here so we force multiple hunks in a diff.
352+
- Pad out some changes here so we force multiple hunks in a diff.
353+
- This change was added after release, reject me!
354+
355+
## An older release
356+
357+
- Yada yada.
358+
",
359+
"\
360+
--- ../CHANGELOG.md
361+
+++ ../CHANGELOG.md
362+
@@ -1,4 +1,5 @@
363+
<!--
364+
+ This change should be accepted.
365+
Pad out some changes here so we force multiple hunks in a diff.
366+
Pad out some changes here so we force multiple hunks in a diff.
367+
Pad out some changes here so we force multiple hunks in a diff.
368+
@@ -17,6 +18,7 @@
369+
- Pad out some changes here so we force multiple hunks in a diff.
370+
- Pad out some changes here so we force multiple hunks in a diff.
371+
- Pad out some changes here so we force multiple hunks in a diff.
372+
+- This change should be accepted.
373+
\u{0020}
374+
## Recently released
375+
\u{0020}
376+
@@ -26,6 +28,7 @@
377+
- Pad out some changes here so we force multiple hunks in a diff.
378+
- Pad out some changes here so we force multiple hunks in a diff.
379+
- Pad out some changes here so we force multiple hunks in a diff.
380+
+- This change was added after release, reject me!
381+
\u{0020}
382+
## An older release
383+
\u{0020}
384+
",
385+
[
386+
"\
387+
@@ -26,6 +28,7 @@
388+
- Pad out some changes here so we force multiple hunks in a diff.
389+
- Pad out some changes here so we force multiple hunks in a diff.
390+
- Pad out some changes here so we force multiple hunks in a diff.
391+
+- This change was added after release, reject me!
392+
\u{0020}
393+
## An older release
394+
\u{0020}
395+
",
396+
],
397+
}
398+
}
399+
}

0 commit comments

Comments
 (0)