Skip to main content

release_kit/release_engine/
cliff.rs

1//! The git-cliff calls: the bumped version, the changelog, and the notes.
2//!
3//! The engine runs the git-cliff binary and links no library. It reads
4//! the pin from the embedded `versions.toml` and refuses a binary at any
5//! other version, so the changelog a release writes moves only with the
6//! registry row. It never passes `--bump`: git-cliff proposes a number,
7//! and the engine decides it.
8//!
9//! SATISFIES release-engine:git-cliff-runs-at-its-pin
10
11use camino::Utf8Path;
12
13use crate::diagnostic::{Diagnostic, Reason};
14use crate::error::RkError;
15
16/// Run git-cliff in `root` with `args`, its failure typed.
17fn run(root: &Utf8Path, args: &[&str]) -> Result<String, RkError> {
18    let mut command = std::process::Command::new(crate::probes::git_cliff_bin());
19    for var in crate::maintenance::GIT_HOOK_VARS {
20        command.env_remove(var);
21    }
22    let output = command
23        .args(args)
24        .current_dir(root.as_std_path())
25        .output()
26        .map_err(|source| {
27            RkError::refusal(
28                Diagnostic::new(
29                    Reason::PrerequisiteUnmet,
30                    format!("git-cliff did not run: {source}"),
31                )
32                .expected("git-cliff on PATH at the version versions.toml pins")
33                .action("install git-cliff at its pin, or put it in the recorded development environment")
34                .target_state("unchanged"),
35            )
36        })?;
37    if !output.status.success() {
38        return Err(RkError::subprocess(
39            Diagnostic::new(
40                Reason::SubprocessFailed,
41                format!(
42                    "git-cliff {} failed: {}",
43                    args.join(" "),
44                    super::last_line(&output.stderr)
45                ),
46            )
47            .expected("git-cliff to read the landed cliff.toml and the history"),
48        ));
49    }
50    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
51}
52
53/// The version `versions.toml` pins.
54#[must_use]
55pub fn pin() -> Option<String> {
56    crate::registry::version_of("git-cliff")
57}
58
59/// Refuse unless the git-cliff on hand runs at the pin.
60///
61/// # Errors
62///
63/// [`Reason::ToolOffPin`] where the binary reports another version, and
64/// a refusal where it does not run.
65pub fn require_pin(root: &Utf8Path) -> Result<String, RkError> {
66    let pinned = pin().unwrap_or_default();
67    let answer = run(root, &["--version"])?;
68    let found = crate::probes::parse_cli_version(&answer);
69    let wanted = crate::probes::parse_cli_version(&pinned);
70    if found.is_none() || found != wanted {
71        return Err(RkError::refusal(
72            Diagnostic::new(
73                Reason::ToolOffPin,
74                format!(
75                    "git-cliff reports {:?}, and versions.toml pins {pinned}",
76                    answer.trim()
77                ),
78            )
79            .expected(format!("git-cliff {pinned}"))
80            .action(format!(
81                "install git-cliff {pinned}, or move the recorded development environment to it"
82            ))
83            .target_state("unchanged"),
84        ));
85    }
86    Ok(pinned)
87}
88
89/// The arguments that limit git-cliff to the commits that touch `paths`:
90/// one `--include-path` per file. No path filters nothing.
91fn include(paths: &[String]) -> Vec<String> {
92    paths
93        .iter()
94        .flat_map(|path| {
95            [
96                "--include-path".to_owned(),
97                super::packed::cliff_pattern(path),
98            ]
99        })
100        .collect()
101}
102
103/// Run git-cliff with `args`, then the path filter of `paths`.
104fn run_on(root: &Utf8Path, args: &[&str], paths: &[String]) -> Result<String, RkError> {
105    let filter = include(paths);
106    let mut all: Vec<&str> = args.to_vec();
107    all.extend(filter.iter().map(String::as_str));
108    run(root, &all)
109}
110
111/// git-cliff's proposal for the next version, from the landed
112/// `cliff.toml` and the history of the commits that touch `paths`, or of
113/// every commit where `paths` is empty.
114///
115/// # Errors
116///
117/// git-cliff's failure.
118pub fn bumped(root: &Utf8Path, paths: &[String]) -> Result<String, RkError> {
119    let answer = run_on(root, &["--bumped-version"], paths)?;
120    Ok(answer
121        .lines()
122        .rev()
123        .find(|line| !line.trim().is_empty())
124        .unwrap_or_default()
125        .trim()
126        .to_owned())
127}
128
129/// Add `tag` as the newest section of `CHANGELOG.md`, from the unreleased
130/// commits that touch `paths`.
131///
132/// git-cliff prepends the section below the header `cliff.toml` defines,
133/// so every released section keeps its bytes. git-cliff refuses to prepend to a file that does not exist, so a target
134/// with no changelog yet gets a new file with the header and the section.
135///
136/// # Errors
137///
138/// git-cliff's failure.
139pub fn changelog(root: &Utf8Path, tag: &str, paths: &[String]) -> Result<(), RkError> {
140    let write = if root.join("CHANGELOG.md").exists() {
141        "--prepend"
142    } else {
143        "-o"
144    };
145    run_on(
146        root,
147        &["--unreleased", "--tag", tag, write, "CHANGELOG.md"],
148        paths,
149    )
150    .map(drop)
151}
152
153/// Write the notes of the unreleased commits that touch `paths`, as `tag`,
154/// into `out`. The tag does not exist yet, so the notes name it.
155///
156/// # Errors
157///
158/// git-cliff's failure.
159pub fn notes(root: &Utf8Path, tag: &str, out: &Utf8Path, paths: &[String]) -> Result<(), RkError> {
160    run_on(
161        root,
162        &["--unreleased", "--tag", tag, "-o", out.as_str()],
163        paths,
164    )
165    .map(drop)
166}