use std::process::Command;
use camino::Utf8Path;
use serde::Serialize;
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::landing::{self, manifest};
use crate::setup::context::TRUNK_BRANCH;
const RELEASE_LINE_PREFIX: &str = "release/";
pub const RELEASE_MARKERS: [&str; 23] = [
".release-plz.toml",
".releaserc",
".releaserc.cjs",
".releaserc.js",
".releaserc.json",
".releaserc.mjs",
".releaserc.yaml",
".releaserc.yml",
"release.config.cjs",
"release.config.js",
"release.config.mjs",
".config/goreleaser.yaml",
".config/goreleaser.yml",
".goreleaser.yaml",
".goreleaser.yml",
"goreleaser.yaml",
"goreleaser.yml",
".github/workflows/publish.yml",
".github/workflows/publish.yaml",
".github/workflows/release.yaml",
".github/workflows/release-drafter.yml",
"CHANGELOG.md",
"CHANGES.md",
];
pub const LONG_LIVED_BRANCHES: [&str; 11] = [
"master",
"main",
"trunk",
"develop",
"development",
"dev",
"staging",
"next",
"release",
"production",
"prod",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Classification {
Greenfield,
Brownfield,
NeedsDecision,
}
impl Classification {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Greenfield => "greenfield",
Self::Brownfield => "brownfield",
Self::NeedsDecision => "needs-decision",
}
}
}
#[derive(Debug, Serialize)]
pub struct Landing {
pub recorded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rk_version: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct Evidence {
pub landing: Landing,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub forge: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
pub release_markers: Vec<String>,
pub collisions: Vec<String>,
pub git: bool,
pub tags: usize,
pub long_lived_branches: Vec<String>,
}
#[must_use]
pub fn classify(evidence: &Evidence) -> Classification {
if !evidence.release_markers.is_empty() || !evidence.collisions.is_empty() {
return Classification::Brownfield;
}
if evidence.tags > 0 || !evidence.long_lived_branches.is_empty() {
return Classification::NeedsDecision;
}
Classification::Greenfield
}
pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
let record = manifest::load(target)?;
let landing = Landing {
recorded: record.is_some(),
rk_version: record.map(|manifest| manifest.rk_version),
};
let detected = crate::detect::detect(target.as_std_path());
let mut release_markers: Vec<String> = RELEASE_MARKERS
.iter()
.filter(|marker| target.join(marker).is_file())
.map(|marker| (*marker).to_owned())
.collect();
if package_json_names_a_release(target)? {
release_markers.push("package.json".to_owned());
}
release_markers.sort();
let mut collisions = Vec::new();
for destination in landing::destinations() {
if landing::read_recorded(target, destination)?.is_some() {
collisions.push(destination.to_owned());
}
}
collisions.sort();
let (git, tags, long_lived_branches) = git_evidence(target)?;
Ok(Evidence {
landing,
tech: crate::detect::tech_of(target.as_std_path()),
forge: detected.forge.map(crate::detect::Forge::as_str),
repo: detected.repo,
release_markers,
collisions,
git,
tags,
long_lived_branches,
})
}
fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
let path = target.join("package.json");
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(RkError::Io(e)),
};
Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
.ok()
.and_then(|value| value.get("release").map(|_| ()))
.is_some())
}
fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
match git_lines(target, &["rev-parse", "--git-dir"]) {
Ok(_) => {}
Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
Err(GitFailure::Other(error)) => return Err(error),
}
let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
let refs = git_lines(
target,
&[
"for-each-ref",
"--format=%(refname)",
"refs/heads",
"refs/remotes",
],
)
.map_err(GitFailure::into_error)?;
Ok((true, tags.len(), long_lived_among(&refs)))
}
#[must_use]
pub fn long_lived_among(refs: &[String]) -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
for reference in refs {
let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
local
} else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
match remote.split_once('/') {
Some((_, "HEAD")) | None => continue,
Some((_, name)) => name,
}
} else {
continue;
};
let catalogued = name != TRUNK_BRANCH && LONG_LIVED_BRANCHES.contains(&name);
if catalogued || name.starts_with(RELEASE_LINE_PREFIX) {
names.insert(name.to_owned());
}
}
names.into_iter().collect()
}
enum GitFailure {
NotARepository,
Other(RkError),
}
impl GitFailure {
fn into_error(self) -> RkError {
match self {
Self::NotARepository => RkError::subprocess(
Diagnostic::new(
Reason::SubprocessFailed,
"git stopped answering for a repository it had just recognized",
)
.expected("a readable repository"),
),
Self::Other(error) => error,
}
}
}
fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
let mut command = Command::new(crate::probes::git_bin());
for var in crate::maintenance::GIT_HOOK_VARS {
command.env_remove(var);
}
let out = command
.env("LC_ALL", "C")
.env_remove("LANGUAGE")
.arg("-C")
.arg(target)
.args(args)
.output()
.map_err(|error| {
GitFailure::Other(RkError::subprocess(
Diagnostic::new(
Reason::SubprocessSpawn,
format!("git could not be spawned: {error}"),
)
.expected("git on PATH, or RK_GIT_BIN naming it"),
))
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("not a git repository") {
return Err(GitFailure::NotARepository);
}
return Err(GitFailure::Other(RkError::subprocess(
Diagnostic::new(
Reason::SubprocessFailed,
format!(
"git {} failed at {target}: {}",
args.join(" "),
stderr.trim()
),
)
.expected("git answering for the target, or a target that is not a repository")
.action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
)));
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_owned)
.collect())
}
#[cfg(test)]
mod tests {
use super::{Classification, Evidence, Landing, classify, long_lived_among};
fn evidence() -> Evidence {
Evidence {
landing: Landing {
recorded: false,
rk_version: None,
},
tech: Some("rust"),
forge: Some("github"),
repo: Some("acme/widget".into()),
release_markers: Vec::new(),
collisions: Vec::new(),
git: true,
tags: 0,
long_lived_branches: Vec::new(),
}
}
#[test]
fn nothing_is_greenfield() {
assert_eq!(classify(&evidence()), Classification::Greenfield);
}
#[test]
fn a_release_marker_or_a_collision_is_brownfield() {
let mut with_marker = evidence();
with_marker.release_markers.push("CHANGELOG.md".into());
assert_eq!(classify(&with_marker), Classification::Brownfield);
let mut with_collision = evidence();
with_collision.collisions.push("release-plz.toml".into());
assert_eq!(classify(&with_collision), Classification::Brownfield);
}
#[test]
fn a_mechanism_beside_activity_is_still_brownfield() {
let mut both = evidence();
both.release_markers.push("CHANGELOG.md".into());
both.tags = 7;
both.long_lived_branches.push("develop".into());
assert_eq!(classify(&both), Classification::Brownfield);
}
#[test]
fn activity_with_no_mechanism_needs_a_decision() {
let mut tagged = evidence();
tagged.tags = 1;
assert_eq!(classify(&tagged), Classification::NeedsDecision);
let mut branched = evidence();
branched.long_lived_branches.push("develop".into());
assert_eq!(classify(&branched), Classification::NeedsDecision);
}
#[test]
fn long_lived_branches_are_read_from_the_full_ref_names() {
let refs: Vec<String> = [
"refs/heads/master",
"refs/remotes/origin/master",
"refs/remotes/origin/HEAD",
"refs/heads/develop",
"refs/remotes/origin/develop",
"refs/heads/feat/x",
"refs/heads/feat/develop",
"refs/remotes/origin/main",
"refs/heads/release/1.2",
"refs/remotes/upstream/release/1.2",
]
.iter()
.map(|name| (*name).to_owned())
.collect();
assert_eq!(
long_lived_among(&refs),
vec!["develop", "main", "release/1.2"]
);
assert!(long_lived_among(&["refs/heads/master".to_owned()]).is_empty());
assert!(long_lived_among(&["refs/heads/feat/develop".to_owned()]).is_empty());
}
#[test]
fn the_verdict_words_are_the_wire_form() {
assert_eq!(Classification::Greenfield.as_str(), "greenfield");
assert_eq!(Classification::Brownfield.as_str(), "brownfield");
assert_eq!(Classification::NeedsDecision.as_str(), "needs-decision");
}
}