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; 7] = [
39 ".release-plz.toml",
40 ".github/workflows/publish.yml",
41 ".github/workflows/publish.yaml",
42 ".github/workflows/release.yaml",
43 ".github/workflows/release-drafter.yml",
44 "CHANGELOG.md",
45 "CHANGES.md",
46];
47
48pub fn marker_names() -> impl Iterator<Item = &'static str> {
52 use crate::migrate::inventory::{GORELEASER, SEMANTIC_RELEASE, release_configs};
53 RELEASE_MARKERS
54 .into_iter()
55 .chain(release_configs(SEMANTIC_RELEASE))
56 .chain(release_configs(GORELEASER))
57}
58
59pub const LONG_LIVED_BRANCHES: [&str; 11] = [
66 "master",
67 "main",
68 "trunk",
69 "develop",
70 "development",
71 "dev",
72 "staging",
73 "next",
74 "release",
75 "production",
76 "prod",
77];
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum Classification {
84 Greenfield,
86 Brownfield,
88 NeedsDecision,
90}
91
92impl Classification {
93 #[must_use]
95 pub const fn as_str(self) -> &'static str {
96 match self {
97 Self::Greenfield => "greenfield",
98 Self::Brownfield => "brownfield",
99 Self::NeedsDecision => "needs-decision",
100 }
101 }
102}
103
104#[derive(Debug, Serialize)]
107pub struct Landing {
108 pub recorded: bool,
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub rk_version: Option<String>,
113}
114
115#[derive(Debug, Serialize)]
117pub struct Evidence {
118 pub landing: Landing,
120 pub technologies: Vec<&'static str>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub forge: Option<&'static str>,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub repo: Option<String>,
128 pub release_markers: Vec<String>,
130 pub collisions: Vec<String>,
133 pub git: bool,
135 pub tags: usize,
137 pub long_lived_branches: Vec<String>,
139}
140
141#[must_use]
144pub const fn classify(evidence: &Evidence) -> Classification {
145 verdict(
146 evidence.release_markers.as_slice(),
147 evidence.collisions.as_slice(),
148 evidence.tags,
149 evidence.long_lived_branches.as_slice(),
150 )
151}
152
153const fn verdict(
156 release_markers: &[String],
157 collisions: &[String],
158 tags: usize,
159 long_lived_branches: &[String],
160) -> Classification {
161 if !release_markers.is_empty() || !collisions.is_empty() {
162 return Classification::Brownfield;
163 }
164 if tags > 0 || !long_lived_branches.is_empty() {
165 return Classification::NeedsDecision;
166 }
167 Classification::Greenfield
168}
169
170#[derive(Debug, Default, Serialize)]
173pub struct OwnLanding {
174 pub regions: Vec<String>,
176 pub rendered: Vec<String>,
179}
180
181impl OwnLanding {
182 #[must_use]
184 pub const fn present(&self) -> bool {
185 !self.regions.is_empty() || !self.rendered.is_empty()
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum Unrecorded {
193 Adopt,
196 Assessed(Classification),
198}
199
200#[must_use]
208pub const fn classify_unrecorded(facts: &Facts, own: &OwnLanding) -> Unrecorded {
209 if own.present() {
210 return Unrecorded::Adopt;
211 }
212 Unrecorded::Assessed(verdict(
213 facts.release_markers.as_slice(),
214 facts.collisions.as_slice(),
215 facts.tags,
216 facts.long_lived_branches.as_slice(),
217 ))
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum Standing {
224 Migrating,
227 Unrecorded(Unrecorded),
229}
230
231pub fn standing(
239 target: &Utf8Path,
240 config: Option<&crate::config::Config>,
241) -> Result<Standing, RkError> {
242 if config.is_some_and(|config| config.migration.is_some()) {
243 return Ok(Standing::Migrating);
244 }
245 let facts = gather_facts(target)?;
246 let own = own_landing(target, config)?;
247 Ok(Standing::Unrecorded(classify_unrecorded(&facts, &own)))
248}
249
250pub fn own_landing(
266 target: &Utf8Path,
267 config: Option<&crate::config::Config>,
268) -> Result<OwnLanding, RkError> {
269 let mut own = OwnLanding::default();
270 for destination in landing::BLOCK_DESTINATIONS {
271 if landing::read_recorded(target, destination)?.is_some() {
272 own.regions.push(destination.to_owned());
273 }
274 }
275 let params = landing::Params::resolve(
276 target,
277 &landing::Inputs::default(),
278 config,
279 None,
280 landing::Purpose::Preview,
281 );
282 let Ok(params) = params else {
283 return Ok(own);
284 };
285 let Ok(evidence) = crate::projection::TargetEvidence::gather(target, None) else {
286 return Ok(own);
287 };
288 let Ok(projection) =
289 crate::projection::Projection::compute(&crate::projection::ProjectionInput {
290 params,
291 evidence,
292 })
293 else {
294 return Ok(own);
295 };
296 for candidate in &projection.candidates {
297 if candidate.kind != landing::Kind::Rendered
300 || candidate.region.is_some()
301 || crate::projection::skill_folder(&candidate.destination).is_some()
302 {
303 continue;
304 }
305 if landing::read_recorded(target, &candidate.destination)?.as_deref()
306 == Some(candidate.bytes.as_slice())
307 {
308 own.rendered.push(candidate.destination.clone());
309 }
310 }
311 own.rendered.sort();
312 Ok(own)
313}
314
315pub fn git_work_tree(target: &Utf8Path) -> Result<bool, RkError> {
326 match git_lines(target, &["rev-parse", "--is-inside-work-tree"]) {
327 Ok(lines) => Ok(lines.first().is_some_and(|line| line == "true")),
328 Err(GitFailure::NotARepository) => Ok(false),
329 Err(GitFailure::Other(error)) => Err(error),
330 }
331}
332
333#[derive(Debug)]
336pub struct Facts {
337 pub technologies: Vec<&'static str>,
339 pub forge: Option<&'static str>,
341 pub repo: Option<String>,
343 pub release_markers: Vec<String>,
345 pub collisions: Vec<String>,
347 pub git: bool,
349 pub tags: usize,
351 pub long_lived_branches: Vec<String>,
353}
354
355pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
366 let record = manifest::load(target)?;
367 let landing = Landing {
368 recorded: record.is_some(),
369 rk_version: record.map(|manifest| manifest.rk_version),
370 };
371 let facts = gather_facts(target)?;
372 Ok(Evidence {
373 landing,
374 technologies: facts.technologies,
375 forge: facts.forge,
376 repo: facts.repo,
377 release_markers: facts.release_markers,
378 collisions: facts.collisions,
379 git: facts.git,
380 tags: facts.tags,
381 long_lived_branches: facts.long_lived_branches,
382 })
383}
384
385pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
393 let detected = crate::detect::detect(target.as_std_path());
394 let mut release_markers: Vec<String> = marker_names()
395 .filter(|marker| target.join(marker).is_file())
396 .map(str::to_owned)
397 .collect();
398 if package_json_names_a_release(target)? {
399 release_markers.push("package.json".to_owned());
400 }
401 release_markers.sort();
402 let mut collisions = Vec::new();
403 for destination in landing::destinations() {
404 if landing::read_recorded(target, destination)?.is_some() {
405 collisions.push(destination.to_owned());
406 }
407 }
408 collisions.sort();
409 let (git, tags, long_lived_branches) = git_evidence(target)?;
410 Ok(Facts {
411 technologies: crate::detect::technologies_of(target.as_std_path()),
412 forge: detected.forge.map(crate::detect::Forge::as_str),
413 repo: detected.repo,
414 release_markers,
415 collisions,
416 git,
417 tags,
418 long_lived_branches,
419 })
420}
421
422fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
426 let path = target.join("package.json");
427 let bytes = match std::fs::read(&path) {
428 Ok(bytes) => bytes,
429 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
430 Err(e) => return Err(RkError::Io(e)),
431 };
432 Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
435 .ok()
436 .and_then(|value| value.get("release").map(|_| ()))
437 .is_some())
438}
439
440fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
449 let trunk = crate::config::trunk_of(target.as_std_path())?;
450 let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
451 let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
452 match git_lines(target, &["rev-parse", "--git-dir"]) {
453 Ok(_) => {}
454 Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
455 Err(GitFailure::Other(error)) => return Err(error),
456 }
457 let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
458 let refs = git_lines(
459 target,
460 &[
461 "for-each-ref",
462 "--format=%(refname)",
463 "refs/heads",
464 "refs/remotes",
465 ],
466 )
467 .map_err(GitFailure::into_error)?;
468 Ok((
469 true,
470 tags.len(),
471 long_lived_among(&refs, trunk, line_prefix),
472 ))
473}
474
475#[must_use]
482pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
483 let mut names = std::collections::BTreeSet::new();
484 for reference in refs {
485 let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
486 local
487 } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
488 match remote.split_once('/') {
489 Some((_, "HEAD")) | None => continue,
490 Some((_, name)) => name,
491 }
492 } else {
493 continue;
494 };
495 let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
496 if catalogued || name.starts_with(line_prefix) {
497 names.insert(name.to_owned());
498 }
499 }
500 names.into_iter().collect()
501}
502
503enum GitFailure {
505 NotARepository,
507 Other(RkError),
509}
510
511impl GitFailure {
512 fn into_error(self) -> RkError {
515 match self {
516 Self::NotARepository => RkError::subprocess(
517 Diagnostic::new(
518 Reason::SubprocessFailed,
519 "git stopped answering for a repository it had just recognized",
520 )
521 .expected("a readable repository"),
522 ),
523 Self::Other(error) => error,
524 }
525 }
526}
527
528fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
536 let mut command = Command::new(crate::probes::git_bin());
537 for var in crate::maintenance::GIT_HOOK_VARS {
538 command.env_remove(var);
539 }
540 let out = command
541 .env("LC_ALL", "C")
542 .env_remove("LANGUAGE")
543 .arg("-C")
544 .arg(target)
545 .args(args)
546 .output()
547 .map_err(|error| {
548 GitFailure::Other(RkError::subprocess(
549 Diagnostic::new(
550 Reason::SubprocessSpawn,
551 format!("git could not be spawned: {error}"),
552 )
553 .expected("git on PATH, or RK_GIT_BIN naming it"),
554 ))
555 })?;
556 if !out.status.success() {
557 let stderr = String::from_utf8_lossy(&out.stderr);
558 if stderr.contains("not a git repository") {
559 return Err(GitFailure::NotARepository);
560 }
561 return Err(GitFailure::Other(RkError::subprocess(
562 Diagnostic::new(
563 Reason::SubprocessFailed,
564 format!(
565 "git {} failed at {target}: {}",
566 args.join(" "),
567 stderr.trim()
568 ),
569 )
570 .expected("git answering for the target, or a target that is not a repository")
571 .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
572 )));
573 }
574 Ok(String::from_utf8_lossy(&out.stdout)
575 .lines()
576 .map(str::trim)
577 .filter(|line| !line.is_empty())
578 .map(str::to_owned)
579 .collect())
580}
581
582#[cfg(test)]
583mod tests {
584 use super::{
585 Classification, Evidence, Landing, RELEASE_MARKERS, classify, long_lived_among,
586 marker_names,
587 };
588 use crate::migrate::inventory::{GORELEASER, Kind, PREDECESSORS, SEMANTIC_RELEASE};
589
590 fn semantic_release_shaped(name: &str) -> bool {
594 name == ".releaserc"
595 || name.starts_with(".releaserc.")
596 || name.starts_with("release.config.")
597 }
598
599 #[test]
603 fn assess_and_the_inventory_name_the_same_semantic_release_files() {
604 let inventory: std::collections::BTreeSet<&str> = PREDECESSORS
605 .iter()
606 .filter(|(path, tool, kind)| {
607 (*tool == SEMANTIC_RELEASE && *kind == Kind::ReleaseConfig)
608 || semantic_release_shaped(path)
609 })
610 .map(|(path, _, _)| *path)
611 .collect();
612 let assess: std::collections::BTreeSet<&str> = marker_names()
613 .filter(|name| semantic_release_shaped(name))
614 .collect();
615 assert_eq!(assess, inventory);
616 assert!(
617 !RELEASE_MARKERS
618 .iter()
619 .any(|name| semantic_release_shaped(name)),
620 "the assess list holds a second copy of a semantic-release name"
621 );
622 for name in [".releaserc.ts", "release.config.ts"] {
623 assert!(assess.contains(name), "{name}");
624 }
625 }
626
627 #[test]
631 fn assess_and_the_inventory_name_the_same_goreleaser_files() {
632 let shaped = |name: &str| name.contains("goreleaser.y");
633 let inventory: std::collections::BTreeSet<&str> = PREDECESSORS
634 .iter()
635 .filter(|(path, tool, kind)| {
636 (*tool == GORELEASER && *kind == Kind::ReleaseConfig) || shaped(path)
637 })
638 .filter(|(path, _, _)| !path.starts_with(".github/"))
639 .map(|(path, _, _)| *path)
640 .collect();
641 let assess: std::collections::BTreeSet<&str> =
642 marker_names().filter(|name| shaped(name)).collect();
643 assert_eq!(assess, inventory);
644 assert_eq!(assess.len(), 6, "{assess:?}");
645 assert!(
646 !RELEASE_MARKERS.iter().any(|name| shaped(name)),
647 "the assess list holds a second copy of a GoReleaser name"
648 );
649 }
650
651 #[test]
656 fn long_lived_branches_are_read_from_the_full_ref_names() {
657 let refs: Vec<String> = [
658 "refs/heads/master",
659 "refs/remotes/origin/master",
660 "refs/remotes/origin/HEAD",
661 "refs/heads/develop",
662 "refs/remotes/origin/develop",
663 "refs/heads/feat/x",
664 "refs/heads/feat/develop",
665 "refs/remotes/origin/main",
666 "refs/heads/release/1.2",
667 "refs/remotes/upstream/release/1.2",
668 ]
669 .iter()
670 .map(|name| (*name).to_owned())
671 .collect();
672 assert_eq!(
673 long_lived_among(&refs, "master", "release/"),
674 vec!["develop", "main", "release/1.2"]
675 );
676 assert!(
677 long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
678 );
679 assert!(
680 long_lived_among(
681 &["refs/heads/feat/develop".to_owned()],
682 "master",
683 "release/"
684 )
685 .is_empty()
686 );
687 }
688
689 #[test]
690 fn the_verdict_words_are_the_wire_form() {
691 for (classification, word) in [
692 (Classification::Greenfield, "greenfield"),
693 (Classification::Brownfield, "brownfield"),
694 (Classification::NeedsDecision, "needs-decision"),
695 ] {
696 assert_eq!(classification.as_str(), word);
697 assert_eq!(
698 serde_json::to_string(&classification).expect("serializes"),
699 format!("\"{word}\"")
700 );
701 }
702 }
703
704 fn evidence() -> Evidence {
705 Evidence {
706 landing: Landing {
707 recorded: false,
708 rk_version: None,
709 },
710 technologies: Vec::new(),
711 forge: None,
712 repo: None,
713 release_markers: Vec::new(),
714 collisions: Vec::new(),
715 git: true,
716 tags: 0,
717 long_lived_branches: Vec::new(),
718 }
719 }
720
721 #[test]
722 fn nothing_is_greenfield() {
723 assert_eq!(classify(&evidence()), Classification::Greenfield);
724 }
725
726 #[test]
727 fn a_release_marker_or_a_collision_is_brownfield() {
728 let with_marker = Evidence {
729 release_markers: vec!["CHANGELOG.md".into()],
730 ..evidence()
731 };
732 assert_eq!(classify(&with_marker), Classification::Brownfield);
733 let with_collision = Evidence {
734 collisions: vec!["release-plz.toml".into()],
735 ..evidence()
736 };
737 assert_eq!(classify(&with_collision), Classification::Brownfield);
738 }
739
740 #[test]
743 fn a_mechanism_beside_activity_is_still_brownfield() {
744 let both = Evidence {
745 release_markers: vec!["CHANGELOG.md".into()],
746 tags: 7,
747 long_lived_branches: vec!["develop".into()],
748 ..evidence()
749 };
750 assert_eq!(classify(&both), Classification::Brownfield);
751 }
752
753 #[test]
754 fn activity_with_no_mechanism_needs_a_decision() {
755 let tagged = Evidence {
756 tags: 1,
757 ..evidence()
758 };
759 assert_eq!(classify(&tagged), Classification::NeedsDecision);
760 let branched = Evidence {
761 long_lived_branches: vec!["develop".into()],
762 ..evidence()
763 };
764 assert_eq!(classify(&branched), Classification::NeedsDecision);
765 }
766}