1use 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
31fn 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
46pub const RELEASE_KIND: &str = "release-record";
48pub const ADR_KIND: &str = "adr";
50
51struct Record {
53 version: String,
54 tag: String,
55 date: String,
56 commit: String,
57 notes: Option<String>,
58 artifacts: Vec<Artifact>,
59}
60
61pub fn compose(root: &Path, objects: &[Object]) -> Changelog {
71 compose_with(root, objects, true)
72}
73
74pub fn compose_published(root: &Path, objects: &[Object]) -> Changelog {
87 compose_with(root, objects, false)
88}
89
90fn 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 let mut ancestry = Ancestry::new(root, records.iter().map(|r| r.commit.as_str()));
111
112 let mut sections = Vec::new();
113
114 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 } else {
130 sections.push(ReleaseSection {
131 version: "unreleased".into(),
132 tag: None,
133 date: None,
134 commit: None,
135 unreleased: true,
136 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 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 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 produced_by: None,
203 }
204}
205
206fn 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
239fn 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
290pub(crate) fn unreadable_releases(root: &Path, objects: &[Object]) -> Vec<String> {
301 let mut ignored = Vec::new();
302 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
329fn 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 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 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
367pub(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 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
408fn 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
439fn added_at_each(root: &Path, paths: &[&str]) -> Vec<Option<String>> {
450 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 .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
488struct Ancestry<'a> {
509 root: &'a Path,
510 trees: Vec<String>,
512 graph: Option<Graph>,
513 held: HashMap<String, Held>,
514}
515
516enum 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 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#[derive(Default)]
557struct Graph {
558 index: HashMap<String, usize>,
559 parents: Vec<Vec<usize>>,
560}
561
562impl Graph {
563 fn list(root: &Path, trees: &[String]) -> Graph {
566 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 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 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 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
637fn 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
643fn 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
662fn 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 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
713fn 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 (Some(rel), Some(added)) => !ancestry.holds(rel, added),
724 _ => true,
727 })
728 .cloned()
729 .collect()
730}
731
732fn 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
751fn day(text: &str) -> &str {
753 text.split('T').next().unwrap_or(text)
754}
755
756pub 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(§ion.version),
777 day(date)
778 ));
779 }
780
781 if !section.decisions.is_empty() {
782 out.push_str("### Decisions\n\n");
783 for d in §ion.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 for group in §ion.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 §ion.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 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 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 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 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 assert_eq!(decisions_after(&mut ancestry, &all, None).len(), 2);
960 }
961
962 #[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 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 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 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 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 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 #[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 assert_eq!(added_at_each(dir.path(), &asked[..1]), alone[..1]);
1079 }
1080
1081 #[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 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 #[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 #[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 #[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 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 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 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 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 #[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 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 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 #[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 #[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 #[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 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 let gone = dir.path().join("absent");
1464 assert!(held_commits(&gone, &asked).is_empty());
1465 }
1466
1467 #[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 #[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}