1use 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
23pub 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
61pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "kebab-case")]
84pub enum Classification {
85 Greenfield,
87 Brownfield,
89 NeedsDecision,
91}
92
93impl Classification {
94 #[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#[derive(Debug, Serialize)]
108pub struct Landing {
109 pub recorded: bool,
111 #[serde(skip_serializing_if = "Option::is_none")]
113 pub rk_version: Option<String>,
114}
115
116#[derive(Debug, Serialize)]
118pub struct Evidence {
119 pub landing: Landing,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub tech: Option<&'static str>,
124 #[serde(skip_serializing_if = "Option::is_none")]
126 pub forge: Option<&'static str>,
127 #[serde(skip_serializing_if = "Option::is_none")]
129 pub repo: Option<String>,
130 pub release_markers: Vec<String>,
132 pub collisions: Vec<String>,
135 pub git: bool,
137 pub tags: usize,
139 pub long_lived_branches: Vec<String>,
141}
142
143#[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
156pub 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
203fn 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 Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
216 .ok()
217 .and_then(|value| value.get("release").map(|_| ()))
218 .is_some())
219}
220
221fn 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#[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
284enum GitFailure {
286 NotARepository,
288 Other(RkError),
290}
291
292impl GitFailure {
293 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
309fn 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 #[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 #[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}