Skip to main content

release_kit/
assess.rs

1//! Classify a target repository before anything lands.
2//!
3//! The assessment is read-only evidence plus one classification computed
4//! from it by an explicit rule: `greenfield` when the target carries no
5//! release mechanism and no release history, `brownfield` when a release
6//! mechanism is already in place — a tool's configuration, a payload
7//! destination, a landed block — and `needs-decision` when the target
8//! shows release activity that no recognized mechanism explains: tags
9//! with no tool behind them, or a second long-lived branch. The rule
10//! lives here so a routing skill reads a verdict it can cite instead of
11//! judging "some release setup" by feel. The gathering spawns git and
12//! reads the disk; the rule itself is pure and unit-tested.
13
14use std::process::Command;
15
16use camino::Utf8Path;
17use serde::Serialize;
18
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::error::RkError;
21use crate::landing::{self, manifest};
22
23// The trunk and the release-line prefix come from the target's own
24// committed configuration; a target that states neither keeps the
25// compiled defaults in `crate::config`.
26
27/// Files that mark a release mechanism, whichever tool owns it.
28///
29/// The payload's own destinations are judged separately, as collisions;
30/// this list is what other tools leave behind: every configuration name
31/// semantic-release and `GoReleaser` document, release-plz's dotted form,
32/// the workflow names a hand-rolled publish commonly takes, and a
33/// changelog. `package.json` joins the list only when it carries the
34/// top-level `release` key semantic-release reads, judged in [`gather`].
35pub const RELEASE_MARKERS: [&str; 23] = [
36    ".release-plz.toml",
37    ".releaserc",
38    ".releaserc.cjs",
39    ".releaserc.js",
40    ".releaserc.json",
41    ".releaserc.mjs",
42    ".releaserc.yaml",
43    ".releaserc.yml",
44    "release.config.cjs",
45    "release.config.js",
46    "release.config.mjs",
47    ".config/goreleaser.yaml",
48    ".config/goreleaser.yml",
49    ".goreleaser.yaml",
50    ".goreleaser.yml",
51    "goreleaser.yaml",
52    "goreleaser.yml",
53    ".github/workflows/publish.yml",
54    ".github/workflows/publish.yaml",
55    ".github/workflows/release.yaml",
56    ".github/workflows/release-drafter.yml",
57    "CHANGELOG.md",
58    "CHANGES.md",
59];
60
61/// Branch names that conventionally outlive a topic.
62///
63/// A second one beside the trunk is the retired two-branch flow, or a
64/// trunk under another name, and either is a migration step. A
65/// `release/<line>` branch — the convention's own long-lived form — is
66/// recognized by its prefix.
67pub const LONG_LIVED_BRANCHES: [&str; 11] = [
68    "master",
69    "main",
70    "trunk",
71    "develop",
72    "development",
73    "dev",
74    "staging",
75    "next",
76    "release",
77    "production",
78    "prod",
79];
80
81/// What the target is, for routing: the corpus verdict, owned by the
82/// planner's classification module and served here under the name the
83/// assessment has always used.
84pub use crate::plan::classify::Verdict as Classification;
85
86/// The landing record's presence, the one fact `rk status` owns that the
87/// routing needs before it reads the full report.
88#[derive(Debug, Serialize)]
89pub struct Landing {
90    /// Whether `.release-kit/manifest.json` exists and reads.
91    pub recorded: bool,
92    /// The release-kit version the record names, where one exists.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub rk_version: Option<String>,
95}
96
97/// The evidence the classification is computed from.
98#[derive(Debug, Serialize)]
99pub struct Evidence {
100    /// The landing record, present or not.
101    pub landing: Landing,
102    /// The technology the version file names, where one is found.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub tech: Option<&'static str>,
105    /// The forge the origin remote maps to, where one is recognized.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub forge: Option<&'static str>,
108    /// The project path from the origin remote, where one exists.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub repo: Option<String>,
111    /// Release-mechanism files of other tools found at the target.
112    pub release_markers: Vec<String>,
113    /// Payload destinations already present: a whole file that exists, or
114    /// a block destination whose marked block is present.
115    pub collisions: Vec<String>,
116    /// Whether the target is a git repository the evidence below reads.
117    pub git: bool,
118    /// How many tags the repository holds.
119    pub tags: usize,
120    /// Long-lived branches found besides the trunk, local or remote.
121    pub long_lived_branches: Vec<String>,
122}
123
124/// Compute the verdict from the evidence. Pure, so the rule is testable
125/// without a repository.
126#[must_use]
127pub fn classify(evidence: &Evidence) -> Classification {
128    crate::plan::classify::verdict(&crate::plan::classify::RepositoryFacts {
129        release_markers: evidence.release_markers.clone(),
130        collisions: evidence.collisions.clone(),
131        tags: evidence.tags,
132        long_lived_branches: evidence.long_lived_branches.clone(),
133    })
134}
135
136/// The repository's facts alone, with no record read: what the planner
137/// gathers beside its own read of the record.
138#[derive(Debug)]
139pub struct Facts {
140    /// The technology the version file names, where one is found.
141    pub tech: Option<&'static str>,
142    /// The forge the origin remote maps to, where one is recognized.
143    pub forge: Option<&'static str>,
144    /// The project path from the origin remote, where one exists.
145    pub repo: Option<String>,
146    /// Release-mechanism files of other tools found at the target.
147    pub release_markers: Vec<String>,
148    /// Payload destinations already present.
149    pub collisions: Vec<String>,
150    /// Whether the target is a git repository.
151    pub git: bool,
152    /// How many tags the repository holds.
153    pub tags: usize,
154    /// Long-lived branches found besides the trunk.
155    pub long_lived_branches: Vec<String>,
156}
157
158/// Gather the evidence at `target`, reading and never writing.
159///
160/// # Errors
161///
162/// Returns the record's own failure taxonomy for an unreadable or unknown
163/// landing record — a broken record must not silently classify —
164/// [`RkError::Io`] for a disk read that fails for a reason other than
165/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
166/// for a repository, because an observation that cannot be read is not a
167/// pass and must never read as an absent release history.
168pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
169    let record = manifest::load(target)?;
170    let landing = Landing {
171        recorded: record.is_some(),
172        rk_version: record.map(|manifest| manifest.rk_version),
173    };
174    let facts = gather_facts(target)?;
175    Ok(Evidence {
176        landing,
177        tech: facts.tech,
178        forge: facts.forge,
179        repo: facts.repo,
180        release_markers: facts.release_markers,
181        collisions: facts.collisions,
182        git: facts.git,
183        tags: facts.tags,
184        long_lived_branches: facts.long_lived_branches,
185    })
186}
187
188/// Gather the repository's facts at `target`, the record aside.
189///
190/// # Errors
191///
192/// [`RkError::Io`] for a disk read that fails for a reason other than
193/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
194/// for a repository.
195pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
196    let detected = crate::detect::detect(target.as_std_path());
197    let mut release_markers: Vec<String> = RELEASE_MARKERS
198        .iter()
199        .filter(|marker| target.join(marker).is_file())
200        .map(|marker| (*marker).to_owned())
201        .collect();
202    if package_json_names_a_release(target)? {
203        release_markers.push("package.json".to_owned());
204    }
205    release_markers.sort();
206    let mut collisions = Vec::new();
207    for destination in landing::destinations() {
208        if landing::read_recorded(target, destination)?.is_some() {
209            collisions.push(destination.to_owned());
210        }
211    }
212    collisions.sort();
213    let (git, tags, long_lived_branches) = git_evidence(target)?;
214    Ok(Facts {
215        tech: crate::detect::tech_of(target.as_std_path()),
216        forge: detected.forge.map(crate::detect::Forge::as_str),
217        repo: detected.repo,
218        release_markers,
219        collisions,
220        git,
221        tags,
222        long_lived_branches,
223    })
224}
225
226/// Whether `package.json` carries the top-level `release` key
227/// semantic-release reads its configuration from. An ordinary Node
228/// project's manifest is not a release marker; only that key is.
229fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
230    let path = target.join("package.json");
231    let bytes = match std::fs::read(&path) {
232        Ok(bytes) => bytes,
233        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
234        Err(e) => return Err(RkError::Io(e)),
235    };
236    // A manifest that does not parse is not evidence of a release
237    // mechanism; the tool that would read it fails on it too.
238    Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
239        .ok()
240        .and_then(|value| value.get("release").map(|_| ()))
241        .is_some())
242}
243
244/// The git-borne evidence: whether the target is a repository, how many
245/// tags it holds, and which long-lived branches stand beside the trunk.
246///
247/// A directory git positively reports as no repository answers `false`
248/// and empty — an observation, never a failure, because a plain
249/// directory is a legitimate greenfield. Every other refusal — a
250/// corrupt repository, an ownership refusal, a git that does not run —
251/// is an error, because an unreadable history must not read as none.
252fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
253    let trunk = crate::config::trunk_of(target.as_std_path())?;
254    let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
255    let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
256    match git_lines(target, &["rev-parse", "--git-dir"]) {
257        Ok(_) => {}
258        Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
259        Err(GitFailure::Other(error)) => return Err(error),
260    }
261    let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
262    let refs = git_lines(
263        target,
264        &[
265            "for-each-ref",
266            "--format=%(refname)",
267            "refs/heads",
268            "refs/remotes",
269        ],
270    )
271    .map_err(GitFailure::into_error)?;
272    Ok((
273        true,
274        tags.len(),
275        long_lived_among(&refs, trunk, line_prefix),
276    ))
277}
278
279/// The long-lived branch names among `refs`, given as full ref names.
280///
281/// `refs/heads/<name>` keeps its whole name, `refs/remotes/<remote>/<name>`
282/// drops the remote alone, and a remote `HEAD` pointer is skipped. A
283/// name is long-lived when it is a catalog entry other than the trunk or
284/// carries the release-line prefix; each appears once, sorted.
285#[must_use]
286pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
287    let mut names = std::collections::BTreeSet::new();
288    for reference in refs {
289        let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
290            local
291        } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
292            match remote.split_once('/') {
293                Some((_, "HEAD")) | None => continue,
294                Some((_, name)) => name,
295            }
296        } else {
297            continue;
298        };
299        let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
300        if catalogued || name.starts_with(line_prefix) {
301            names.insert(name.to_owned());
302        }
303    }
304    names.into_iter().collect()
305}
306
307/// Why one git call gave no answer.
308enum GitFailure {
309    /// Git ran and said the target is not a repository.
310    NotARepository,
311    /// Git did not run, or ran and refused for another reason.
312    Other(RkError),
313}
314
315impl GitFailure {
316    /// After the target is known to be a repository, every failure is
317    /// the same kind: a history that cannot be read.
318    fn into_error(self) -> RkError {
319        match self {
320            Self::NotARepository => RkError::subprocess(
321                Diagnostic::new(
322                    Reason::SubprocessFailed,
323                    "git stopped answering for a repository it had just recognized",
324                )
325                .expected("a readable repository"),
326            ),
327            Self::Other(error) => error,
328        }
329    }
330}
331
332/// The non-empty stdout lines of one git call.
333///
334/// The call answers for the `-C` target alone: the variables a running
335/// hook exports are scrubbed, so an inherited `GIT_DIR` cannot redirect
336/// the probe at another repository, and the locale is pinned to `C`, so
337/// the one diagnostic this module reads — git's own "not a git
338/// repository" — arrives untranslated.
339fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
340    let mut command = Command::new(crate::probes::git_bin());
341    for var in crate::maintenance::GIT_HOOK_VARS {
342        command.env_remove(var);
343    }
344    let out = command
345        .env("LC_ALL", "C")
346        .env_remove("LANGUAGE")
347        .arg("-C")
348        .arg(target)
349        .args(args)
350        .output()
351        .map_err(|error| {
352            GitFailure::Other(RkError::subprocess(
353                Diagnostic::new(
354                    Reason::SubprocessSpawn,
355                    format!("git could not be spawned: {error}"),
356                )
357                .expected("git on PATH, or RK_GIT_BIN naming it"),
358            ))
359        })?;
360    if !out.status.success() {
361        let stderr = String::from_utf8_lossy(&out.stderr);
362        if stderr.contains("not a git repository") {
363            return Err(GitFailure::NotARepository);
364        }
365        return Err(GitFailure::Other(RkError::subprocess(
366            Diagnostic::new(
367                Reason::SubprocessFailed,
368                format!(
369                    "git {} failed at {target}: {}",
370                    args.join(" "),
371                    stderr.trim()
372                ),
373            )
374            .expected("git answering for the target, or a target that is not a repository")
375            .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
376        )));
377    }
378    Ok(String::from_utf8_lossy(&out.stdout)
379        .lines()
380        .map(str::trim)
381        .filter(|line| !line.is_empty())
382        .map(str::to_owned)
383        .collect())
384}
385
386#[cfg(test)]
387mod tests {
388    use super::{Classification, long_lived_among};
389
390    /// The trunk is never evidence against itself; only the remote
391    /// segment is stripped, so a topic branch whose last segment is a
392    /// catalog name stays a topic branch; a release line is recognized
393    /// by its prefix; a remote HEAD pointer is skipped; each name once.
394    #[test]
395    fn long_lived_branches_are_read_from_the_full_ref_names() {
396        let refs: Vec<String> = [
397            "refs/heads/master",
398            "refs/remotes/origin/master",
399            "refs/remotes/origin/HEAD",
400            "refs/heads/develop",
401            "refs/remotes/origin/develop",
402            "refs/heads/feat/x",
403            "refs/heads/feat/develop",
404            "refs/remotes/origin/main",
405            "refs/heads/release/1.2",
406            "refs/remotes/upstream/release/1.2",
407        ]
408        .iter()
409        .map(|name| (*name).to_owned())
410        .collect();
411        assert_eq!(
412            long_lived_among(&refs, "master", "release/"),
413            vec!["develop", "main", "release/1.2"]
414        );
415        assert!(
416            long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
417        );
418        assert!(
419            long_lived_among(
420                &["refs/heads/feat/develop".to_owned()],
421                "master",
422                "release/"
423            )
424            .is_empty()
425        );
426    }
427
428    #[test]
429    fn the_verdict_words_are_the_wire_form() {
430        assert_eq!(Classification::Greenfield.as_str(), "greenfield");
431        assert_eq!(Classification::Brownfield.as_str(), "brownfield");
432        assert_eq!(Classification::NeedsDecision.as_str(), "needs-decision");
433    }
434}