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 technologies the version files name, zero or many.
123    pub technologies: Vec<&'static str>,
124    /// The forge the origin remote maps to, where one is recognized.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub forge: Option<&'static str>,
127    /// The project path from the origin remote, where one exists.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub repo: Option<String>,
130    /// Release-mechanism files of other tools found at the target.
131    pub release_markers: Vec<String>,
132    /// Landable destinations already present: a whole file that exists, or
133    /// a block destination whose marked block is present.
134    pub collisions: Vec<String>,
135    /// Whether the target is a git repository the evidence below reads.
136    pub git: bool,
137    /// How many tags the repository holds.
138    pub tags: usize,
139    /// Long-lived branches found besides the trunk, local or remote.
140    pub long_lived_branches: Vec<String>,
141}
142
143/// Compute the verdict from the evidence. Pure, so the rule is testable
144/// without a repository.
145#[must_use]
146pub const fn classify(evidence: &Evidence) -> Classification {
147    verdict(
148        evidence.release_markers.as_slice(),
149        evidence.collisions.as_slice(),
150        evidence.tags,
151        evidence.long_lived_branches.as_slice(),
152    )
153}
154
155/// The one rule both readers apply: a mechanism in place outranks
156/// unexplained activity, and nothing at all is a fresh start.
157const fn verdict(
158    release_markers: &[String],
159    collisions: &[String],
160    tags: usize,
161    long_lived_branches: &[String],
162) -> Classification {
163    if !release_markers.is_empty() || !collisions.is_empty() {
164        return Classification::Brownfield;
165    }
166    if tags > 0 || !long_lived_branches.is_empty() {
167        return Classification::NeedsDecision;
168    }
169    Classification::Greenfield
170}
171
172/// What marks release-kit's own landing at a target that carries no
173/// record: the evidence the adoption detector reads.
174#[derive(Debug, Default, Serialize)]
175pub struct OwnLanding {
176    /// Block destinations whose release-kit marked region is present.
177    pub regions: Vec<String>,
178    /// Whole-file rendered destinations holding exactly the bytes this
179    /// binary renders for them at this target.
180    pub rendered: Vec<String>,
181}
182
183impl OwnLanding {
184    /// Whether any of release-kit's own landing is present.
185    #[must_use]
186    pub const fn present(&self) -> bool {
187        !self.regions.is_empty() || !self.rendered.is_empty()
188    }
189}
190
191/// Where a target with no record stands, by the one rule above and the
192/// adoption detector before it.
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Unrecorded {
195    /// release-kit's own landing is present and only the record is
196    /// missing.
197    Adopt,
198    /// Everything else, by the assessment's verdict.
199    Assessed(Classification),
200}
201
202/// Classify a target with no record.
203///
204/// Pure, so the precedence is testable without a repository:
205/// release-kit's own landing outranks every other mechanism, because its
206/// marked regions and its rendered files are what a landing made before
207/// the record existed leaves behind, and they would otherwise read as
208/// another tool's collisions.
209#[must_use]
210pub const fn classify_unrecorded(facts: &Facts, own: &OwnLanding) -> Unrecorded {
211    if own.present() {
212        return Unrecorded::Adopt;
213    }
214    Unrecorded::Assessed(verdict(
215        facts.release_markers.as_slice(),
216        facts.collisions.as_slice(),
217        facts.tags,
218        facts.long_lived_branches.as_slice(),
219    ))
220}
221
222/// Where a target with no record stands, by the precedence `rk status`
223/// reads it with.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Standing {
226    /// A migration is open: its archive emptied the target on purpose,
227    /// and this outranks every other reading until it closes.
228    Migrating,
229    /// No migration is open, so the adoption detector and the rule.
230    Unrecorded(Unrecorded),
231}
232
233/// Read where a target with no record stands: an open migration first,
234/// then the adoption detector and the assessment's rule.
235///
236/// # Errors
237///
238/// [`RkError::Io`] for a read of the target that fails for a reason
239/// other than absence.
240pub fn standing(
241    target: &Utf8Path,
242    config: Option<&crate::config::Config>,
243) -> Result<Standing, RkError> {
244    if config.is_some_and(|config| config.migration.is_some()) {
245        return Ok(Standing::Migrating);
246    }
247    let facts = gather_facts(target)?;
248    let own = own_landing(target, config)?;
249    Ok(Standing::Unrecorded(classify_unrecorded(&facts, &own)))
250}
251
252/// Find release-kit's own landing at `target`, reading and never
253/// writing.
254///
255/// A marked region carries release-kit's own name, so its presence
256/// alone is the evidence. A whole file is release-kit's only where it
257/// holds exactly the bytes this binary renders for it here, because a
258/// file at the same path from another tool is a migration's business.
259/// Where this binary cannot resolve the parameters a render needs, the
260/// rendered half reads as empty rather than failing: the regions still
261/// answer.
262///
263/// # Errors
264///
265/// [`RkError::Io`] for a read that fails for a reason other than
266/// absence.
267pub fn own_landing(
268    target: &Utf8Path,
269    config: Option<&crate::config::Config>,
270) -> Result<OwnLanding, RkError> {
271    let mut own = OwnLanding::default();
272    for destination in landing::BLOCK_DESTINATIONS {
273        if landing::read_recorded(target, destination)?.is_some() {
274            own.regions.push(destination.to_owned());
275        }
276    }
277    let params = landing::Params::resolve(
278        target,
279        &landing::Inputs::default(),
280        config,
281        None,
282        landing::Purpose::Preview,
283    );
284    let Ok(params) = params else {
285        return Ok(own);
286    };
287    let Ok(evidence) = crate::projection::TargetEvidence::gather(target, None) else {
288        return Ok(own);
289    };
290    let Ok(projection) =
291        crate::projection::Projection::compute(&crate::projection::ProjectionInput {
292            params,
293            evidence,
294        })
295    else {
296        return Ok(own);
297    };
298    for candidate in &projection.candidates {
299        if candidate.kind != landing::Kind::Rendered || candidate.region.is_some() {
300            continue;
301        }
302        if landing::read_recorded(target, &candidate.destination)?.as_deref()
303            == Some(candidate.bytes.as_slice())
304        {
305            own.rendered.push(candidate.destination.clone());
306        }
307    }
308    own.rendered.sort();
309    Ok(own)
310}
311
312/// Whether `target` sits in a non-bare Git work tree.
313///
314/// A directory Git reports as no repository, and a bare repository,
315/// answer `false`: both are facts the setup skill stops on, because the
316/// method lands files a work tree commits.
317///
318/// # Errors
319///
320/// [`RkError::Subprocess`] where git does not run or refuses for a
321/// reason other than the target not being a repository.
322pub fn git_work_tree(target: &Utf8Path) -> Result<bool, RkError> {
323    match git_lines(target, &["rev-parse", "--is-inside-work-tree"]) {
324        Ok(lines) => Ok(lines.first().is_some_and(|line| line == "true")),
325        Err(GitFailure::NotARepository) => Ok(false),
326        Err(GitFailure::Other(error)) => Err(error),
327    }
328}
329
330/// The repository's facts alone, with no record read: what the planner
331/// gathers beside its own read of the record.
332#[derive(Debug)]
333pub struct Facts {
334    /// The technologies the version files name, zero or many.
335    pub technologies: Vec<&'static str>,
336    /// The forge the origin remote maps to, where one is recognized.
337    pub forge: Option<&'static str>,
338    /// The project path from the origin remote, where one exists.
339    pub repo: Option<String>,
340    /// Release-mechanism files of other tools found at the target.
341    pub release_markers: Vec<String>,
342    /// Landable destinations already present.
343    pub collisions: Vec<String>,
344    /// Whether the target is a git repository.
345    pub git: bool,
346    /// How many tags the repository holds.
347    pub tags: usize,
348    /// Long-lived branches found besides the trunk.
349    pub long_lived_branches: Vec<String>,
350}
351
352/// Gather the evidence at `target`, reading and never writing.
353///
354/// # Errors
355///
356/// Returns the record's own failure taxonomy for an unreadable or unknown
357/// landing record — a broken record must not silently classify —
358/// [`RkError::Io`] for a disk read that fails for a reason other than
359/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
360/// for a repository, because an observation that cannot be read is not a
361/// pass and must never read as an absent release history.
362pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
363    let record = manifest::load(target)?;
364    let landing = Landing {
365        recorded: record.is_some(),
366        rk_version: record.map(|manifest| manifest.rk_version),
367    };
368    let facts = gather_facts(target)?;
369    Ok(Evidence {
370        landing,
371        technologies: facts.technologies,
372        forge: facts.forge,
373        repo: facts.repo,
374        release_markers: facts.release_markers,
375        collisions: facts.collisions,
376        git: facts.git,
377        tags: facts.tags,
378        long_lived_branches: facts.long_lived_branches,
379    })
380}
381
382/// Gather the repository's facts at `target`, the record aside.
383///
384/// # Errors
385///
386/// [`RkError::Io`] for a disk read that fails for a reason other than
387/// absence, and [`RkError::Subprocess`] where git runs but cannot answer
388/// for a repository.
389pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
390    let detected = crate::detect::detect(target.as_std_path());
391    let mut release_markers: Vec<String> = RELEASE_MARKERS
392        .iter()
393        .filter(|marker| target.join(marker).is_file())
394        .map(|marker| (*marker).to_owned())
395        .collect();
396    if package_json_names_a_release(target)? {
397        release_markers.push("package.json".to_owned());
398    }
399    release_markers.sort();
400    let mut collisions = Vec::new();
401    for destination in landing::destinations() {
402        if landing::read_recorded(target, destination)?.is_some() {
403            collisions.push(destination.to_owned());
404        }
405    }
406    collisions.sort();
407    let (git, tags, long_lived_branches) = git_evidence(target)?;
408    Ok(Facts {
409        technologies: crate::detect::technologies_of(target.as_std_path()),
410        forge: detected.forge.map(crate::detect::Forge::as_str),
411        repo: detected.repo,
412        release_markers,
413        collisions,
414        git,
415        tags,
416        long_lived_branches,
417    })
418}
419
420/// Whether `package.json` carries the top-level `release` key
421/// semantic-release reads its configuration from. An ordinary Node
422/// project's manifest is not a release marker; only that key is.
423fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
424    let path = target.join("package.json");
425    let bytes = match std::fs::read(&path) {
426        Ok(bytes) => bytes,
427        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
428        Err(e) => return Err(RkError::Io(e)),
429    };
430    // A manifest that does not parse is not evidence of a release
431    // mechanism; the tool that would read it fails on it too.
432    Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
433        .ok()
434        .and_then(|value| value.get("release").map(|_| ()))
435        .is_some())
436}
437
438/// The git-borne evidence: whether the target is a repository, how many
439/// tags it holds, and which long-lived branches stand beside the trunk.
440///
441/// A directory git positively reports as no repository answers `false`
442/// and empty — an observation, never a failure, because a plain
443/// directory is a legitimate greenfield. Every other refusal — a
444/// corrupt repository, an ownership refusal, a git that does not run —
445/// is an error, because an unreadable history must not read as none.
446fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
447    let trunk = crate::config::trunk_of(target.as_std_path())?;
448    let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
449    let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
450    match git_lines(target, &["rev-parse", "--git-dir"]) {
451        Ok(_) => {}
452        Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
453        Err(GitFailure::Other(error)) => return Err(error),
454    }
455    let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
456    let refs = git_lines(
457        target,
458        &[
459            "for-each-ref",
460            "--format=%(refname)",
461            "refs/heads",
462            "refs/remotes",
463        ],
464    )
465    .map_err(GitFailure::into_error)?;
466    Ok((
467        true,
468        tags.len(),
469        long_lived_among(&refs, trunk, line_prefix),
470    ))
471}
472
473/// The long-lived branch names among `refs`, given as full ref names.
474///
475/// `refs/heads/<name>` keeps its whole name, `refs/remotes/<remote>/<name>`
476/// drops the remote alone, and a remote `HEAD` pointer is skipped. A
477/// name is long-lived when it is a catalog entry other than the trunk or
478/// carries the release-line prefix; each appears once, sorted.
479#[must_use]
480pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
481    let mut names = std::collections::BTreeSet::new();
482    for reference in refs {
483        let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
484            local
485        } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
486            match remote.split_once('/') {
487                Some((_, "HEAD")) | None => continue,
488                Some((_, name)) => name,
489            }
490        } else {
491            continue;
492        };
493        let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
494        if catalogued || name.starts_with(line_prefix) {
495            names.insert(name.to_owned());
496        }
497    }
498    names.into_iter().collect()
499}
500
501/// Why one git call gave no answer.
502enum GitFailure {
503    /// Git ran and said the target is not a repository.
504    NotARepository,
505    /// Git did not run, or ran and refused for another reason.
506    Other(RkError),
507}
508
509impl GitFailure {
510    /// After the target is known to be a repository, every failure is
511    /// the same kind: a history that cannot be read.
512    fn into_error(self) -> RkError {
513        match self {
514            Self::NotARepository => RkError::subprocess(
515                Diagnostic::new(
516                    Reason::SubprocessFailed,
517                    "git stopped answering for a repository it had just recognized",
518                )
519                .expected("a readable repository"),
520            ),
521            Self::Other(error) => error,
522        }
523    }
524}
525
526/// The non-empty stdout lines of one git call.
527///
528/// The call answers for the `-C` target alone: the variables a running
529/// hook exports are scrubbed, so an inherited `GIT_DIR` cannot redirect
530/// the probe at another repository, and the locale is pinned to `C`, so
531/// the one diagnostic this module reads — git's own "not a git
532/// repository" — arrives untranslated.
533fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
534    let mut command = Command::new(crate::probes::git_bin());
535    for var in crate::maintenance::GIT_HOOK_VARS {
536        command.env_remove(var);
537    }
538    let out = command
539        .env("LC_ALL", "C")
540        .env_remove("LANGUAGE")
541        .arg("-C")
542        .arg(target)
543        .args(args)
544        .output()
545        .map_err(|error| {
546            GitFailure::Other(RkError::subprocess(
547                Diagnostic::new(
548                    Reason::SubprocessSpawn,
549                    format!("git could not be spawned: {error}"),
550                )
551                .expected("git on PATH, or RK_GIT_BIN naming it"),
552            ))
553        })?;
554    if !out.status.success() {
555        let stderr = String::from_utf8_lossy(&out.stderr);
556        if stderr.contains("not a git repository") {
557            return Err(GitFailure::NotARepository);
558        }
559        return Err(GitFailure::Other(RkError::subprocess(
560            Diagnostic::new(
561                Reason::SubprocessFailed,
562                format!(
563                    "git {} failed at {target}: {}",
564                    args.join(" "),
565                    stderr.trim()
566                ),
567            )
568            .expected("git answering for the target, or a target that is not a repository")
569            .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
570        )));
571    }
572    Ok(String::from_utf8_lossy(&out.stdout)
573        .lines()
574        .map(str::trim)
575        .filter(|line| !line.is_empty())
576        .map(str::to_owned)
577        .collect())
578}
579
580#[cfg(test)]
581mod tests {
582    use super::{Classification, Evidence, Landing, classify, long_lived_among};
583
584    /// The trunk is never evidence against itself; only the remote
585    /// segment is stripped, so a topic branch whose last segment is a
586    /// catalog name stays a topic branch; a release line is recognized
587    /// by its prefix; a remote HEAD pointer is skipped; each name once.
588    #[test]
589    fn long_lived_branches_are_read_from_the_full_ref_names() {
590        let refs: Vec<String> = [
591            "refs/heads/master",
592            "refs/remotes/origin/master",
593            "refs/remotes/origin/HEAD",
594            "refs/heads/develop",
595            "refs/remotes/origin/develop",
596            "refs/heads/feat/x",
597            "refs/heads/feat/develop",
598            "refs/remotes/origin/main",
599            "refs/heads/release/1.2",
600            "refs/remotes/upstream/release/1.2",
601        ]
602        .iter()
603        .map(|name| (*name).to_owned())
604        .collect();
605        assert_eq!(
606            long_lived_among(&refs, "master", "release/"),
607            vec!["develop", "main", "release/1.2"]
608        );
609        assert!(
610            long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
611        );
612        assert!(
613            long_lived_among(
614                &["refs/heads/feat/develop".to_owned()],
615                "master",
616                "release/"
617            )
618            .is_empty()
619        );
620    }
621
622    #[test]
623    fn the_verdict_words_are_the_wire_form() {
624        for (classification, word) in [
625            (Classification::Greenfield, "greenfield"),
626            (Classification::Brownfield, "brownfield"),
627            (Classification::NeedsDecision, "needs-decision"),
628        ] {
629            assert_eq!(classification.as_str(), word);
630            assert_eq!(
631                serde_json::to_string(&classification).expect("serializes"),
632                format!("\"{word}\"")
633            );
634        }
635    }
636
637    fn evidence() -> Evidence {
638        Evidence {
639            landing: Landing {
640                recorded: false,
641                rk_version: None,
642            },
643            technologies: Vec::new(),
644            forge: None,
645            repo: None,
646            release_markers: Vec::new(),
647            collisions: Vec::new(),
648            git: true,
649            tags: 0,
650            long_lived_branches: Vec::new(),
651        }
652    }
653
654    #[test]
655    fn nothing_is_greenfield() {
656        assert_eq!(classify(&evidence()), Classification::Greenfield);
657    }
658
659    #[test]
660    fn a_release_marker_or_a_collision_is_brownfield() {
661        let with_marker = Evidence {
662            release_markers: vec!["CHANGELOG.md".into()],
663            ..evidence()
664        };
665        assert_eq!(classify(&with_marker), Classification::Brownfield);
666        let with_collision = Evidence {
667            collisions: vec!["release-plz.toml".into()],
668            ..evidence()
669        };
670        assert_eq!(classify(&with_collision), Classification::Brownfield);
671    }
672
673    /// A mechanism outranks unexplained activity: tags beside a marker
674    /// are a history the mechanism made, not a question.
675    #[test]
676    fn a_mechanism_beside_activity_is_still_brownfield() {
677        let both = Evidence {
678            release_markers: vec!["CHANGELOG.md".into()],
679            tags: 7,
680            long_lived_branches: vec!["develop".into()],
681            ..evidence()
682        };
683        assert_eq!(classify(&both), Classification::Brownfield);
684    }
685
686    #[test]
687    fn activity_with_no_mechanism_needs_a_decision() {
688        let tagged = Evidence {
689            tags: 1,
690            ..evidence()
691        };
692        assert_eq!(classify(&tagged), Classification::NeedsDecision);
693        let branched = Evidence {
694            long_lived_branches: vec!["develop".into()],
695            ..evidence()
696        };
697        assert_eq!(classify(&branched), Classification::NeedsDecision);
698    }
699}