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}