Skip to main content

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}