majordomus_cli/release/commits.rs
1//! Conventional commits, read from git and parsed.
2//!
3//! The parse is deliberately small and total: a subject that does not match the convention
4//! is [`ChangeKind::Other`](crate::release::ChangeKind::Other) rather than an error, because
5//! the changelog's job is to say what
6//! happened and a commit nobody spelled conventionally still happened.
7//!
8//! ```text
9//! feat(commands): one canonical command graph
10//! ^^^^ ^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
11//! kind scope subject
12//!
13//! feat(api)!: the route moved `!` marks it breaking
14//! BREAKING CHANGE: <why> so does this trailer, in the body
15//! ```
16
17use std::path::Path;
18use std::process::Command;
19
20use super::model::{Change, Reference};
21use crate::model::Object;
22
23/// The separator between a commit's fields, and between commits. Chosen because git will
24/// not produce it and a commit body may contain any newline arrangement it likes.
25const FIELD: &str = "\u{1}";
26const RECORD: &str = "\u{2}";
27
28/// How many hex digits of a commit's name the changelog carries.
29///
30/// A constant this repository chooses, rather than git's `%h`. Git abbreviates to whatever
31/// is unambiguous in the object database in front of it, so `%h` is a function of how many
32/// objects the clone happens to hold: the same commit reads `933dba91d` in a working
33/// checkout that has fetched every branch and `933dba91` in the fresh one CI makes. The
34/// changelog is a committed artifact, so that turned the width of somebody's object store
35/// into part of a generated file — `generate --check` then reported the artifact stale in
36/// every clone but the one the generator last ran in, with no canonical source changed
37/// anywhere (`project.derived-files-regenerated`). Nine digits is what this history already
38/// carries; the abbreviation is now the repository's decision and reads the same everywhere.
39const NAME_DIGITS: usize = 9;
40
41/// A commit's name, as the changelog carries it: the full name git gave, cut to
42/// [`NAME_DIGITS`]. The cut is here and not in git's format string, because git's own
43/// abbreviation depends on the clone rather than on the commit.
44fn short(name: &str) -> &str {
45 &name[..NAME_DIGITS.min(name.len())]
46}
47
48/// Every commit in `range`, newest first, parsed.
49///
50/// `range` is anything `git log` accepts — `v0.3.1..HEAD`, a bare `HEAD`, two shas. An
51/// unknown ref is not an error here: it yields no commits and the caller reports it, which
52/// is what lets a changelog render in a shallow clone that has no tags.
53pub fn in_range(root: &Path, range: &str, objects: &[Object]) -> Vec<Change> {
54 let out = Command::new("git")
55 .arg("-C")
56 .arg(root)
57 .args([
58 "log",
59 "--no-merges",
60 &format!("--format=%H{FIELD}%s{FIELD}%b{RECORD}"),
61 range,
62 ])
63 .output();
64 let Ok(out) = out else { return Vec::new() };
65 if !out.status.success() {
66 return Vec::new();
67 }
68 String::from_utf8_lossy(&out.stdout)
69 .split(RECORD)
70 .filter_map(|record| {
71 let mut parts = record.trim_start_matches('\n').split(FIELD);
72 let commit = parts.next()?.trim();
73 let subject = parts.next()?;
74 let body = parts.next().unwrap_or_default();
75 if commit.is_empty() {
76 return None;
77 }
78 let mut change = parse(short(commit), subject, body);
79 change.references = references(&format!("{subject} {body}"), objects);
80 Some(change)
81 })
82 .collect()
83}
84
85/// Every word in `text` shaped like a record id of this repository: `I` and four digits,
86/// `M` and three.
87///
88/// The shape alone, with nothing resolved. Both halves of the question read it: the
89/// changelog asks which candidates the layer *holds*, and the commit validator asks which
90/// it does *not* — and a repository that scanned for one direction and not the other is how
91/// a commit came to name a record nobody had, with nothing to say so. One scan, two callers,
92/// no way for the two to disagree about what counts as an id.
93///
94/// ```
95/// use majordomus_cli::release::commits::record_candidates;
96/// assert_eq!(record_candidates("done under I1305 for M000"), vec!["I1305", "M000"]);
97/// // a word that merely starts with the letter is never a candidate
98/// assert!(record_candidates("Interesting M1 and I12345 are not ids").is_empty());
99/// // and each is offered once, in the order it was written
100/// assert_eq!(record_candidates("I1305 again I1305"), vec!["I1305"]);
101/// ```
102pub fn record_candidates(text: &str) -> Vec<String> {
103 let mut words: Vec<String> = Vec::new();
104 let mut word = String::new();
105 for ch in text.chars() {
106 if ch.is_ascii_alphanumeric() {
107 word.push(ch);
108 } else if !word.is_empty() {
109 words.push(std::mem::take(&mut word));
110 }
111 }
112 if !word.is_empty() {
113 words.push(word);
114 }
115 let mut out: Vec<String> = Vec::new();
116 for c in words {
117 let looks_like = (c.starts_with('I')
118 && c.len() == 5
119 && c[1..].chars().all(|d| d.is_ascii_digit()))
120 || (c.starts_with('M') && c.len() == 4 && c[1..].chars().all(|d| d.is_ascii_digit()));
121 if looks_like && !out.contains(&c) {
122 out.push(c);
123 }
124 }
125 out
126}
127
128/// The records a commit's own text names, resolved against what the layer holds.
129///
130/// The reference is inferred from the text — `I1305`, `M000` — and then *looked up*. An id
131/// that matches the shape but names nothing is dropped, so this can never produce a link to
132/// a record that does not exist. The scan is over the subject and the body together, because
133/// a commit that explains itself in its body is the one most worth linking.
134///
135/// ```
136/// use majordomus_cli::release::commits::references;
137/// // the shape alone is not a reference: against a layer that holds nothing, nothing resolves
138/// assert!(references("fix(link): resolves I4242 and mentions M000", &[]).is_empty());
139/// // and a word that merely starts with the letter is never a candidate
140/// assert!(references("Interesting: M1 and I12345 are not ids", &[]).is_empty());
141/// ```
142pub fn references(text: &str, objects: &[Object]) -> Vec<Reference> {
143 let mut out: Vec<Reference> = Vec::new();
144 let mut seen: Vec<String> = Vec::new();
145 for c in record_candidates(text) {
146 if seen.contains(&c) {
147 continue;
148 }
149 let kind = if c.starts_with('I') {
150 "issue"
151 } else {
152 "milestone"
153 };
154 if let Some(o) = objects.iter().find(|o| o.kind == kind && o.identity == c) {
155 seen.push(c.clone());
156 out.push(Reference {
157 kind: kind.to_string(),
158 title: o
159 .title
160 .clone()
161 .or_else(|| {
162 o.metadata
163 .get("title")
164 .and_then(|v| v.as_str())
165 .map(String::from)
166 })
167 .unwrap_or_else(|| c.clone()),
168 route: Some(format!("/plan/{}/", c.to_lowercase())),
169 id: c,
170 });
171 }
172 }
173 out
174}
175
176/// One commit, as a change.
177///
178/// The grammar is `commit::Header`'s, not a second one: the changelog and the
179/// commit validator read the same parse and disagree only about what to *do* with a header
180/// that is not conventional. Here that is not a failure — a commit nobody spelled
181/// conventionally still happened, and a changelog that dropped it would lie about what the
182/// release contains.
183pub fn parse(commit: &str, subject: &str, body: &str) -> Change {
184 let breaking_trailer = body
185 .lines()
186 .any(|l| l.starts_with("BREAKING CHANGE:") || l.starts_with("BREAKING-CHANGE:"));
187 let header = crate::commit::CommitHeader::parse(subject);
188 Change {
189 kind: header.kind,
190 scope: header.scope,
191 subject: header.subject,
192 breaking: header.breaking || breaking_trailer,
193 commit: commit.to_string(),
194 url: None,
195 references: Vec::new(),
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::release::ChangeKind;
203
204 fn object(kind: &str, id: &str, title: &str) -> Object {
205 // Only the three fields the resolution reads carry meaning; the rest is what an
206 // object must have to exist, so the fixture states the question and nothing else.
207 Object {
208 kind: kind.into(),
209 identity: id.into(),
210 uri: format!("majordomus://{kind}/{id}"),
211 title: Some(title.into()),
212 description: None,
213 metadata: serde_json::Value::Null,
214 body: String::new(),
215 content: String::new(),
216 media_type: "text/yaml",
217 provenance: crate::model::Provenance {
218 path: format!(".ai/repo/project/{kind}s/{id}.yaml"),
219 directory: format!(".ai/repo/project/{kind}s"),
220 source_class: kind.into(),
221 section: None,
222 bytes: 0,
223 member: None,
224 },
225 }
226 }
227
228 #[test]
229 fn a_reference_is_carried_only_when_the_layer_holds_what_it_names() {
230 let layer = vec![
231 object("issue", "I1305", "Traceability"),
232 object("milestone", "M000", "Foundations"),
233 ];
234 // both named and both held
235 let refs = references("feat(x): done under I1305 for M000", &layer);
236 assert_eq!(refs.len(), 2);
237 assert_eq!(refs[0].id, "I1305");
238 assert_eq!(refs[0].kind, "issue");
239 assert_eq!(refs[1].kind, "milestone");
240
241 // the shape matches and the layer holds nothing: no link, because a link to a record
242 // that does not exist cannot be told from a good one until it is followed
243 assert!(references("feat(x): done under I9999", &layer).is_empty());
244
245 // named twice, carried once
246 assert_eq!(references("I1305 and again I1305", &layer).len(), 1);
247
248 // a word that merely starts with the letter is not an id
249 assert!(references("Interesting M1 Improvements", &layer).is_empty());
250 }
251
252 #[test]
253 fn a_commit_name_is_cut_to_a_width_this_repository_chooses() {
254 // The changelog is committed, so the width cannot come from git: `%h` is short in a
255 // fresh clone and longer in one that has fetched everything, and the artifact would
256 // then be stale in every checkout but the last one the generator ran in.
257 let full = "933dba91d1f7e0a54a1a2b3c4d5e6f7a8b9c0d1e";
258 assert_eq!(short(full), "933dba91d");
259 assert_eq!(short(full).len(), NAME_DIGITS);
260 // a name already shorter than the width is carried whole rather than panicking
261 assert_eq!(short("abc"), "abc");
262 assert_eq!(short(""), "");
263 }
264
265 #[test]
266 fn a_conventional_subject_splits_into_its_parts() {
267 let c = parse("abc1234", "feat(commands): one canonical command graph", "");
268 assert_eq!(c.kind, ChangeKind::Feat);
269 assert_eq!(c.scope.as_deref(), Some("commands"));
270 assert_eq!(c.subject, "one canonical command graph");
271 assert!(!c.breaking);
272 }
273
274 #[test]
275 fn a_scope_is_optional() {
276 let c = parse("abc1234", "docs: the document the env recipes cite", "");
277 assert_eq!(c.kind, ChangeKind::Docs);
278 assert_eq!(c.scope, None);
279 assert_eq!(c.subject, "the document the env recipes cite");
280 }
281
282 #[test]
283 fn both_spellings_of_breaking_are_read() {
284 assert!(parse("a", "feat(api)!: the route moved", "").breaking);
285 assert!(
286 parse(
287 "a",
288 "feat(api): the route moved",
289 "BREAKING CHANGE: it moved"
290 )
291 .breaking
292 );
293 assert!(
294 parse(
295 "a",
296 "feat(api): the route moved",
297 "BREAKING-CHANGE: it moved"
298 )
299 .breaking
300 );
301 }
302
303 #[test]
304 fn a_subject_that_is_not_conventional_is_kept_whole() {
305 // A changelog that drops what it cannot classify lies by omission, so the entry
306 // survives as `Other` with its subject intact rather than being split at a colon
307 // that was part of the sentence.
308 let c = parse("abc1234", "Merge pull request #117 from korczis/int", "");
309 assert_eq!(c.kind, ChangeKind::Other);
310 assert_eq!(c.subject, "Merge pull request #117 from korczis/int");
311 let c = parse("abc1234", "Revert: the thing that broke", "");
312 assert_eq!(c.kind, ChangeKind::Other);
313 assert_eq!(c.subject, "Revert: the thing that broke");
314 }
315
316 #[test]
317 fn an_unknown_lowercase_type_is_other_and_keeps_its_word() {
318 let c = parse("abc1234", "wip(site): halfway through", "");
319 assert_eq!(c.kind, ChangeKind::Other);
320 assert_eq!(c.scope.as_deref(), Some("site"));
321 // the word is what the author said about the change; hiding it says less
322 assert_eq!(c.subject, "wip: halfway through");
323 }
324}