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 pub technologies: Vec<&'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 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
155const 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#[derive(Debug, Default, Serialize)]
175pub struct OwnLanding {
176 pub regions: Vec<String>,
178 pub rendered: Vec<String>,
181}
182
183impl OwnLanding {
184 #[must_use]
186 pub const fn present(&self) -> bool {
187 !self.regions.is_empty() || !self.rendered.is_empty()
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Unrecorded {
195 Adopt,
198 Assessed(Classification),
200}
201
202#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum Standing {
226 Migrating,
229 Unrecorded(Unrecorded),
231}
232
233pub 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
252pub 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
312pub 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#[derive(Debug)]
333pub struct Facts {
334 pub technologies: Vec<&'static str>,
336 pub forge: Option<&'static str>,
338 pub repo: Option<String>,
340 pub release_markers: Vec<String>,
342 pub collisions: Vec<String>,
344 pub git: bool,
346 pub tags: usize,
348 pub long_lived_branches: Vec<String>,
350}
351
352pub 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
382pub 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
420fn 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 Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
433 .ok()
434 .and_then(|value| value.get("release").map(|_| ()))
435 .is_some())
436}
437
438fn 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#[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
501enum GitFailure {
503 NotARepository,
505 Other(RkError),
507}
508
509impl GitFailure {
510 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
526fn 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 #[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 #[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}