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
81pub use crate::plan::classify::Verdict as Classification;
85
86#[derive(Debug, Serialize)]
89pub struct Landing {
90 pub recorded: bool,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub rk_version: Option<String>,
95}
96
97#[derive(Debug, Serialize)]
99pub struct Evidence {
100 pub landing: Landing,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub tech: Option<&'static str>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub forge: Option<&'static str>,
108 #[serde(skip_serializing_if = "Option::is_none")]
110 pub repo: Option<String>,
111 pub release_markers: Vec<String>,
113 pub collisions: Vec<String>,
116 pub git: bool,
118 pub tags: usize,
120 pub long_lived_branches: Vec<String>,
122}
123
124#[must_use]
127pub fn classify(evidence: &Evidence) -> Classification {
128 crate::plan::classify::verdict(&crate::plan::classify::RepositoryFacts {
129 release_markers: evidence.release_markers.clone(),
130 collisions: evidence.collisions.clone(),
131 tags: evidence.tags,
132 long_lived_branches: evidence.long_lived_branches.clone(),
133 })
134}
135
136#[derive(Debug)]
139pub struct Facts {
140 pub tech: Option<&'static str>,
142 pub forge: Option<&'static str>,
144 pub repo: Option<String>,
146 pub release_markers: Vec<String>,
148 pub collisions: Vec<String>,
150 pub git: bool,
152 pub tags: usize,
154 pub long_lived_branches: Vec<String>,
156}
157
158pub fn gather(target: &Utf8Path) -> Result<Evidence, RkError> {
169 let record = manifest::load(target)?;
170 let landing = Landing {
171 recorded: record.is_some(),
172 rk_version: record.map(|manifest| manifest.rk_version),
173 };
174 let facts = gather_facts(target)?;
175 Ok(Evidence {
176 landing,
177 tech: facts.tech,
178 forge: facts.forge,
179 repo: facts.repo,
180 release_markers: facts.release_markers,
181 collisions: facts.collisions,
182 git: facts.git,
183 tags: facts.tags,
184 long_lived_branches: facts.long_lived_branches,
185 })
186}
187
188pub fn gather_facts(target: &Utf8Path) -> Result<Facts, RkError> {
196 let detected = crate::detect::detect(target.as_std_path());
197 let mut release_markers: Vec<String> = RELEASE_MARKERS
198 .iter()
199 .filter(|marker| target.join(marker).is_file())
200 .map(|marker| (*marker).to_owned())
201 .collect();
202 if package_json_names_a_release(target)? {
203 release_markers.push("package.json".to_owned());
204 }
205 release_markers.sort();
206 let mut collisions = Vec::new();
207 for destination in landing::destinations() {
208 if landing::read_recorded(target, destination)?.is_some() {
209 collisions.push(destination.to_owned());
210 }
211 }
212 collisions.sort();
213 let (git, tags, long_lived_branches) = git_evidence(target)?;
214 Ok(Facts {
215 tech: crate::detect::tech_of(target.as_std_path()),
216 forge: detected.forge.map(crate::detect::Forge::as_str),
217 repo: detected.repo,
218 release_markers,
219 collisions,
220 git,
221 tags,
222 long_lived_branches,
223 })
224}
225
226fn package_json_names_a_release(target: &Utf8Path) -> Result<bool, RkError> {
230 let path = target.join("package.json");
231 let bytes = match std::fs::read(&path) {
232 Ok(bytes) => bytes,
233 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
234 Err(e) => return Err(RkError::Io(e)),
235 };
236 Ok(serde_json::from_slice::<serde_json::Value>(&bytes)
239 .ok()
240 .and_then(|value| value.get("release").map(|_| ()))
241 .is_some())
242}
243
244fn git_evidence(target: &Utf8Path) -> Result<(bool, usize, Vec<String>), RkError> {
253 let trunk = crate::config::trunk_of(target.as_std_path())?;
254 let line_prefix = crate::config::line_prefix_of(target.as_std_path())?;
255 let (trunk, line_prefix) = (trunk.as_str(), line_prefix.as_str());
256 match git_lines(target, &["rev-parse", "--git-dir"]) {
257 Ok(_) => {}
258 Err(GitFailure::NotARepository) => return Ok((false, 0, Vec::new())),
259 Err(GitFailure::Other(error)) => return Err(error),
260 }
261 let tags = git_lines(target, &["tag", "--list"]).map_err(GitFailure::into_error)?;
262 let refs = git_lines(
263 target,
264 &[
265 "for-each-ref",
266 "--format=%(refname)",
267 "refs/heads",
268 "refs/remotes",
269 ],
270 )
271 .map_err(GitFailure::into_error)?;
272 Ok((
273 true,
274 tags.len(),
275 long_lived_among(&refs, trunk, line_prefix),
276 ))
277}
278
279#[must_use]
286pub fn long_lived_among(refs: &[String], trunk: &str, line_prefix: &str) -> Vec<String> {
287 let mut names = std::collections::BTreeSet::new();
288 for reference in refs {
289 let name = if let Some(local) = reference.strip_prefix("refs/heads/") {
290 local
291 } else if let Some(remote) = reference.strip_prefix("refs/remotes/") {
292 match remote.split_once('/') {
293 Some((_, "HEAD")) | None => continue,
294 Some((_, name)) => name,
295 }
296 } else {
297 continue;
298 };
299 let catalogued = name != trunk && LONG_LIVED_BRANCHES.contains(&name);
300 if catalogued || name.starts_with(line_prefix) {
301 names.insert(name.to_owned());
302 }
303 }
304 names.into_iter().collect()
305}
306
307enum GitFailure {
309 NotARepository,
311 Other(RkError),
313}
314
315impl GitFailure {
316 fn into_error(self) -> RkError {
319 match self {
320 Self::NotARepository => RkError::subprocess(
321 Diagnostic::new(
322 Reason::SubprocessFailed,
323 "git stopped answering for a repository it had just recognized",
324 )
325 .expected("a readable repository"),
326 ),
327 Self::Other(error) => error,
328 }
329 }
330}
331
332fn git_lines(target: &Utf8Path, args: &[&str]) -> Result<Vec<String>, GitFailure> {
340 let mut command = Command::new(crate::probes::git_bin());
341 for var in crate::maintenance::GIT_HOOK_VARS {
342 command.env_remove(var);
343 }
344 let out = command
345 .env("LC_ALL", "C")
346 .env_remove("LANGUAGE")
347 .arg("-C")
348 .arg(target)
349 .args(args)
350 .output()
351 .map_err(|error| {
352 GitFailure::Other(RkError::subprocess(
353 Diagnostic::new(
354 Reason::SubprocessSpawn,
355 format!("git could not be spawned: {error}"),
356 )
357 .expected("git on PATH, or RK_GIT_BIN naming it"),
358 ))
359 })?;
360 if !out.status.success() {
361 let stderr = String::from_utf8_lossy(&out.stderr);
362 if stderr.contains("not a git repository") {
363 return Err(GitFailure::NotARepository);
364 }
365 return Err(GitFailure::Other(RkError::subprocess(
366 Diagnostic::new(
367 Reason::SubprocessFailed,
368 format!(
369 "git {} failed at {target}: {}",
370 args.join(" "),
371 stderr.trim()
372 ),
373 )
374 .expected("git answering for the target, or a target that is not a repository")
375 .action("an unreadable history is not an absent one; repair the repository or its ownership before classifying"),
376 )));
377 }
378 Ok(String::from_utf8_lossy(&out.stdout)
379 .lines()
380 .map(str::trim)
381 .filter(|line| !line.is_empty())
382 .map(str::to_owned)
383 .collect())
384}
385
386#[cfg(test)]
387mod tests {
388 use super::{Classification, long_lived_among};
389
390 #[test]
395 fn long_lived_branches_are_read_from_the_full_ref_names() {
396 let refs: Vec<String> = [
397 "refs/heads/master",
398 "refs/remotes/origin/master",
399 "refs/remotes/origin/HEAD",
400 "refs/heads/develop",
401 "refs/remotes/origin/develop",
402 "refs/heads/feat/x",
403 "refs/heads/feat/develop",
404 "refs/remotes/origin/main",
405 "refs/heads/release/1.2",
406 "refs/remotes/upstream/release/1.2",
407 ]
408 .iter()
409 .map(|name| (*name).to_owned())
410 .collect();
411 assert_eq!(
412 long_lived_among(&refs, "master", "release/"),
413 vec!["develop", "main", "release/1.2"]
414 );
415 assert!(
416 long_lived_among(&["refs/heads/master".to_owned()], "master", "release/").is_empty()
417 );
418 assert!(
419 long_lived_among(
420 &["refs/heads/feat/develop".to_owned()],
421 "master",
422 "release/"
423 )
424 .is_empty()
425 );
426 }
427
428 #[test]
429 fn the_verdict_words_are_the_wire_form() {
430 assert_eq!(Classification::Greenfield.as_str(), "greenfield");
431 assert_eq!(Classification::Brownfield.as_str(), "brownfield");
432 assert_eq!(Classification::NeedsDecision.as_str(), "needs-decision");
433 }
434}