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)]
84#[serde(rename_all = "kebab-case")]
85pub enum Classification {
86 Greenfield,
88 Brownfield,
90 NeedsDecision,
92}
93
94impl Classification {
95 #[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#[derive(Debug, Serialize)]
109pub struct Landing {
110 pub recorded: bool,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub rk_version: Option<String>,
115}
116
117#[derive(Debug, Serialize)]
119pub struct Evidence {
120 pub landing: Landing,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub tech: Option<&'static str>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub forge: Option<&'static str>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub repo: Option<String>,
131 pub release_markers: Vec<String>,
133 pub collisions: Vec<String>,
136 pub git: bool,
138 pub tags: usize,
140 pub long_lived_branches: Vec<String>,
142}
143
144#[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#[derive(Debug)]
160pub struct Facts {
161 pub tech: Option<&'static str>,
163 pub forge: Option<&'static str>,
165 pub repo: Option<String>,
167 pub release_markers: Vec<String>,
169 pub collisions: Vec<String>,
171 pub git: bool,
173 pub tags: usize,
175 pub long_lived_branches: Vec<String>,
177}
178
179pub 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
209pub 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
247fn 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 Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
260 .ok()
261 .and_then(|value| value.get("release").map(|_| ()))
262 .is_some())
263}
264
265fn 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#[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
328enum GitFailure {
330 NotARepository,
332 Other(RkError),
334}
335
336impl GitFailure {
337 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
353fn 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 #[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 #[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}