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