Skip to main content

anodizer_core/git/commits/
subjects.rs

1use anyhow::{Context as _, Result, bail};
2use std::path::Path;
3use std::process::Command;
4
5/// Subject prefix anodizer stamps on its own release-machinery commits
6/// (version-sync bumps, rollback reverts). The matchers that must recognise
7/// those commits — rollback's idempotency check, the changelog stage's
8/// version-sync exclusion — compose their patterns from this same constant
9/// so a reworded writer can never silently break a matcher.
10pub const RELEASE_COMMIT_PREFIX: &str = "chore(release): ";
11
12/// `chore(release): bump ` — the subject prefix shared by every version-sync
13/// bump commit (see [`release_bump_subject`]).
14pub fn release_bump_subject_prefix() -> String {
15    format!("{RELEASE_COMMIT_PREFIX}bump ")
16}
17
18/// Build a version-sync bump commit subject:
19/// `chore(release): bump <summary><suffix>`. `suffix` carries the optional
20/// ` [skip ci]` marker (empty when none applies).
21pub fn release_bump_subject(summary: &str, suffix: &str) -> String {
22    format!("{}{summary}{suffix}", release_bump_subject_prefix())
23}
24
25/// Prefix of the changelog-provenance marker lines the `tag` and
26/// `bump --commit` commands record in the version-bump commit body when their
27/// `--changelog` refresh regenerates on-disk `CHANGELOG.md` files.
28///
29/// The publish stage's already-published content guard matches these markers
30/// (via [`changelog_regenerated_recorded_in`]) to decide whether a crate-root
31/// `CHANGELOG.md` difference against an already-published version is the
32/// tool's own re-cut artifact (forgivable) or operator-authored drift (a hard
33/// divergence). Writer and matcher compose from this one constant so a
34/// reworded marker can never silently break the guard.
35pub const CHANGELOG_PROVENANCE_PREFIX: &str = "changelog regenerated for ";
36
37/// Marker line recording that the tool regenerated the changelog file owned
38/// by `crate_name` at `version`: `changelog regenerated for <crate>@<version>`.
39/// Always crate-scoped, so one crate's regeneration can never vouch for a
40/// same-numbered version of a different crate, and a root-only aggregate that
41/// touched no packaged crate's own `CHANGELOG.md` mints no marker at all.
42pub fn changelog_regenerated_marker(crate_name: &str, version: &str) -> String {
43    format!("{CHANGELOG_PROVENANCE_PREFIX}{crate_name}@{version}")
44}
45
46/// Whether the LAST commit that touched `changelog_rel_path` (repo-relative,
47/// `/`-separated) in `workspace_root` records that the tool regenerated the
48/// changelog for `crate_name` at `version`.
49///
50/// Anchoring on the file's last toucher — not any marker anywhere in history —
51/// scopes the provenance to the file's CURRENT content: an operator hand-edit
52/// committed after the tool's regeneration makes the operator's commit the
53/// last toucher, whose message carries no marker, so the guard reverts to
54/// byte-strict instead of forgiving drift the tool did not author.
55///
56/// The marker match is an exact trimmed-line comparison, ruling out substring
57/// false positives (a `0.12.0` marker never matches a `0.12.01` probe).
58/// Returns `Ok(false)` when the file has never been committed or the last
59/// toucher carries no matching marker; `Err` only when git itself fails
60/// (callers making forgiveness decisions treat that as "no provenance").
61pub fn changelog_regenerated_recorded_in(
62    workspace_root: &Path,
63    crate_name: &str,
64    version: &str,
65    changelog_rel_path: &str,
66) -> Result<bool> {
67    let marker = changelog_regenerated_marker(crate_name, version);
68    let out = Command::new("git")
69        .arg("-C")
70        .arg(workspace_root)
71        .args([
72            "-c",
73            "log.showSignature=false",
74            "log",
75            "-1",
76            "--pretty=format:%B",
77            "--",
78            changelog_rel_path,
79        ])
80        .env("GIT_TERMINAL_PROMPT", "0")
81        .env("LC_ALL", "C")
82        .output()
83        .context("failed to invoke git log for the changelog provenance marker")?;
84    if !out.status.success() {
85        let stderr = String::from_utf8_lossy(&out.stderr);
86        // A repo with no commits yet trivially records no marker.
87        if stderr.contains("does not have any commits yet") {
88            return Ok(false);
89        }
90        let raw = format!("git log failed: {}", stderr.trim());
91        bail!("{}", crate::redact::redact_process_env(&raw));
92    }
93    let body = String::from_utf8_lossy(&out.stdout);
94    Ok(body.lines().map(str::trim).any(|line| line == marker))
95}