Skip to main content

majordomus_cli/release/
changelog.rs

1//! Composing the changelog out of what the repository already states.
2//!
3//! Nothing here is authored. A section per release record, newest first, with an unreleased
4//! section leading when there is work after the last one:
5//!
6//! ```text
7//!   release record  ──►  version, tag, date, commit, artifacts
8//!   adr objects     ──►  the decisions dated inside this release's window
9//!   git log A..B    ──►  the changes, as conventional commits
10//! ```
11//!
12//! The window of a release is `(previous release's date, this release's date]` for the
13//! decisions and `previous..this` for the commits. Both are the same interval said in the
14//! two vocabularies the two sources have — an ADR carries a date and no commit, and a commit
15//! carries no date the layer indexes.
16
17use std::collections::{HashMap, HashSet};
18use std::path::Path;
19use std::sync::atomic::{AtomicUsize, Ordering};
20
21use serde_json::Value;
22
23use crate::model::Object;
24
25use super::commits;
26use super::model::{
27    Artifact, Change, ChangeGroup, Changelog, Decision, ReleaseSection, CHANGELOG_SCHEMA,
28};
29use super::version;
30
31/// The repository the links point into, as the crate manifest declares it.
32///
33/// Never a literal: `about::REPOSITORY` is `CARGO_PKG_REPOSITORY`, so a fork or a move
34/// carries every link with it and nothing here has to be told. A URL that is not a forge
35/// this understands yields no links at all rather than a guess — a wrong link is worse than
36/// no link, because a reader cannot tell it is wrong until they follow it.
37fn forge() -> Option<&'static str> {
38    let url = crate::about::REPOSITORY.trim_end_matches('/');
39    if url.starts_with("https://github.com/") && url.split('/').count() == 5 {
40        Some(url)
41    } else {
42        None
43    }
44}
45
46/// The kind the layer gives a published release.
47pub const RELEASE_KIND: &str = "release-record";
48/// The kind the layer gives a decision.
49pub const ADR_KIND: &str = "adr";
50
51/// One release, as read out of its record, before the changes are joined to it.
52struct Record {
53    version: String,
54    tag: String,
55    date: String,
56    commit: String,
57    notes: Option<String>,
58    artifacts: Vec<Artifact>,
59}
60
61/// Compose the changelog, unreleased section included.
62///
63/// `objects` is the index's own view of the layer; `root` is the repository git reads. A
64/// repository with no records yields one unreleased section, which is the right answer for a
65/// project that has never published rather than an error.
66///
67/// This is what a live reader wants — the API, MCP and the Cockpit answer at request time
68/// and HEAD is whatever it is then. It is not what a committed artifact may hold: see
69/// [`compose_published`].
70pub fn compose(root: &Path, objects: &[Object]) -> Changelog {
71    compose_with(root, objects, true)
72}
73
74/// Compose the changelog of what has been published, and nothing after it.
75///
76/// The one form a committed artifact may take. The unreleased section is
77/// `<last release>..HEAD`, and a file inside a commit cannot describe the commit it is in:
78/// every commit made the committed changelog stale by construction, the pre-commit gate
79/// refused until a full derive had run, and every session paid several minutes per commit
80/// or worked around the gate. A document that depends only on the release records and the
81/// history up to the newest of them is the same bytes at every commit that changes neither,
82/// which is what "current" has to mean for a file under `docs/generated/`.
83///
84/// A repository with no records yields no section at all here — there is nothing published
85/// to describe — where [`compose`] yields the unreleased one.
86pub fn compose_published(root: &Path, objects: &[Object]) -> Changelog {
87    compose_with(root, objects, false)
88}
89
90/// The release records among `objects`, newest first by the date each carries: the order a
91/// changelog is read in, and so the order in which "the previous release" is the next one.
92fn release_records(objects: &[Object], diagnostics: &mut Vec<String>) -> Vec<Record> {
93    let mut records: Vec<Record> = objects
94        .iter()
95        .filter(|o| o.kind == RELEASE_KIND)
96        .filter_map(|o| record_of(&o.metadata, diagnostics))
97        .collect();
98    records.sort_by(|a, b| b.date.cmp(&a.date));
99    records
100}
101
102fn compose_with(root: &Path, objects: &[Object], unreleased: bool) -> Changelog {
103    let mut diagnostics = Vec::new();
104
105    let records = release_records(objects, &mut diagnostics);
106
107    let decisions = decisions_of(root, objects);
108    // Which commits each release's tree holds, read once for every release and every
109    // decision asked about it (see `Ancestry`).
110    let mut ancestry = Ancestry::new(root, records.iter().map(|r| r.commit.as_str()));
111
112    let mut sections = Vec::new();
113
114    // The unreleased section: everything after the newest release, when there is any, and
115    // only for a reader that asked for it — a committed document never does (see
116    // `compose_published`).
117    let newest = records.first();
118    let unreleased_range = match newest {
119        Some(r) => format!("{}..HEAD", r.commit),
120        None => "HEAD".to_string(),
121    };
122    let unreleased_changes = if unreleased {
123        commits::in_range(root, &unreleased_range, objects)
124    } else {
125        Vec::new()
126    };
127    if !unreleased || (newest.is_some() && unreleased_changes.is_empty()) {
128        // nothing since the last release, or nobody asked; no section rather than an empty one
129    } else {
130        sections.push(ReleaseSection {
131            version: "unreleased".into(),
132            tag: None,
133            date: None,
134            commit: None,
135            unreleased: true,
136            // Nothing is published yet, so there are no notes; the range is what has landed
137            // since the last release, which is exactly what this section lists.
138            notes_url: None,
139            compare_url: forge()
140                .zip(newest)
141                .map(|(base, r)| format!("{base}/compare/{}...master", r.tag)),
142            tree_url: forge().map(|base| format!("{base}/tree/master")),
143            decisions: decisions_after(
144                &mut ancestry,
145                &decisions,
146                newest.map(|r| r.commit.as_str()),
147            ),
148            groups: grouped(unreleased_changes),
149            artifacts: Vec::new(),
150        });
151    }
152
153    for (i, r) in records.iter().enumerate() {
154        let previous = records.get(i + 1);
155        let range = match previous {
156            Some(p) => format!("{}..{}", p.commit, r.commit),
157            // The first release: everything up to it. A clone without that history answers
158            // nothing, which the diagnostics below make visible rather than silent.
159            None => r.commit.clone(),
160        };
161        let changes = commits::in_range(root, &range, objects);
162        if changes.is_empty() {
163            diagnostics.push(format!(
164                "no commit was readable for {} ({}); the clone may not carry that history",
165                r.version, range
166            ));
167        }
168        sections.push(ReleaseSection {
169            version: r.version.clone(),
170            tag: Some(r.tag.clone()),
171            date: Some(r.date.clone()),
172            commit: Some(r.commit.clone()),
173            unreleased: false,
174            // The record names its own notes; the range and the tree are the forge's own
175            // addresses for facts the record already carries, so neither is authored.
176            notes_url: r
177                .notes
178                .clone()
179                .or_else(|| forge().map(|base| format!("{base}/releases/tag/{}", r.tag))),
180            compare_url: forge().map(|base| match previous {
181                Some(p) => format!("{base}/compare/{}...{}", p.tag, r.tag),
182                None => format!("{base}/commits/{}", r.tag),
183            }),
184            tree_url: forge().map(|base| format!("{base}/tree/{}", r.tag)),
185            decisions: decisions_between(
186                &mut ancestry,
187                &decisions,
188                previous.map(|p| p.commit.as_str()),
189                &r.commit,
190            ),
191            groups: grouped(changes),
192            artifacts: r.artifacts.clone(),
193        });
194    }
195
196    Changelog {
197        schema: CHANGELOG_SCHEMA.into(),
198        current: version::declared(root).unwrap_or_else(|| "unknown".into()),
199        sections,
200        diagnostics,
201        // Filled by whoever answers with it: the composer does not know which surface asked.
202        produced_by: None,
203    }
204}
205
206/// The changes of a section, grouped by kind and ordered by the rank each kind carries.
207///
208/// Done here rather than in each renderer: the Markdown, the site and any other reader get
209/// the same order because they are given it, not because they each reimplemented it.
210///
211/// One group per kind, exactly. The rank is a *presentation* order and two kinds may share
212/// one — `Ci` and `Build` are both rank 6, `Chore` and `Style` both rank 7 — so a sort keyed
213/// on the rank alone leaves equal-ranked kinds interleaved in the order their commits
214/// arrived, and `dedup`, which removes only *adjacent* repeats, then leaves the same kind
215/// standing several times. That is what put six `chore` groups and five `style` ones on the
216/// changelog page, each rendering all of its changes again under an id the page had already
217/// used, and what repeated their headings in the Markdown. The key is therefore total: the
218/// rank first, then the kind's own order, so equal ranks are adjacent and one survives.
219fn grouped(mut changes: Vec<Change>) -> Vec<ChangeGroup> {
220    if let Some(base) = forge() {
221        for c in &mut changes {
222            c.url = Some(format!("{base}/commit/{}", c.commit));
223        }
224    }
225    let mut kinds: Vec<_> = changes.iter().map(|c| c.kind).collect();
226    kinds.sort_by_key(|k| (k.rank(), *k));
227    kinds.dedup();
228    kinds
229        .into_iter()
230        .map(|kind| ChangeGroup {
231            kind,
232            heading: kind.heading().to_string(),
233            rank: kind.rank(),
234            changes: changes.iter().filter(|c| c.kind == kind).cloned().collect(),
235        })
236        .collect()
237}
238
239/// One release record, from its metadata.
240fn record_of(metadata: &Value, diagnostics: &mut Vec<String>) -> Option<Record> {
241    let version = metadata.get("version")?.as_str()?.to_string();
242    let tag = metadata
243        .get("tag")
244        .and_then(|v| v.as_str())
245        .unwrap_or(&version)
246        .to_string();
247    let date = metadata
248        .get("published_at")
249        .and_then(|v| v.as_str())
250        .unwrap_or_default()
251        .to_string();
252    let Some(commit) = metadata.get("commit").and_then(|v| v.as_str()) else {
253        diagnostics.push(format!(
254            "the record for {version} names no commit, so its changes cannot be read"
255        ));
256        return None;
257    };
258    let artifacts = metadata
259        .get("artifacts")
260        .and_then(|v| v.as_array())
261        .map(|a| {
262            a.iter()
263                .filter_map(|v| {
264                    Some(Artifact {
265                        target: v.get("target")?.as_str()?.to_string(),
266                        name: v.get("name")?.as_str()?.to_string(),
267                        sha256: v
268                            .get("sha256")
269                            .and_then(|s| s.as_str())
270                            .unwrap_or_default()
271                            .to_string(),
272                    })
273                })
274                .collect()
275        })
276        .unwrap_or_default();
277    Some(Record {
278        version,
279        tag,
280        date,
281        commit: commit.to_string(),
282        notes: metadata
283            .get("notes_url")
284            .and_then(|v| v.as_str())
285            .map(|s| s.to_string()),
286        artifacts,
287    })
288}
289
290/// The releases whose window this repository's object database cannot read, by version.
291///
292/// A section of the published changelog is the history between two release commits, and a
293/// repository made from a tree rather than from that history — a fixture exported with
294/// `git archive`, a shallow clone — holds the tree and not the commits. Composed there, every
295/// such section came out with no changes: a different document rather than a newer one, which
296/// `generate --check` reported as stale and `generate` wrote over the committed changelog
297/// (I1409). A release is unreadable when its commit or the previous release's is not an object
298/// here, or when the repository is shallow, whose history ends at a graft whatever it holds.
299/// Asked of git twice, whatever the number of releases.
300pub(crate) fn unreadable_releases(root: &Path, objects: &[Object]) -> Vec<String> {
301    let mut ignored = Vec::new();
302    // the order `compose_with` reads them in, so "the previous release" is the same one
303    let records = release_records(objects, &mut ignored);
304    if records.is_empty() {
305        return Vec::new();
306    }
307    let shallow = std::process::Command::new("git")
308        .arg("-C")
309        .arg(root)
310        .args(["rev-parse", "--is-shallow-repository"])
311        .output()
312        .is_ok_and(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "true");
313    let commits: Vec<&str> = records.iter().map(|r| r.commit.as_str()).collect();
314    let held = held_commits(root, &commits);
315    records
316        .iter()
317        .enumerate()
318        .filter(|(i, r)| {
319            shallow
320                || !held.contains(r.commit.as_str())
321                || records
322                    .get(i + 1)
323                    .is_some_and(|p| !held.contains(p.commit.as_str()))
324        })
325        .map(|(_, r)| r.version.clone())
326        .collect()
327}
328
329/// Which of `commits` this repository holds as commits, asked in one `git cat-file`. Nothing
330/// when git cannot answer, which is the same as holding none of them.
331fn held_commits<'c>(root: &Path, commits: &[&'c str]) -> HashSet<&'c str> {
332    use std::io::Write;
333    use std::process::{Command, Stdio};
334    let Ok(mut child) = Command::new("git")
335        .arg("-C")
336        .arg(root)
337        .args(["cat-file", "--batch-check=%(objecttype)"])
338        .stdin(Stdio::piped())
339        .stdout(Stdio::piped())
340        .stderr(Stdio::null())
341        .spawn()
342    else {
343        return HashSet::new();
344    };
345    let names: String = commits
346        .iter()
347        .map(|c| format!("{c}^{{commit}}\n"))
348        .collect();
349    // a write that fails leaves fewer answers than questions, which the zip reads as absent
350    let _ = child
351        .stdin
352        .take()
353        .map(|mut stdin| stdin.write_all(names.as_bytes()));
354    let out = match child.wait_with_output() {
355        Ok(out) if out.status.success() => out,
356        _ => return HashSet::new(),
357    };
358    // one answer per question, in order: `commit`, or `<name> missing`
359    commits
360        .iter()
361        .zip(String::from_utf8_lossy(&out.stdout).lines())
362        .filter(|(_, answer)| *answer == "commit")
363        .map(|(c, _)| *c)
364        .collect()
365}
366
367/// Keep, for each release in `unreadable`, what the committed changelog already says it
368/// changed, and return the versions kept.
369///
370/// What a release changed is read from history (its commits and the decisions its tree first
371/// holds), and where the history is absent the committed document is the projection that was
372/// made where it was present: keeping its groups and decisions is the one answer that is not
373/// invented. Everything a record carries — version, tag, date, notes, artifacts — is still
374/// composed from the record, so a changed record is still stale; and a section is kept only
375/// when the committed one names the same commit, so a new release, or a record whose commit
376/// changed, is composed and judged rather than passed on an older document.
377pub(crate) fn keep_committed(
378    log: &mut Changelog,
379    committed: &Changelog,
380    unreadable: &[String],
381) -> Vec<String> {
382    let mut kept = Vec::new();
383    for section in log
384        .sections
385        .iter_mut()
386        .filter(|s| !s.unreleased && unreadable.contains(&s.version))
387    {
388        let Some(was) = committed
389            .sections
390            .iter()
391            .find(|c| !c.unreleased && c.version == section.version && c.commit == section.commit)
392        else {
393            continue;
394        };
395        section.groups = was.groups.clone();
396        section.decisions = was.decisions.clone();
397        kept.push(section.version.clone());
398    }
399    // the composition said it read nothing for these; what it now carries was read elsewhere
400    log.diagnostics.retain(|d| {
401        !kept
402            .iter()
403            .any(|v| d.starts_with(&format!("no commit was readable for {v} (")))
404    });
405    kept
406}
407
408/// When the file that states a decision was added, as git knows it.
409///
410/// The date in an ADR's front matter is when the decision was *made*, which is not the same
411/// question as which release carried it — and using it put two decisions written on 2026-09-09
412/// into a release published on 2026-09-08, because both ends were compared as calendar days.
413/// The commits half of this module already asks git; the decisions half asks git now too.
414fn added_at(root: &Path, path: &str) -> Option<String> {
415    let out = std::process::Command::new("git")
416        .arg("-C")
417        .arg(root)
418        .args([
419            "log",
420            "--diff-filter=A",
421            "--format=%H",
422            "--max-count=1",
423            "--",
424            path,
425        ])
426        .output()
427        .ok()?;
428    if !out.status.success() {
429        return None;
430    }
431    let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
432    if sha.is_empty() {
433        None
434    } else {
435        Some(sha)
436    }
437}
438
439/// When each of `paths` was added, as [`added_at`] answers it, in the order given.
440///
441/// One `git log` per decision walks the whole history each time, and asked one after the
442/// other they were 43% of `generate --check` — some ten of the twenty seconds the pre-commit
443/// gate is bounded by (case 56). A single walk over every path is not the same question:
444/// git simplifies history per pathspec, so a merge that leaves one decision untouched but
445/// changes another prunes a side branch for the first and follows it for the second, and on
446/// this repository's history three of 95 answers moved. The question is unchanged here; the
447/// processes run side by side instead, and they mostly wait on git rather than on this
448/// process.
449fn added_at_each(root: &Path, paths: &[&str]) -> Vec<Option<String>> {
450    // as many at once as the machine runs, and no more: it runs other sessions' work too
451    let threads = std::thread::available_parallelism()
452        .map(|n| n.get())
453        .unwrap_or(4)
454        .clamp(1, 16)
455        .min(paths.len());
456    if threads < 2 {
457        return paths.iter().map(|p| added_at(root, p)).collect();
458    }
459    let next = AtomicUsize::new(0);
460    let answered: Vec<(usize, Option<String>)> = std::thread::scope(|scope| {
461        let workers: Vec<_> = (0..threads)
462            .map(|_| {
463                scope.spawn(|| {
464                    let mut mine = Vec::new();
465                    loop {
466                        let i = next.fetch_add(1, Ordering::Relaxed);
467                        let Some(path) = paths.get(i) else {
468                            return mine;
469                        };
470                        mine.push((i, added_at(root, path)));
471                    }
472                })
473            })
474            .collect();
475        workers
476            .into_iter()
477            // `added_at` has no panic site, so a worker always returns: no panic to carry on
478            .flat_map(|w| w.join().expect("added_at does not panic"))
479            .collect()
480    });
481    let mut out = vec![None; paths.len()];
482    for (i, added) in answered {
483        out[i] = added;
484    }
485    out
486}
487
488/// Which commits a tree already holds, asked of git once for every tree.
489///
490/// The question the two window functions ask is "was the commit that added this decision
491/// already in that release's tree?", for every decision against every release, twice for
492/// most. Asked as `git merge-base --is-ancestor` it was one process per pair: some six hundred
493/// at 62 decisions and six releases, about 6.5 of the 13.7 seconds an unoptimised
494/// `generate --check` spent, and so of the pre-commit gate that runs it on every commit, and
495/// it grows with the product of the two. Listing each tree's history once (`git rev-list
496/// <tree>`) made every question a lookup, but still cost one process and one full walk per
497/// release: at 21 releases that was 9 of the 37 seconds `generate --check` spent on
498/// 2026-10-15 under load, and `scripts/derive` pays it twice.
499///
500/// So the trees the caller names are listed together, once, with their parents
501/// (`git rev-list --parents <trees>`), and a tree's history is what it reaches over those
502/// edges: the same answer, since the listing holds every commit any named tree reaches and
503/// every parent of each, so the walk from a tree visits exactly what `git rev-list <tree>`
504/// lists (the tree's own commit included, as `--is-ancestor` includes it). A tree the
505/// listing does not name — not a full commit name, or one git cannot resolve, which fails
506/// the whole listing — is listed alone as before, and a name git cannot resolve holds
507/// nothing, as `--is-ancestor` refuses it.
508struct Ancestry<'a> {
509    root: &'a Path,
510    /// The trees asked about, listed together on the first question.
511    trees: Vec<String>,
512    graph: Option<Graph>,
513    held: HashMap<String, Held>,
514}
515
516/// What one tree holds: its reach over the shared listing, or its own listing.
517enum Held {
518    Reached(Vec<bool>),
519    Listed(HashSet<String>),
520}
521
522impl<'a> Ancestry<'a> {
523    fn new<'t>(root: &'a Path, trees: impl IntoIterator<Item = &'t str>) -> Self {
524        Ancestry {
525            root,
526            trees: trees.into_iter().map(str::to_string).collect(),
527            graph: None,
528            held: HashMap::new(),
529        }
530    }
531
532    /// Whether `commit` — a full commit name, as [`added_at`] gives it — was already in the
533    /// tree `of`: whether it is `of` or one of its ancestors.
534    fn holds(&mut self, of: &str, commit: &str) -> bool {
535        let Ancestry {
536            root,
537            trees,
538            graph,
539            held,
540        } = self;
541        let graph = graph.get_or_insert_with(|| Graph::list(root, trees));
542        let held = held
543            .entry(of.to_string())
544            .or_insert_with(|| match graph.index.get(of) {
545                Some(&tree) => Held::Reached(graph.reach(tree)),
546                None => Held::Listed(history(root, of)),
547            });
548        match held {
549            Held::Reached(reached) => graph.index.get(commit).is_some_and(|&c| reached[c]),
550            Held::Listed(listed) => listed.contains(commit),
551        }
552    }
553}
554
555/// Every commit some trees reach, each with its parents, from one `git rev-list --parents`.
556#[derive(Default)]
557struct Graph {
558    index: HashMap<String, usize>,
559    parents: Vec<Vec<usize>>,
560}
561
562impl Graph {
563    /// The trees named by a full commit name, listed together; empty when there are none or
564    /// git cannot list them all, and then every tree is listed alone.
565    fn list(root: &Path, trees: &[String]) -> Graph {
566        // each distinct name once, in byte order: a set, not a sort
567        let named: Vec<&str> = trees
568            .iter()
569            .map(String::as_str)
570            .filter(|t| is_full_name(t))
571            .collect::<std::collections::BTreeSet<&str>>()
572            .into_iter()
573            .collect();
574        if named.is_empty() {
575            return Graph::default();
576        }
577        let Ok(out) = std::process::Command::new("git")
578            .arg("-C")
579            .arg(root)
580            .args(["rev-list", "--parents"])
581            .args(&named)
582            .arg("--")
583            .output()
584        else {
585            return Graph::default();
586        };
587        if !out.status.success() {
588            return Graph::default();
589        }
590        Graph::from_listing(&String::from_utf8_lossy(&out.stdout))
591    }
592
593    /// The graph a `git rev-list --parents` listing describes: one row per commit, its full
594    /// name and then its parents'. Empty when a row names a parent no row lists.
595    fn from_listing(text: &str) -> Graph {
596        let rows: Vec<Vec<&str>> = text
597            .lines()
598            .map(|l| l.split_whitespace().collect::<Vec<_>>())
599            .filter(|r| !r.is_empty())
600            .collect();
601        let index: HashMap<String, usize> = rows
602            .iter()
603            .enumerate()
604            .map(|(i, r)| (r[0].to_string(), i))
605            .collect();
606        let mut parents = Vec::with_capacity(rows.len());
607        for row in &rows {
608            let mut of = Vec::with_capacity(row.len() - 1);
609            for p in &row[1..] {
610                // Every parent of a listed commit is itself listed; one that is not means the
611                // listing is not the whole history, and a partial graph would answer "no" where
612                // git answers "yes". Each tree is then listed alone.
613                let Some(&i) = index.get(*p) else {
614                    return Graph::default();
615                };
616                of.push(i);
617            }
618            parents.push(of);
619        }
620        Graph { index, parents }
621    }
622
623    /// Which listed commits `tree` reaches, itself included.
624    fn reach(&self, tree: usize) -> Vec<bool> {
625        let mut reached = vec![false; self.parents.len()];
626        let mut stack = vec![tree];
627        while let Some(c) = stack.pop() {
628            if std::mem::replace(&mut reached[c], true) {
629                continue;
630            }
631            stack.extend(self.parents[c].iter().copied().filter(|&p| !reached[p]));
632        }
633        reached
634    }
635}
636
637/// Whether `name` is a full commit name as git prints one: what the listing is keyed by, so
638/// any other spelling of a tree (a tag, a branch, an abbreviation) is listed alone.
639fn is_full_name(name: &str) -> bool {
640    matches!(name.len(), 40 | 64) && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
641}
642
643/// Every commit `of` reaches, by full name; nothing when git cannot resolve it.
644fn history(root: &Path, of: &str) -> HashSet<String> {
645    let Ok(out) = std::process::Command::new("git")
646        .arg("-C")
647        .arg(root)
648        .args(["rev-list", of, "--"])
649        .output()
650    else {
651        return HashSet::new();
652    };
653    if !out.status.success() {
654        return HashSet::new();
655    }
656    String::from_utf8_lossy(&out.stdout)
657        .lines()
658        .map(str::to_string)
659        .collect()
660}
661
662/// Every decision the layer holds, newest first.
663fn decisions_of(root: &Path, objects: &[Object]) -> Vec<Decision> {
664    let adrs: Vec<(&Object, String)> = objects
665        .iter()
666        .filter(|o| o.kind == ADR_KIND)
667        .filter_map(|o| Some((o, o.metadata.get("id")?.as_str()?.to_string())))
668        .collect();
669    let paths: Vec<&str> = adrs
670        .iter()
671        .map(|(o, _)| o.provenance.path.as_str())
672        .collect();
673    let mut out: Vec<Decision> = adrs
674        .into_iter()
675        .zip(added_at_each(root, &paths))
676        .map(|((o, id), added)| {
677            Decision {
678                title: o
679                    .title
680                    .clone()
681                    .or_else(|| {
682                        o.metadata
683                            .get("title")
684                            .and_then(|v| v.as_str())
685                            .map(|s| s.to_string())
686                    })
687                    .unwrap_or_else(|| id.clone()),
688                status: o
689                    .metadata
690                    .get("status")
691                    .and_then(|v| v.as_str())
692                    .unwrap_or("unknown")
693                    .to_string(),
694                date: o
695                    .metadata
696                    .get("date")
697                    .and_then(|v| v.as_str())
698                    .unwrap_or_default()
699                    .to_string(),
700                url: forge().map(|base| format!("{base}/blob/master/{}", o.provenance.path)),
701                // Which release carried it is a question for git, not for the front matter:
702                // the date says when the decision was made, and two ADRs written the day
703                // after a release were claimed by it while this read that field instead.
704                added,
705                id,
706            }
707        })
708        .collect();
709    out.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| b.id.cmp(&a.id)));
710    out
711}
712
713/// The decisions not yet in any release: their file was added after the newest release's tree.
714fn decisions_after(
715    ancestry: &mut Ancestry<'_>,
716    decisions: &[Decision],
717    newest: Option<&str>,
718) -> Vec<Decision> {
719    decisions
720        .iter()
721        .filter(|d| match (newest, d.added.as_deref()) {
722            // added, and not already in the last release's tree
723            (Some(rel), Some(added)) => !ancestry.holds(rel, added),
724            // nothing released yet, or git could not say when it was added: an unreleased
725            // section that omits a decision is worse than one that shows an early arrival
726            _ => true,
727        })
728        .cloned()
729        .collect()
730}
731
732/// The decisions this release carried: in its tree, and not in the one before it.
733fn decisions_between(
734    ancestry: &mut Ancestry<'_>,
735    decisions: &[Decision],
736    previous: Option<&str>,
737    release: &str,
738) -> Vec<Decision> {
739    decisions
740        .iter()
741        .filter(|d| {
742            let Some(added) = d.added.as_deref() else {
743                return false;
744            };
745            ancestry.holds(release, added) && previous.is_none_or(|p| !ancestry.holds(p, added))
746        })
747        .cloned()
748        .collect()
749}
750
751/// The `YYYY-MM-DD` of an ISO-8601 date or timestamp.
752fn day(text: &str) -> &str {
753    text.split('T').next().unwrap_or(text)
754}
755
756/// The changelog as Markdown, which is what the generated document and the site render.
757pub fn render(changelog: &Changelog) -> String {
758    let mut out = String::new();
759    out.push_str("# Changelog\n\n");
760    out.push_str(
761        "Every entry below is derived: a section per release the layer records, its decisions \
762         the ADRs dated inside that release's window, its changes the conventional commits in \
763         its range, its artifacts the record's own evidence. Nothing here is written by hand, \
764         and `majordomus generate changelog --check` fails when it stops matching the tree.\n\n",
765    );
766    out.push_str(&format!("Current version: **{}**\n", changelog.current));
767
768    for section in &changelog.sections {
769        out.push('\n');
770        if section.unreleased {
771            out.push_str("## Unreleased\n\n");
772        } else {
773            let date = section.date.as_deref().unwrap_or("");
774            out.push_str(&format!(
775                "## {} — {}\n\n",
776                section.tag.as_deref().unwrap_or(&section.version),
777                day(date)
778            ));
779        }
780
781        if !section.decisions.is_empty() {
782            out.push_str("### Decisions\n\n");
783            for d in &section.decisions {
784                out.push_str(&format!(
785                    "- **{}** {} _({})_\n",
786                    d.id.to_uppercase(),
787                    d.title,
788                    d.status
789                ));
790            }
791            out.push('\n');
792        }
793
794        // The groups the document carries, in the order it carries them: this renderer no
795        // longer decides the order, it reads it, which is what lets the site agree with it.
796        for group in &section.groups {
797            out.push_str(&format!("### {}\n\n", group.heading));
798            for c in &group.changes {
799                let scope = c
800                    .scope
801                    .as_ref()
802                    .map(|s| format!("**{s}**: "))
803                    .unwrap_or_default();
804                let breaking = if c.breaking { "**BREAKING** " } else { "" };
805                out.push_str(&format!(
806                    "- {breaking}{scope}{} (`{}`)\n",
807                    c.subject, c.commit
808                ));
809            }
810            out.push('\n');
811        }
812
813        if !section.artifacts.is_empty() {
814            out.push_str("### Published\n\n");
815            for a in &section.artifacts {
816                out.push_str(&format!(
817                    "- `{}` — {} (`{}`)\n",
818                    a.target,
819                    a.name,
820                    &a.sha256[..a.sha256.len().min(12)]
821                ));
822            }
823            out.push('\n');
824        }
825    }
826
827    if !changelog.diagnostics.is_empty() {
828        out.push_str("\n## What could not be read\n\n");
829        for d in &changelog.diagnostics {
830            out.push_str(&format!("- {d}\n"));
831        }
832    }
833    out
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    use crate::release::model::ChangeKind;
840
841    fn decision(id: &str, date: &str) -> Decision {
842        Decision {
843            id: id.into(),
844            title: "t".into(),
845            status: "accepted".into(),
846            date: date.into(),
847            url: None,
848            added: None,
849        }
850    }
851
852    fn git(root: &Path, args: &[&str]) -> String {
853        let out = std::process::Command::new("git")
854            .arg("-C")
855            .arg(root)
856            .args(args)
857            .env("GIT_AUTHOR_NAME", "t")
858            .env("GIT_AUTHOR_EMAIL", "t@t")
859            .env("GIT_COMMITTER_NAME", "t")
860            .env("GIT_COMMITTER_EMAIL", "t@t")
861            .output()
862            .expect("git runs");
863        assert!(
864            out.status.success(),
865            "git {args:?}: {}",
866            String::from_utf8_lossy(&out.stderr)
867        );
868        String::from_utf8_lossy(&out.stdout).trim().to_string()
869    }
870
871    /// A repository whose decisions arrive one file per commit, so that git can say when each
872    /// was added: the fact the two window functions read. Returns the commit of each arrival,
873    /// in order.
874    fn repo_with_decisions(ids: &[&str]) -> (tempfile::TempDir, Vec<String>) {
875        let dir = tempfile::tempdir().expect("a temp dir");
876        let root = dir.path();
877        git(root, &["init", "-q"]);
878        std::fs::create_dir_all(root.join(".ai/repo/decisions")).expect("the decisions dir");
879        let mut arrivals = Vec::new();
880        for id in ids {
881            std::fs::write(
882                root.join(format!(".ai/repo/decisions/{id}.md")),
883                "decided\n",
884            )
885            .expect("a decision file");
886            git(root, &["add", "-A"]);
887            git(root, &["commit", "-q", "-m", &format!("docs(adr): {id}")]);
888            arrivals.push(git(root, &["rev-parse", "HEAD"]));
889        }
890        (dir, arrivals)
891    }
892
893    /// An ADR object as the index would hold it, dated on the same day as every other one
894    /// here: the date is deliberately useless, so that only git's answer can pass the test.
895    fn adr_object(id: &str) -> Object {
896        let mut metadata = serde_json::Map::new();
897        metadata.insert("id".into(), id.into());
898        metadata.insert("status".into(), "accepted".into());
899        metadata.insert("date".into(), "2026-09-01".into());
900        Object {
901            kind: ADR_KIND.into(),
902            identity: id.into(),
903            uri: format!("majordomus://adr/{id}"),
904            title: Some("t".into()),
905            description: None,
906            metadata: serde_json::Value::Object(metadata),
907            body: String::new(),
908            content: String::new(),
909            media_type: "text/markdown",
910            provenance: crate::model::Provenance {
911                path: format!(".ai/repo/decisions/{id}.md"),
912                directory: ".ai/repo/decisions".into(),
913                source_class: "decision".into(),
914                section: None,
915                bytes: 0,
916                member: None,
917            },
918        }
919    }
920
921    #[test]
922    fn a_decision_belongs_to_the_release_whose_tree_first_holds_it() {
923        let (dir, at) = repo_with_decisions(&["adr-0001", "adr-0002", "adr-0003"]);
924        let all = decisions_of(
925            dir.path(),
926            &[
927                adr_object("adr-0001"),
928                adr_object("adr-0002"),
929                adr_object("adr-0003"),
930            ],
931        );
932        assert!(
933            all.iter().all(|d| d.added.is_some()),
934            "git says when each file was added"
935        );
936        let mut ancestry = Ancestry::new(dir.path(), at.iter().map(String::as_str));
937        // (first tree, second tree] — the second only, whatever its front matter is dated.
938        let between = decisions_between(&mut ancestry, &all, Some(&at[0]), &at[1]);
939        assert_eq!(between.len(), 1);
940        assert_eq!(between[0].id, "adr-0002");
941        // The first release has no predecessor, so it carries everything its tree holds.
942        let first = decisions_between(&mut ancestry, &all, None, &at[0]);
943        assert_eq!(first.len(), 1);
944        assert_eq!(first[0].id, "adr-0001");
945    }
946
947    #[test]
948    fn the_unreleased_section_holds_what_no_released_tree_holds() {
949        let (dir, at) = repo_with_decisions(&["adr-0001", "adr-0003"]);
950        let all = decisions_of(
951            dir.path(),
952            &[adr_object("adr-0001"), adr_object("adr-0003")],
953        );
954        let mut ancestry = Ancestry::new(dir.path(), [at[0].as_str()]);
955        let after = decisions_after(&mut ancestry, &all, Some(&at[0]));
956        assert_eq!(after.len(), 1);
957        assert_eq!(after[0].id, "adr-0003");
958        // No release at all: everything is unreleased.
959        assert_eq!(decisions_after(&mut ancestry, &all, None).len(), 2);
960    }
961
962    /// Listing the trees' history once answers what `git merge-base --is-ancestor` answered one
963    /// process per question: for every pair of commits of a history with a branch and a merge
964    /// in it — a commit holds itself, a side branch's commit is held from the merge on and not
965    /// before — and a tree git cannot resolve, which is what a clone without that history
966    /// has, holds nothing. The answers are the same however the trees are named: listed
967    /// together when each is a full commit name, which is the whole of the change; listed
968    /// alone when one is a branch or an abbreviation, or when one git cannot resolve fails
969    /// the shared listing.
970    #[test]
971    fn a_tree_holds_exactly_the_commits_merge_base_calls_its_ancestors() {
972        let dir = tempfile::tempdir().expect("a temp dir");
973        let root = dir.path();
974        git(root, &["init", "-q", "-b", "main"]);
975        let step = |message: &str| {
976            git(root, &["commit", "-q", "--allow-empty", "-m", message]);
977            git(root, &["rev-parse", "HEAD"])
978        };
979        let a = step("a");
980        git(root, &["checkout", "-q", "-b", "side"]);
981        let s = step("s");
982        git(root, &["checkout", "-q", "main"]);
983        let b = step("b");
984        git(root, &["merge", "-q", "--no-ff", "-m", "m", "side"]);
985        let m = git(root, &["rev-parse", "HEAD"]);
986        let c = step("c");
987        let all = [a, s, b, m, c];
988        let unknown = "0".repeat(40);
989        let short: Vec<String> = all.iter().map(|c| c[..12].to_string()).collect();
990
991        // every tree as each spelling names it, against every commit, as merge-base answers
992        let agrees = |ancestry: &mut Ancestry<'_>, trees: &[&str]| {
993            let mut held = 0;
994            for of in trees {
995                for commit in &all {
996                    let asked = std::process::Command::new("git")
997                        .arg("-C")
998                        .arg(root)
999                        .args(["merge-base", "--is-ancestor", commit, of])
1000                        .status()
1001                        .expect("git runs")
1002                        .success();
1003                    assert_eq!(ancestry.holds(of, commit), asked, "is {commit} in {of}?");
1004                    held += usize::from(asked);
1005                }
1006            }
1007            // both answers occur, so the agreement is not vacuous
1008            assert!(held > 0 && held < trees.len() * all.len(), "{held} held");
1009            assert!(
1010                !ancestry.holds(&unknown, &all[0]),
1011                "an unknown tree holds nothing"
1012            );
1013        };
1014        let full: Vec<&str> = all.iter().map(String::as_str).collect();
1015
1016        // full names: one listing answers every tree
1017        let mut together = Ancestry::new(root, full.iter().copied());
1018        agrees(&mut together, &full);
1019        let listed = together
1020            .graph
1021            .as_ref()
1022            .expect("listed on the first question");
1023        assert_eq!(
1024            listed.index.len(),
1025            all.len(),
1026            "the listing holds every commit"
1027        );
1028        assert!(full
1029            .iter()
1030            .all(|t| matches!(together.held[*t], Held::Reached(_))));
1031        assert!(matches!(together.held[&unknown], Held::Listed(_)));
1032
1033        // branches and abbreviations: each listed alone, the same answers
1034        let mut spelled = Ancestry::new(
1035            root,
1036            ["main", "side"]
1037                .into_iter()
1038                .chain(short.iter().map(String::as_str)),
1039        );
1040        let mut names = vec!["main", "side"];
1041        names.extend(short.iter().map(String::as_str));
1042        agrees(&mut spelled, &names);
1043        assert!(spelled.graph.as_ref().is_some_and(|g| g.index.is_empty()));
1044
1045        // a tree git cannot resolve fails the shared listing: every tree alone, the same answers
1046        let mut broken = Ancestry::new(root, full.iter().copied().chain([unknown.as_str()]));
1047        agrees(&mut broken, &full);
1048        assert!(broken.graph.as_ref().is_some_and(|g| g.index.is_empty()));
1049        assert!(full
1050            .iter()
1051            .all(|t| matches!(broken.held[*t], Held::Listed(_))));
1052    }
1053
1054    /// Asked side by side, every decision gets the answer it got asked alone, in its own
1055    /// place: the order of the paths survives the order the threads finish in, and a path git
1056    /// never saw added is still nobody's.
1057    #[test]
1058    fn decisions_asked_together_are_dated_as_when_asked_alone() {
1059        let ids: Vec<String> = (1..=12).map(|n| format!("adr-{n:04}")).collect();
1060        let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
1061        let (dir, at) = repo_with_decisions(&refs);
1062        let mut paths: Vec<String> = ids
1063            .iter()
1064            .rev()
1065            .map(|id| format!(".ai/repo/decisions/{id}.md"))
1066            .collect();
1067        paths.insert(5, ".ai/repo/decisions/never-added.md".into());
1068        let asked: Vec<&str> = paths.iter().map(String::as_str).collect();
1069
1070        let together = added_at_each(dir.path(), &asked);
1071        let alone: Vec<Option<String>> = asked.iter().map(|p| added_at(dir.path(), p)).collect();
1072        assert_eq!(together, alone);
1073        assert_eq!(together[5], None);
1074        assert_eq!(together[0].as_deref(), Some(at[11].as_str()));
1075        assert_eq!(together[12].as_deref(), Some(at[0].as_str()));
1076        assert!(added_at_each(dir.path(), &[]).is_empty());
1077        // one path is asked in this thread, and answered as it is alone
1078        assert_eq!(added_at_each(dir.path(), &asked[..1]), alone[..1]);
1079    }
1080
1081    /// An ADR the index holds without a string `id` names no decision: it is left out, not
1082    /// listed under an empty or a numeric name.
1083    #[test]
1084    fn an_adr_without_a_string_id_is_no_decision() {
1085        let (dir, _) = repo_with_decisions(&["adr-0001"]);
1086        let mut unnamed = adr_object("adr-0002");
1087        unnamed.metadata.as_object_mut().unwrap().remove("id");
1088        let mut numbered = adr_object("adr-0003");
1089        numbered.metadata["id"] = 3.into();
1090        let all = decisions_of(dir.path(), &[adr_object("adr-0001"), unnamed, numbered]);
1091        let ids: Vec<&str> = all.iter().map(|d| d.id.as_str()).collect();
1092        assert_eq!(ids, ["adr-0001"]);
1093    }
1094
1095    /// A throwaway repository with `n` commits, the first of them tagged as a release the
1096    /// records name. What every test of the two compositions needs: a real HEAD that moves.
1097    fn repo_with_commits(n: usize) -> (tempfile::TempDir, String) {
1098        let dir = tempfile::tempdir().expect("a temp dir");
1099        let root = dir.path();
1100        let git = |args: &[&str]| {
1101            let out = std::process::Command::new("git")
1102                .arg("-C")
1103                .arg(root)
1104                .args(args)
1105                .env("GIT_AUTHOR_NAME", "t")
1106                .env("GIT_AUTHOR_EMAIL", "t@t")
1107                .env("GIT_COMMITTER_NAME", "t")
1108                .env("GIT_COMMITTER_EMAIL", "t@t")
1109                .output()
1110                .expect("git runs");
1111            assert!(
1112                out.status.success(),
1113                "git {args:?}: {}",
1114                String::from_utf8_lossy(&out.stderr)
1115            );
1116            String::from_utf8_lossy(&out.stdout).trim().to_string()
1117        };
1118        git(&["init", "-q"]);
1119        git(&[
1120            "commit",
1121            "-q",
1122            "--allow-empty",
1123            "-m",
1124            "feat(a): the release",
1125        ]);
1126        let release = git(&["rev-parse", "HEAD"]);
1127        for i in 1..n {
1128            git(&[
1129                "commit",
1130                "-q",
1131                "--allow-empty",
1132                "-m",
1133                &format!("fix(b): after the release {i}"),
1134            ]);
1135        }
1136        (dir, release)
1137    }
1138
1139    fn release_record(commit: &str) -> Object {
1140        let mut metadata = serde_json::Map::new();
1141        metadata.insert("schema".into(), "release/v1".into());
1142        metadata.insert("version".into(), "0.1.0".into());
1143        metadata.insert("tag".into(), "v0.1.0".into());
1144        metadata.insert("channel".into(), "stable".into());
1145        metadata.insert("commit".into(), commit.into());
1146        metadata.insert("published_at".into(), "2026-09-01T00:00:00Z".into());
1147        metadata.insert("artifacts".into(), serde_json::Value::Array(Vec::new()));
1148        Object {
1149            kind: RELEASE_KIND.into(),
1150            identity: "v0.1.0".into(),
1151            uri: "majordomus://release-record/v0.1.0".into(),
1152            title: None,
1153            description: None,
1154            metadata: serde_json::Value::Object(metadata),
1155            body: String::new(),
1156            content: String::new(),
1157            media_type: "application/yaml",
1158            provenance: crate::model::Provenance {
1159                path: ".ai/repo/releases/v0.1.0.yaml".into(),
1160                directory: ".ai/repo/releases".into(),
1161                source_class: "release".into(),
1162                section: None,
1163                bytes: 0,
1164                member: None,
1165            },
1166        }
1167    }
1168
1169    /// The property the committed artifact exists to have: a commit that publishes nothing
1170    /// does not change it. The live form is the one that follows HEAD.
1171    #[test]
1172    fn the_published_changelog_is_the_same_bytes_at_every_commit_after_the_release() {
1173        let (dir, release) = repo_with_commits(2);
1174        let objects = vec![release_record(&release)];
1175        let before = compose_published(dir.path(), &objects);
1176        let live_before = compose(dir.path(), &objects);
1177
1178        std::process::Command::new("git")
1179            .arg("-C")
1180            .arg(dir.path())
1181            .args([
1182                "commit",
1183                "-q",
1184                "--allow-empty",
1185                "-m",
1186                "fix(c): one more commit",
1187            ])
1188            .env("GIT_AUTHOR_NAME", "t")
1189            .env("GIT_AUTHOR_EMAIL", "t@t")
1190            .env("GIT_COMMITTER_NAME", "t")
1191            .env("GIT_COMMITTER_EMAIL", "t@t")
1192            .status()
1193            .expect("git runs");
1194
1195        let after = compose_published(dir.path(), &objects);
1196        let live_after = compose(dir.path(), &objects);
1197
1198        assert_eq!(
1199            serde_json::to_string(&before).unwrap(),
1200            serde_json::to_string(&after).unwrap(),
1201            "a commit that publishes nothing must not move the committed changelog"
1202        );
1203        assert!(
1204            !before.sections.iter().any(|s| s.unreleased),
1205            "the committed form carries no unreleased section"
1206        );
1207        assert!(
1208            live_before.sections.iter().any(|s| s.unreleased),
1209            "the live form does"
1210        );
1211        assert_ne!(
1212            serde_json::to_string(&live_before).unwrap(),
1213            serde_json::to_string(&live_after).unwrap(),
1214            "and the live form follows HEAD"
1215        );
1216    }
1217
1218    /// A repository that has never published: the live form says so with an unreleased
1219    /// section, and the committed form says so with nothing — there is nothing published to
1220    /// describe, and an empty section would be a claim.
1221    #[test]
1222    fn with_no_release_the_published_form_is_empty_and_the_live_form_is_unreleased() {
1223        let (dir, _) = repo_with_commits(1);
1224        let published = compose_published(dir.path(), &[]);
1225        assert!(published.sections.is_empty());
1226        let live = compose(dir.path(), &[]);
1227        assert_eq!(live.sections.len(), 1);
1228        assert!(live.sections[0].unreleased);
1229    }
1230
1231    /// One group per kind, whatever order the commits arrived in and however many kinds
1232    /// share a rank.
1233    ///
1234    /// The page gives each group an id built from its kind, and the Markdown gives each one
1235    /// a heading; both are unique only if this is. It was not: the sort was keyed on the
1236    /// rank alone, `Chore` and `Style` share rank 7, and `dedup` removes only adjacent
1237    /// repeats — so alternating chore and style commits produced a group per commit.
1238    #[test]
1239    fn a_kind_gets_one_group_however_the_commits_interleave() {
1240        let change = |kind: ChangeKind, commit: &str| Change {
1241            kind,
1242            scope: None,
1243            subject: "s".into(),
1244            breaking: false,
1245            commit: commit.into(),
1246            url: None,
1247            references: Vec::new(),
1248        };
1249        // Chore and Style share rank 7; Ci and Build share rank 6.
1250        let groups = grouped(vec![
1251            change(ChangeKind::Ci, "a"),
1252            change(ChangeKind::Chore, "b"),
1253            change(ChangeKind::Style, "c"),
1254            change(ChangeKind::Chore, "d"),
1255            change(ChangeKind::Build, "e"),
1256            change(ChangeKind::Style, "f"),
1257            change(ChangeKind::Chore, "g"),
1258        ]);
1259        let kinds: Vec<_> = groups.iter().map(|g| g.kind).collect();
1260        assert_eq!(
1261            kinds,
1262            vec![
1263                ChangeKind::Ci,
1264                ChangeKind::Build,
1265                ChangeKind::Chore,
1266                ChangeKind::Style
1267            ],
1268            "a kind appears more than once, or the ranks are out of order"
1269        );
1270        // and every change is carried exactly once, by the one group of its kind
1271        assert_eq!(groups.iter().map(|g| g.changes.len()).sum::<usize>(), 7);
1272        assert!(groups.windows(2).all(|w| w[0].rank <= w[1].rank));
1273    }
1274
1275    #[test]
1276    fn a_timestamp_and_a_date_compare_on_the_day_they_share() {
1277        assert_eq!(day("2026-09-09T15:04:05Z"), "2026-09-09");
1278        assert_eq!(day("2026-09-09"), "2026-09-09");
1279    }
1280
1281    #[test]
1282    fn the_rendering_groups_by_kind_and_marks_what_breaks() {
1283        let changelog = Changelog {
1284            schema: CHANGELOG_SCHEMA.into(),
1285            current: "0.4.0".into(),
1286            sections: vec![ReleaseSection {
1287                version: "unreleased".into(),
1288                tag: None,
1289                date: None,
1290                commit: None,
1291                unreleased: true,
1292                notes_url: None,
1293                compare_url: None,
1294                tree_url: None,
1295                decisions: vec![decision("adr-0027", "2026-09-09")],
1296                // Given in the order the commits arrived — a fix first — so that the
1297                // grouping, not the input, is what decides the order the renderer shows.
1298                groups: grouped(vec![
1299                    Change {
1300                        kind: ChangeKind::Fix,
1301                        scope: Some("ci".into()),
1302                        subject: "the gate runs".into(),
1303                        breaking: false,
1304                        commit: "aaa1111".into(),
1305                        url: None,
1306                        references: Vec::new(),
1307                    },
1308                    Change {
1309                        kind: ChangeKind::Feat,
1310                        scope: Some("commands".into()),
1311                        subject: "one graph".into(),
1312                        breaking: true,
1313                        commit: "bbb2222".into(),
1314                        url: None,
1315                        references: Vec::new(),
1316                    },
1317                ]),
1318                artifacts: Vec::new(),
1319            }],
1320            diagnostics: Vec::new(),
1321            produced_by: None,
1322        };
1323        let md = render(&changelog);
1324        assert!(md.contains("## Unreleased"));
1325        assert!(md.contains("**ADR-0027**"));
1326        // Added before Fixed, whatever order the commits arrived in.
1327        let added = md.find("### Added").expect("no Added heading");
1328        let fixed = md.find("### Fixed").expect("no Fixed heading");
1329        assert!(added < fixed, "the headings are out of order:\n{md}");
1330        assert!(md.contains("**BREAKING** **commands**: one graph"));
1331    }
1332
1333    /// I1409: a repository that holds the tree and not the history — the release commit is
1334    /// not an object in it — names the release as unreadable, and keeping the committed
1335    /// changelog's changes for it gives back the document composed where the history was.
1336    #[test]
1337    fn a_release_without_its_history_keeps_what_the_committed_changelog_says() {
1338        let (dir, release) = repo_with_commits(2);
1339        let objects = vec![release_record(&release)];
1340        assert!(
1341            unreadable_releases(dir.path(), &objects).is_empty(),
1342            "a repository holding the release commit can read it"
1343        );
1344        let whole = compose_published(dir.path(), &objects);
1345        assert!(!whole.sections[0].groups.is_empty(), "{whole:?}");
1346
1347        // the same record, in a repository that never had that commit
1348        let export = tempfile::tempdir().expect("a temp dir");
1349        let init = std::process::Command::new("git")
1350            .arg("-C")
1351            .arg(export.path())
1352            .args(["init", "-q"])
1353            .output()
1354            .expect("git runs");
1355        assert!(init.status.success());
1356        assert_eq!(
1357            unreadable_releases(export.path(), &objects),
1358            vec!["0.1.0".to_string()]
1359        );
1360        let mut husk = compose_published(export.path(), &objects);
1361        assert!(
1362            husk.sections[0].groups.is_empty(),
1363            "nothing to read: {husk:?}"
1364        );
1365        assert!(!husk.diagnostics.is_empty(), "the empty read is said");
1366
1367        let kept = keep_committed(&mut husk, &whole, &["0.1.0".to_string()]);
1368        assert_eq!(kept, vec!["0.1.0".to_string()]);
1369        assert_eq!(
1370            husk, whole,
1371            "the kept document is the one composed with history"
1372        );
1373
1374        // a committed section for another commit is not this release's: composed, not kept
1375        let mut other = whole.clone();
1376        other.sections[0].commit = Some("0".repeat(40));
1377        let mut husk = compose_published(export.path(), &objects);
1378        assert!(keep_committed(&mut husk, &other, &["0.1.0".to_string()]).is_empty());
1379        assert!(husk.sections[0].groups.is_empty());
1380    }
1381
1382    /// A shallow clone holds the release commit and still not the history behind it.
1383    #[test]
1384    fn a_shallow_clone_cannot_read_a_release() {
1385        let (dir, release) = repo_with_commits(3);
1386        let objects = vec![release_record(&release)];
1387        let shallow = tempfile::tempdir().expect("a temp dir");
1388        let url = format!("file://{}", dir.path().display());
1389        let clone = std::process::Command::new("git")
1390            .args(["clone", "-q", "--depth", "1", &url])
1391            .arg(shallow.path().join("c"))
1392            .output()
1393            .expect("git runs");
1394        assert!(
1395            clone.status.success(),
1396            "{}",
1397            String::from_utf8_lossy(&clone.stderr)
1398        );
1399        assert_eq!(
1400            unreadable_releases(&shallow.path().join("c"), &objects),
1401            vec!["0.1.0".to_string()]
1402        );
1403    }
1404
1405    /// A release's window opens at the previous release: with both commits held both read,
1406    /// and with the previous release's commit absent the newer one cannot be read either.
1407    #[test]
1408    fn a_release_is_unreadable_when_the_previous_release_is_not_held() {
1409        let (dir, first) = repo_with_commits(2);
1410        let second = git(dir.path(), &["rev-parse", "HEAD"]);
1411        assert_ne!(first, second);
1412        let record = |version: &str, commit: &str, at: &str| {
1413            let mut object = release_record(commit);
1414            let metadata = object.metadata.as_object_mut().expect("a mapping");
1415            metadata.insert("version".into(), version.into());
1416            metadata.insert("tag".into(), format!("v{version}").into());
1417            metadata.insert("published_at".into(), at.into());
1418            object
1419        };
1420        let both = vec![
1421            record("0.1.0", &first, "2026-09-01T00:00:00Z"),
1422            record("0.2.0", &second, "2026-09-02T00:00:00Z"),
1423        ];
1424        assert!(
1425            unreadable_releases(dir.path(), &both).is_empty(),
1426            "both windows are held"
1427        );
1428
1429        let absent = "0".repeat(40);
1430        let gap = vec![
1431            record("0.1.0", &absent, "2026-09-01T00:00:00Z"),
1432            record("0.2.0", &second, "2026-09-02T00:00:00Z"),
1433        ];
1434        assert_eq!(
1435            unreadable_releases(dir.path(), &gap),
1436            vec!["0.2.0".to_string(), "0.1.0".to_string()],
1437            "the newer release's window starts at a commit this repository lacks"
1438        );
1439    }
1440
1441    /// A git that cannot be asked — it cannot start, or the directory is no repository —
1442    /// holds no commit, so every release reads as unreadable rather than as empty.
1443    #[test]
1444    fn a_git_that_cannot_answer_holds_no_commit() {
1445        let (dir, release) = repo_with_commits(1);
1446        let absent = "0".repeat(40);
1447        let asked = [release.as_str(), absent.as_str()];
1448        assert_eq!(
1449            held_commits(dir.path(), &asked),
1450            HashSet::from([release.as_str()]),
1451            "the repository holds its own commit and not an unknown one"
1452        );
1453
1454        // a NUL in the path: the command cannot even be started
1455        let unspawnable = Path::new("no\0where");
1456        assert!(held_commits(unspawnable, &asked).is_empty());
1457        assert_eq!(
1458            unreadable_releases(unspawnable, &[release_record(&release)]),
1459            vec!["0.1.0".to_string()]
1460        );
1461
1462        // a directory that does not exist: git starts and refuses
1463        let gone = dir.path().join("absent");
1464        assert!(held_commits(&gone, &asked).is_empty());
1465    }
1466
1467    /// The shared listing is all or nothing: a git that cannot start lists nothing, and a
1468    /// listing that names a parent it does not list is not the whole history.
1469    #[test]
1470    fn a_listing_git_cannot_give_whole_is_no_graph() {
1471        let full = "a".repeat(40);
1472        let graph = Graph::list(Path::new("no\0where"), &[full]);
1473        assert!(graph.index.is_empty() && graph.parents.is_empty());
1474
1475        let (c, p) = ("c".repeat(40), "d".repeat(40));
1476        let partial = Graph::from_listing(&format!("{c} {p}\n"));
1477        assert!(partial.index.is_empty(), "a parent outside the listing");
1478        let whole = Graph::from_listing(&format!("{c} {p}\n{p}\n"));
1479        assert_eq!(whole.index.len(), 2);
1480        assert_eq!(whole.parents[whole.index[&c]], vec![whole.index[&p]]);
1481    }
1482
1483    /// A commit reached along two paths is reached once: a merge whose first parent is also
1484    /// the second parent's parent pushes that commit twice before it is popped.
1485    #[test]
1486    fn a_commit_reached_twice_is_reached_once() {
1487        let [m, r, a, x] = ["1", "2", "3", "4"].map(|d| d.repeat(40));
1488        let graph = Graph::from_listing(&format!("{m} {r} {a}\n{a} {r}\n{r}\n{x}\n"));
1489        let reached = graph.reach(graph.index[&m]);
1490        for held in [&m, &r, &a] {
1491            assert!(reached[graph.index[held]], "{held} is in the merge");
1492        }
1493        assert!(!reached[graph.index[&x]], "an unrelated commit is not");
1494        let from_a = graph.reach(graph.index[&a]);
1495        assert!(from_a[graph.index[&r]] && !from_a[graph.index[&m]]);
1496    }
1497}