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 landed
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/// release-kit'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, computed from the
82/// repository's evidence alone.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum Classification {
86    /// No release mechanism and no release history: land the workflow.
87    Greenfield,
88    /// A release mechanism is in place: migrate, never land beside it.
89    Brownfield,
90    /// Release activity no mechanism explains: the operator decides.
91    NeedsDecision,
92}
93
94impl Classification {
95    /// The kebab-case verdict word, as the JSON serializes it.
96    #[must_use]
97    pub const fn as_str(self) -> &'static str {
98        match self {
99            Self::Greenfield => "greenfield",
100            Self::Brownfield => "brownfield",
101            Self::NeedsDecision => "needs-decision",
102        }
103    }
104}
105
106/// The landing record's presence, the one fact `rk status` owns that the
107/// routing needs before it reads the full report.
108#[derive(Debug, Serialize)]
109pub struct Landing {
110    /// Whether `.release-kit/manifest.json` exists and reads.
111    pub recorded: bool,
112    /// The release-kit version the record names, where one exists.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub rk_version: Option<String>,
115}
116
117/// The evidence the classification is computed from.
118#[derive(Debug, Serialize)]
119pub struct Evidence {
120    /// The landing record, present or not.
121    pub landing: Landing,
122    /// The technology the version file names, where one is found.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub tech: Option<&'static str>,
125    /// The forge the origin remote maps to, where one is recognized.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub forge: Option<&'static str>,
128    /// The project path from the origin remote, where one exists.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub repo: Option<String>,
131    /// Release-mechanism files of other tools found at the target.
132    pub release_markers: Vec<String>,
133    /// Landable destinations already present: a whole file that exists, or
134    /// a block destination whose marked block is present.
135    pub collisions: Vec<String>,
136    /// Whether the target is a git repository the evidence below reads.
137    pub git: bool,
138    /// How many tags the repository holds.
139    pub tags: usize,
140    /// Long-lived branches found besides the trunk, local or remote.
141    pub long_lived_branches: Vec<String>,
142}
143
144/// Compute the verdict from the evidence. Pure, so the rule is testable
145/// without a repository.
146#[must_use]
147pub const fn classify(evidence: &Evidence) -> Classification {
148    if !evidence.release_markers.is_empty() || !evidence.collisions.is_empty() {
149        return Classification::Brownfield;
150    }
151    if evidence.tags > 0 || !evidence.long_lived_branches.is_empty() {
152        return Classification::NeedsDecision;
153    }
154    Classification::Greenfield
155}
156
157/// The repository's facts alone, with no record read: what the planner
158/// gathers beside its own read of the record.
159#[derive(Debug)]
160pub struct Facts {
161    /// The technology the version file names, where one is found.
162    pub tech: Option<&'static str>,
163    /// The forge the origin remote maps to, where one is recognized.
164    pub forge: Option<&'static str>,
165    /// The project path from the origin remote, where one exists.
166    pub repo: Option<String>,
167    /// Release-mechanism files of other tools found at the target.
168    pub release_markers: Vec<String>,
169    /// Landable destinations already present.
170    pub collisions: Vec<String>,
171    /// Whether the target is a git repository.
172    pub git: bool,
173    /// How many tags the repository holds.
174    pub tags: usize,
175    /// Long-lived branches found besides the trunk.
176    pub long_lived_branches: Vec<String>,
177}
178
179/// Gather the evidence at `target`, reading and never writing.
180///
181/// # Errors
182///
183/// Returns the record's own failure taxonomy for an unreadable or unknown
184/// landing record — a broken record must not silently classify —
185/// [`RkError::Io`] for a disk read that fails for a reason other than
186/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
187/// for a repository, because an observation that cannot be read is not a
188/// pass and must never read as an absent release history.
189pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
190    let record = manifest::load(target)?;
191    let landing = Landing {
192        recorded: record.is_some(),
193        rk_version: record.map(|manifest| manifest.rk_version),
194    };
195    let facts = gather_facts(target)?;
196    Ok(Evidence {
197        landing,
198        tech: facts.tech,
199        forge: facts.forge,
200        repo: facts.repo,
201        release_markers: facts.release_markers,
202        collisions: facts.collisions,
203        git: facts.git,
204        tags: facts.tags,
205        long_lived_branches: facts.long_lived_branches,
206    })
207}
208
209/// Gather the repository's facts at `target`, the record aside.
210///
211/// # Errors
212///
213/// [`RkError::Io`] for a disk read that fails for a reason other than
214/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
215/// for a repository.
216pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
217    let detected = crate::detect::detect(target.as_std_path());
218    let mut release_markers: Vec<String> = RELEASE_MARKERS
219        .iter()
220        .filter(|marker| target.join(marker).is_file())
221        .map(|marker| (*marker).to_owned())
222        .collect();
223    if package_json_names_a_release(target)? {
224        release_markers.push("package.json".to_owned());
225    }
226    release_markers.sort();
227    let mut collisions = Vec::new();
228    for destination in landing::destinations() {
229        if landing::read_recorded(target, destination)?.is_some() {
230            collisions.push(destination.to_owned());
231        }
232    }
233    collisions.sort();
234    let (git, tags, long_lived_branches) = git_evidence(target)?;
235    Ok(Facts {
236        tech: crate::detect::tech_of(target.as_std_path()),
237        forge: detected.forge.map(crate::detect::Forge::as_str),
238        repo: detected.repo,
239        release_markers,
240        collisions,
241        git,
242        tags,
243        long_lived_branches,
244    })
245}
246
247/// Whether `package.json` carries the top-level `release` key
248/// semantic-release reads its configuration from. An ordinary Node
249/// project's manifest is not a release marker; only that key is.
250fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
251    let path = target.join("package.json");
252    let bytes = match std::fs::read(&path) {
253        Ok(bytes) => bytes,
254        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
255        Err(e) => return Err(RkError::Io(e)),
256    };
257    // A manifest that does not parse is not evidence of a release
258    // mechanism; the tool that would read it fails on it too.
259    Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
260        .ok()
261        .and_then(|value| value.get("release").map(|_| ()))
262        .is_some())
263}
264
265/// The git-borne evidence: whether the target is a repository, how many
266/// tags it holds, and which long-lived branches stand beside the trunk.
267///
268/// A directory git positively reports as no repository answers `false`
269/// and empty — an observation, never a failure, because a plain
270/// directory is a legitimate greenfield. Every other refusal — a
271/// corrupt repository, an ownership refusal, a git that does not run —
272/// is an error, because an unreadable history must not read as none.
273fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
274    let trunk = crate::config::trunk_of(target.as_std_path())?;
275    let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
276    let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
277    match git_lines(target, &["rev-parse", "--git-dir"]) {
278        Ok(_) => {}
279        Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
280        Err(GitFailure::Other(error)) => return Err(error),
281    }
282    let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
283    let refs = git_lines(
284        target,
285        &[
286            "for-each-ref",
287            "--format=%(refname)",
288            "refs/heads",
289            "refs/remotes",
290        ],
291    )
292    .map_err(GitFailure::into_error)?;
293    Ok((
294        true,
295        tags.len(),
296        long_lived_among(&refs, trunk, line_prefix),
297    ))
298}
299
300/// The long-lived branch names among `refs`, given as full ref names.
301///
302/// `refs/heads/<name>` keeps its whole name, `refs/remotes/<remote>/<name>`
303/// drops the remote alone, and a remote `HEAD` pointer is skipped. A
304/// name is long-lived when it is a catalog entry other than the trunk or
305/// carries the release-line prefix; each appears once, sorted.
306#[must_use]
307pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
308    let mut names = std::collections::BTreeSet::new();
309    for reference in refs {
310        let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
311            local
312        } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
313            match remote.split_once('/') {
314                Some((_, "HEAD")) | None => continue,
315                Some((_, name)) => name,
316            }
317        } else {
318            continue;
319        };
320        let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
321        if catalogued || name.starts_with(line_prefix) {
322            names.insert(name.to_owned());
323        }
324    }
325    names.into_iter().collect()
326}
327
328/// Why one git call gave no answer.
329enum GitFailure {
330    /// Git ran and said the target is not a repository.
331    NotARepository,
332    /// Git did not run, or ran and refused for another reason.
333    Other(RkError),
334}
335
336impl GitFailure {
337    /// After the target is known to be a repository, every failure is
338    /// the same kind: a history that cannot be read.
339    fn into_error(self) -> RkError {
340        match self {
341            Self::NotARepository => RkError::subprocess(
342                Diagnostic::new(
343                    Reason::SubprocessFailed,
344                    "git stopped answering for a repository it had just recognized",
345                )
346                .expected("a readable repository"),
347            ),
348            Self::Other(error) => error,
349        }
350    }
351}
352
353/// The non-empty stdout lines of one git call.
354///
355/// The call answers for the `-C` target alone: the variables a running
356/// hook exports are scrubbed, so an inherited `GIT_DIR` cannot redirect
357/// the probe at another repository, and the locale is pinned to `C`, so
358/// the one diagnostic this module reads — git's own "not a git
359/// repository" — arrives untranslated.
360fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
361    let mut command = Command::new(crate::probes::git_bin());
362    for var in crate::maintenance::GIT_HOOK_VARS {
363        command.env_remove(var);
364    }
365    let out = command
366        .env("LC_ALL", "C")
367        .env_remove("LANGUAGE")
368        .arg("-C")
369        .arg(target)
370        .args(args)
371        .output()
372        .map_err(|error| {
373            GitFailure::Other(RkError::subprocess(
374                Diagnostic::new(
375                    Reason::SubprocessSpawn,
376                    format!("git could not be spawned: {error}"),
377                )
378                .expected("git on PATH, or RK_GIT_BIN naming it"),
379            ))
380        })?;
381    if !out.status.success() {
382        let stderr = String::from_utf8_lossy(&out.stderr);
383        if stderr.contains("not a git repository") {
384            return Err(GitFailure::NotARepository);
385        }
386        return Err(GitFailure::Other(RkError::subprocess(
387            Diagnostic::new(
388                Reason::SubprocessFailed,
389                format!(
390                    "git {} failed at {target}: {}",
391                    args.join(" "),
392                    stderr.trim()
393                ),
394            )
395            .expected("git answering for the target, or a target that is not a repository")
396            .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
397        )));
398    }
399    Ok(String::from_utf8_lossy(&out.stdout)
400        .lines()
401        .map(str::trim)
402        .filter(|line| !line.is_empty())
403        .map(str::to_owned)
404        .collect())
405}
406
407#[cfg(test)]
408mod tests {
409    use super::{Classification, Evidence, Landing, classify, long_lived_among};
410
411    /// The trunk is never evidence against itself; only the remote
412    /// segment is stripped, so a topic branch whose last segment is a
413    /// catalog name stays a topic branch; a release line is recognized
414    /// by its prefix; a remote HEAD pointer is skipped; each name once.
415    #[test]
416    fn long_lived_branches_are_read_from_the_full_ref_names() {
417        let refs: Vec<String> = [
418            "refs/heads/master",
419            "refs/remotes/origin/master",
420            "refs/remotes/origin/HEAD",
421            "refs/heads/develop",
422            "refs/remotes/origin/develop",
423            "refs/heads/feat/x",
424            "refs/heads/feat/develop",
425            "refs/remotes/origin/main",
426            "refs/heads/release/1.2",
427            "refs/remotes/upstream/release/1.2",
428        ]
429        .iter()
430        .map(|name| (*name).to_owned())
431        .collect();
432        assert_eq!(
433            long_lived_among(&refs, "master", "release/"),
434            vec!["develop", "main", "release/1.2"]
435        );
436        assert!(
437            long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
438        );
439        assert!(
440            long_lived_among(
441                &["refs/heads/feat/develop".to_owned()],
442                "master",
443                "release/"
444            )
445            .is_empty()
446        );
447    }
448
449    #[test]
450    fn the_verdict_words_are_the_wire_form() {
451        for (classification, word) in [
452            (Classification::Greenfield, "greenfield"),
453            (Classification::Brownfield, "brownfield"),
454            (Classification::NeedsDecision, "needs-decision"),
455        ] {
456            assert_eq!(classification.as_str(), word);
457            assert_eq!(
458                serde_json::to_string(&classification).expect("serializes"),
459                format!("\"{word}\"")
460            );
461        }
462    }
463
464    fn evidence() -> Evidence {
465        Evidence {
466            landing: Landing {
467                recorded: false,
468                rk_version: None,
469            },
470            tech: None,
471            forge: None,
472            repo: None,
473            release_markers: Vec::new(),
474            collisions: Vec::new(),
475            git: true,
476            tags: 0,
477            long_lived_branches: Vec::new(),
478        }
479    }
480
481    #[test]
482    fn nothing_is_greenfield() {
483        assert_eq!(classify(&evidence()), Classification::Greenfield);
484    }
485
486    #[test]
487    fn a_release_marker_or_a_collision_is_brownfield() {
488        let with_marker = Evidence {
489            release_markers: vec!["CHANGELOG.md".into()],
490            ..evidence()
491        };
492        assert_eq!(classify(&with_marker), Classification::Brownfield);
493        let with_collision = Evidence {
494            collisions: vec!["release-plz.toml".into()],
495            ..evidence()
496        };
497        assert_eq!(classify(&with_collision), Classification::Brownfield);
498    }
499
500    /// A mechanism outranks unexplained activity: tags beside a marker
501    /// are a history the mechanism made, not a question.
502    #[test]
503    fn a_mechanism_beside_activity_is_still_brownfield() {
504        let both = Evidence {
505            release_markers: vec!["CHANGELOG.md".into()],
506            tags: 7,
507            long_lived_branches: vec!["develop".into()],
508            ..evidence()
509        };
510        assert_eq!(classify(&both), Classification::Brownfield);
511    }
512
513    #[test]
514    fn activity_with_no_mechanism_needs_a_decision() {
515        let tagged = Evidence {
516            tags: 1,
517            ..evidence()
518        };
519        assert_eq!(classify(&tagged), Classification::NeedsDecision);
520        let branched = Evidence {
521            long_lived_branches: vec!["develop".into()],
522            ..evidence()
523        };
524        assert_eq!(classify(&branched), Classification::NeedsDecision);
525    }
526}