1use camino::Utf8Path;
15use serde::Serialize;
16
17use crate::cli::lines::{LinesAction, LinesArgs};
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::maintenance::{self, Deletion};
21use crate::output::Output;
22use crate::release_engine::adapter::{self, Version};
23use crate::worktree::{Worktree, parse_worktrees};
24
25pub fn run(args: &LinesArgs) -> Result<(), RkError> {
31 crate::requirements::require("rk lines", &[crate::requirements::Tool::Git])?;
32 match &args.action {
33 LinesAction::List { target, json } => list(target, Output::new(*json)),
34 LinesAction::Open {
35 line,
36 base,
37 target,
38 apply,
39 json,
40 } => open(line, base.as_deref(), target, *apply, Output::new(*json)),
41 LinesAction::Mint {
42 line,
43 expect,
44 promote,
45 target,
46 as_bot,
47 apply,
48 json,
49 } => mint(
50 line,
51 expect.as_deref(),
52 *promote,
53 target,
54 *as_bot,
55 *apply,
56 Output::new(*json),
57 ),
58 LinesAction::Rc { line, target, json } => rc(line, target, Output::new(*json)),
59 LinesAction::Retire {
60 line,
61 target,
62 apply,
63 json,
64 } => retire(line, target, *apply, Output::new(*json)),
65 }
66}
67
68#[derive(Debug, Clone, Serialize)]
70pub struct LineRow {
71 pub line: String,
73 pub branch: String,
75 pub presence: &'static str,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub newest_release: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub newest_candidate: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
87 pub tag_covered: Option<bool>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub seat: Option<String>,
91}
92
93#[derive(Debug, Serialize)]
95struct ListReport {
96 schema: &'static str,
98 lines: Vec<LineRow>,
100}
101
102fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
103 let rows = rows(target)?;
104 if rows.is_empty() {
105 out.result_line("no release lines; the trunk is the only line alive");
106 }
107 for row in &rows {
108 let mut parts = vec![format!("{} ({})", row.branch, row.presence)];
109 if let Some(tag) = &row.newest_release {
110 parts.push(format!("newest release {tag}"));
111 }
112 if let Some(tag) = &row.newest_candidate {
113 parts.push(format!("newest candidate {tag}"));
114 }
115 match row.tag_covered {
116 Some(true) => parts.push("tag-covered".to_owned()),
117 Some(false) => parts.push("commits beyond the tags; not retirable".to_owned()),
118 None => {}
119 }
120 if let Some(seat) = &row.seat {
121 parts.push(format!("seated at {seat}"));
122 }
123 out.result_line(parts.join(" — "));
124 }
125 out.emit(&ListReport {
126 schema: "rk.lines-list/1",
127 lines: rows,
128 })
129}
130
131pub fn rows(target: &Utf8Path) -> Result<Vec<LineRow>, RkError> {
139 let prefix = crate::config::line_prefix_of(target.as_std_path())?;
140 let local = ref_names(target, &format!("refs/heads/{prefix}"))?;
141 let remote: Vec<String> = ref_names(target, &format!("refs/remotes/origin/{prefix}"))?
142 .into_iter()
143 .filter_map(|name| name.strip_prefix("origin/").map(str::to_owned))
144 .collect();
145 let mut names: Vec<String> = local.iter().chain(remote.iter()).cloned().collect();
146 names.sort();
147 names.dedup();
148 let seats = inventory(target)?;
149 let mut rows = Vec::new();
150 for branch in names {
151 let Some(line) = branch.strip_prefix(&prefix) else {
152 continue;
153 };
154 let is_local = local.contains(&branch);
155 let presence = match (is_local, remote.contains(&branch)) {
156 (true, true) => "both",
157 (true, false) => "local",
158 _ => "remote",
159 };
160 rows.push(LineRow {
161 line: line.to_owned(),
162 branch: branch.clone(),
163 presence,
164 newest_release: newest_tag(target, line, false)?,
165 newest_candidate: newest_tag(target, line, true)?,
166 tag_covered: if is_local {
167 Some(uncovered_commits(target, &branch)?.is_empty())
168 } else {
169 None
170 },
171 seat: seats
172 .iter()
173 .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
174 .map(|seat| seat.path.to_string()),
175 });
176 }
177 Ok(rows)
178}
179
180#[derive(Debug, Serialize)]
184struct OpenReport {
185 schema: &'static str,
187 mode: &'static str,
189 branch: String,
191 next: Vec<String>,
193}
194
195fn open(
196 line: &str,
197 base: Option<&str>,
198 target: &Utf8Path,
199 apply: bool,
200 out: Output,
201) -> Result<(), RkError> {
202 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
203 let Some(base) = base else {
204 return Err(RkError::Usage(format!(
205 "a line is a snapshot of a chosen commit, so {branch} takes no default base; pass --base \"v<version>\", the tag it patches"
206 )));
207 };
208 if branch_exists(target, &branch)? {
209 out.result_line(format!(
210 "satisfied: {branch} already exists; the open adopts it"
211 ));
212 let next = vec![format!(
213 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
214 )];
215 return out.emit(&OpenReport {
216 schema: "rk.lines-open/1",
217 mode: "satisfied",
218 branch,
219 next,
220 });
221 }
222 let remote = format!("origin/{branch}");
226 if remote_exists(target, &branch)? {
227 if !apply {
228 out.result_line(format!(
229 "DRY RUN: would adopt {branch} from {remote}, tracking it at the remote tip; --base is not used, because the line already exists"
230 ));
231 let next = vec![format!(
232 "rk lines open {line} --base \"{base}\" --target {target} --apply"
233 )];
234 out.next(&next);
235 return out.emit(&OpenReport {
236 schema: "rk.lines-open/1",
237 mode: "preview",
238 branch,
239 next,
240 });
241 }
242 let tracked = git(target, &["branch", "--track", &branch, &remote])?;
243 if !tracked.status.success() {
244 return Err(RkError::refusal(
245 Diagnostic::new(Reason::StateDrift, last_line(&tracked.stderr))
246 .expected("a tracking branch git can create from the remote line")
247 .target_state("unchanged"),
248 ));
249 }
250 out.result_line(format!(
251 "adopted {branch} from {remote} at the remote tip; --base was not used, because the line already exists"
252 ));
253 let next = vec![format!(
254 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
255 )];
256 out.next(&next);
257 return out.emit(&OpenReport {
258 schema: "rk.lines-open/1",
259 mode: "created",
260 branch,
261 next,
262 });
263 }
264 let resolved = resolve_commit(target, base)?;
265 if !apply {
266 out.result_line(format!(
267 "DRY RUN: would create {branch} at {base} ({resolved})"
268 ));
269 let next = vec![format!(
270 "rk lines open {line} --base \"{base}\" --target {target} --apply"
271 )];
272 out.next(&next);
273 return out.emit(&OpenReport {
274 schema: "rk.lines-open/1",
275 mode: "preview",
276 branch,
277 next,
278 });
279 }
280 let created = git(target, &["branch", &branch, &resolved])?;
281 if !created.status.success() {
282 return Err(RkError::refusal(
283 Diagnostic::new(Reason::StateDrift, last_line(&created.stderr))
284 .expected("a branch git can create at the named base")
285 .target_state("unchanged"),
286 ));
287 }
288 out.result_line(format!("created {branch} at {base} ({resolved})"));
289 let next = vec![
290 format!(
291 "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
292 ),
293 format!("git push -u origin {branch} publishes it with its upstream set"),
294 "rk setup step protect-release-lines --apply protects every line, once per repository"
295 .to_owned(),
296 ];
297 out.next(&next);
298 out.emit(&OpenReport {
299 schema: "rk.lines-open/1",
300 mode: "created",
301 branch,
302 next,
303 })
304}
305
306#[derive(Debug, Serialize)]
308struct RcReport {
309 schema: &'static str,
311 line: String,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 newest_release: Option<String>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 newest_candidate: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
321 next_candidate: Option<u64>,
322}
323
324fn rc(line: &str, target: &Utf8Path, out: Output) -> Result<(), RkError> {
325 line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
326 let newest_release = newest_tag(target, line, false)?;
327 let newest_candidate = newest_tag(target, line, true)?;
328 let next_candidate = newest_candidate
329 .as_deref()
330 .and_then(|tag| tag.rsplit_once("-rc.")?.1.parse::<u64>().ok())
331 .map(|n| n + 1);
332 match &newest_candidate {
333 Some(tag) => {
334 out.result_line(format!("newest candidate {tag}"));
335 if let Some(next) = next_candidate {
336 out.result_line(format!(
337 "the next candidate is rc.{next}; an rc number is single-use"
338 ));
339 }
340 }
341 None => out.result_line(empty_candidate_message(target)),
342 }
343 if let Some(tag) = &newest_release {
344 out.result_line(format!("newest release {tag}"));
345 }
346 out.emit(&RcReport {
347 schema: "rk.lines-rc/1",
348 line: line.to_owned(),
349 newest_release,
350 newest_candidate,
351 next_candidate,
352 })
353}
354
355#[derive(Debug, PartialEq, Eq)]
357enum MintDecision {
358 Mint(Version),
360 Pending(Version),
364}
365
366fn decide_mint(
373 line: (u64, u64),
374 remote_tags: &[Version],
375 manifest: Option<Version>,
376) -> Result<MintDecision, String> {
377 let on_line: Vec<Version> = remote_tags
378 .iter()
379 .copied()
380 .filter(|tag| (tag.major, tag.minor) == line)
381 .collect();
382 let base_patch = on_line
383 .iter()
384 .filter(|tag| tag.rc.is_none())
385 .map(|tag| tag.patch)
386 .max()
387 .map_or(0, |patch| patch + 1);
388 if let Some(current) = manifest.filter(|version| version.rc.is_some())
389 && !on_line.contains(¤t)
390 {
391 if (current.major, current.minor, current.patch) == (line.0, line.1, base_patch) {
392 return Ok(MintDecision::Pending(current));
393 }
394 return Err(format!(
395 "the manifest carries the untagged candidate {}, which is not a candidate of the next release {}.{}.{}",
396 current.tag(),
397 line.0,
398 line.1,
399 base_patch
400 ));
401 }
402 let number = on_line
403 .iter()
404 .filter(|tag| tag.patch == base_patch)
405 .filter_map(|tag| tag.rc)
406 .max()
407 .map_or(1, |n| n + 1);
408 Ok(MintDecision::Mint(Version {
409 major: line.0,
410 minor: line.1,
411 patch: base_patch,
412 rc: Some(number),
413 }))
414}
415
416#[derive(Debug, Serialize)]
418struct MintReport {
419 schema: &'static str,
421 mode: &'static str,
423 line: String,
425 branch: String,
427 tip: String,
429 version: String,
432 tag: String,
434 #[serde(skip_serializing_if = "Option::is_none")]
437 package: Option<String>,
438 #[serde(skip_serializing_if = "Option::is_none")]
440 commit: Option<String>,
441}
442
443struct MintContext<'a> {
446 line: &'a str,
447 numbers: (u64, u64),
448 branch: String,
449 target: &'a Utf8Path,
450 driver: String,
451 file: &'static str,
452 tip: String,
453 tags: Vec<RemoteTag>,
454 manifest: Option<Version>,
455 as_bot: bool,
457}
458
459impl MintContext<'_> {
460 fn report(&self, mode: &'static str, version: Version, commit: Option<String>) -> MintReport {
461 MintReport {
462 schema: "rk.lines-mint/1",
463 mode,
464 line: self.line.to_owned(),
465 branch: self.branch.clone(),
466 tip: self.tip.clone(),
467 version: version.spelled(&self.driver),
468 tag: version.tag(),
469 package: adapter::package_name(self.target.as_std_path(), &self.driver),
470 commit,
471 }
472 }
473
474 fn versions(&self) -> Vec<Version> {
475 self.tags.iter().map(|tag| tag.version).collect()
476 }
477}
478
479fn mint(
480 line: &str,
481 expect: Option<&str>,
482 promote: bool,
483 target: &Utf8Path,
484 as_bot: bool,
485 apply: bool,
486 out: Output,
487) -> Result<(), RkError> {
488 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
489 let numbers = line
490 .split_once('.')
491 .and_then(|(major, minor)| Some((major.parse().ok()?, minor.parse().ok()?)))
492 .ok_or_else(|| RkError::Usage(format!("'{line}' is not a line")))?;
493 if apply && expect.is_none() {
494 return Err(RkError::Usage(format!(
495 "a mint acts only on the tip its caller saw; pass --expect <sha>, the tip of {branch} you read"
496 )));
497 }
498 let driver = recorded_driver(target)?;
499 if as_bot && !matches!(driver.as_str(), "bash" | "python") {
502 return Err(RkError::Usage(format!(
503 "--as-bot commits as the bot on a bash or python line, and this line releases {driver}; drop --as-bot"
504 )));
505 }
506 let (file, _) = adapter::version_file(&driver).ok_or_else(|| {
507 RkError::refusal(
508 Diagnostic::new(
509 Reason::StateDrift,
510 format!("the release driver {driver} carries no version file this verb writes"),
511 )
512 .expected("a rust, python, or bash target")
513 .target_state("unchanged"),
514 )
515 })?;
516 let (tip, tags) = remote_line(target, &branch, line)?;
517 if let Some(expect) = expect {
518 refuse_a_moved_line(target, &branch, &tip, expect)?;
519 }
520 let manifest = adapter::manifest_version(target.as_std_path(), &driver)
521 .as_deref()
522 .and_then(Version::parse);
523 let context = MintContext {
524 line,
525 numbers,
526 branch,
527 target,
528 driver,
529 file,
530 tip,
531 tags,
532 manifest,
533 as_bot,
534 };
535 if promote {
536 promote_candidate(&context, apply, out)
537 } else {
538 mint_candidate(&context, apply, out)
539 }
540}
541
542fn mint_candidate(context: &MintContext<'_>, apply: bool, out: Output) -> Result<(), RkError> {
544 let MintContext {
545 line,
546 branch,
547 target,
548 driver,
549 file,
550 tip,
551 ..
552 } = context;
553 let decision = decide_mint(context.numbers, &context.versions(), context.manifest).map_err(
554 |detail| {
555 RkError::refusal(
556 Diagnostic::new(Reason::StateDrift, detail)
557 .expected("a manifest that carries a released version or the pending candidate of the next release")
558 .action(format!("restore {file} to the line's released version, then mint again"))
559 .target_state("unchanged"),
560 )
561 },
562 )?;
563 let candidate = match decision {
564 MintDecision::Pending(version) => {
565 out.result_line(format!(
566 "pending: {file} already carries {}, and {} does not exist yet; nothing is written, and the release step publishes it",
567 version.spelled(driver),
568 version.tag()
569 ));
570 return out.emit(&context.report("pending", version, None));
571 }
572 MintDecision::Mint(version) => version,
573 };
574 if !apply {
575 out.result_line(format!(
576 "DRY RUN: would write {} into {file}, commit, and push {branch} from {tip}; the release half tags {}",
577 candidate.spelled(driver),
578 candidate.tag()
579 ));
580 let next = vec![format!(
581 "rk lines mint {line} --expect {tip} --target {target} --apply"
582 )];
583 out.next(&next);
584 return out.emit(&context.report("preview", candidate, None));
585 }
586 let message = format!("chore(release): mint {}", candidate.tag());
587 let commit = commit_and_push(context, candidate, &message)?;
588 out.result_line(format!(
589 "minted {} on {branch} at {commit}; the release half tags {}",
590 candidate.spelled(driver),
591 candidate.tag()
592 ));
593 out.emit(&context.report("minted", candidate, Some(commit)))
594}
595
596fn promote_candidate(context: &MintContext<'_>, apply: bool, out: Output) -> Result<(), RkError> {
600 let MintContext {
601 line,
602 branch,
603 target,
604 driver,
605 file,
606 tip,
607 ..
608 } = context;
609 let newest = newest_candidate(context.numbers, &context.tags).ok_or_else(|| {
610 RkError::refusal(
611 Diagnostic::new(
612 Reason::StateDrift,
613 format!("{branch} has no tagged candidate of its next release to promote"),
614 )
615 .expected("a candidate the release half tagged and a human validated")
616 .action(format!("rk lines mint {line} mints one"))
617 .target_state("unchanged"),
618 )
619 })?;
620 let stable = Version {
621 rc: None,
622 ..newest.version
623 };
624 let untested = untested_commits(context, &newest.commit, tip)?;
625 if !untested.is_empty() {
626 return Err(RkError::refusal(
627 Diagnostic::new(
628 Reason::StateDrift,
629 format!(
630 "{branch} holds {} commit(s) after {}, which no candidate tested: {}",
631 untested.len(),
632 newest.version.tag(),
633 untested.join("; ")
634 ),
635 )
636 .expected(format!(
637 "{branch} at the commit {} tagged",
638 newest.version.tag()
639 ))
640 .action(format!(
641 "rk lines mint {line} mints a candidate of them; promote that one once it stands"
642 ))
643 .target_state("unchanged"),
644 ));
645 }
646 if context.manifest == Some(stable) {
647 out.result_line(format!(
648 "pending: {file} already carries {}; nothing is written, and the line's release request releases it",
649 stable.spelled(driver)
650 ));
651 return out.emit(&context.report("pending", stable, None));
652 }
653 if !apply {
654 out.result_line(format!(
655 "DRY RUN: would write {} into {file}, commit, and push {branch} from {tip}; the line's release request then proposes it",
656 stable.spelled(driver)
657 ));
658 let next = vec![format!(
659 "rk lines mint {line} --promote --expect {tip} --target {target} --apply"
660 )];
661 out.next(&next);
662 return out.emit(&context.report("preview", stable, None));
663 }
664 let message = format!(
665 "chore(release): promote {} to {}",
666 newest.version.tag(),
667 stable.tag()
668 );
669 let commit = commit_and_push(context, stable, &message)?;
670 out.result_line(format!(
671 "promoted {} to {} on {branch} at {commit}; merge the line's release request to release it",
672 newest.version.tag(),
673 stable.spelled(driver)
674 ));
675 out.emit(&context.report("promoted", stable, Some(commit)))
676}
677
678fn newest_candidate(line: (u64, u64), tags: &[RemoteTag]) -> Option<&RemoteTag> {
680 let versions: Vec<Version> = tags.iter().map(|tag| tag.version).collect();
681 let MintDecision::Mint(next) = decide_mint(line, &versions, None).ok()? else {
682 return None;
683 };
684 tags.iter()
685 .filter(|tag| tag.version.rc.is_some() && tag.version.patch == next.patch)
686 .filter(|tag| (tag.version.major, tag.version.minor) == line)
687 .max_by_key(|tag| tag.version.rc)
688}
689
690fn commit_and_push(
693 context: &MintContext<'_>,
694 version: Version,
695 message: &str,
696) -> Result<String, RkError> {
697 let MintContext {
698 line,
699 branch,
700 target,
701 driver,
702 file,
703 tip,
704 ..
705 } = context;
706 let identity = if context.as_bot {
709 Some(bot_identity(target)?)
710 } else {
711 None
712 };
713 let written = if *driver == "python" {
716 let python = adapter::for_driver(driver, "", None)
717 .ok_or_else(|| RkError::Usage(format!("no version writer for the {driver} driver")))?;
718 python
719 .write_version(target, version)?
720 .into_iter()
721 .map(String::from)
722 .collect()
723 } else {
724 write_manifest_and_lock(target, driver, file, version)?
725 };
726 let mut add = vec!["add", "--"];
727 add.extend(written.iter().map(String::as_str));
728 run_git(target, &add, "the version staged")?;
729 if let Some((name, email)) = identity {
730 let user = format!("user.name={name}");
733 let mail = format!("user.email={email}");
734 let committed = crate::release_engine::git(
735 target,
736 &["-c", &user, "-c", &mail, "commit", "-q", "-m", message],
737 )?;
738 if !committed.status.success() {
739 return Err(RkError::subprocess(
740 Diagnostic::new(Reason::SubprocessFailed, last_line(&committed.stderr))
741 .expected("a commit under the identity the forge names for the bot")
742 .target_state("the version file is written and not pushed"),
743 ));
744 }
745 } else {
746 run_git(
747 target,
748 &["commit", "-q", "-m", message],
749 "a commit under the identity the checkout configures",
750 )?;
751 }
752 let commit = resolve_commit(target, "HEAD")?;
753 let refspec = format!("HEAD:refs/heads/{branch}");
754 let pushed = git(target, &["push", "-q", "origin", &refspec])?;
755 if pushed.status.success() {
756 return Ok(commit);
757 }
758 if let Ok((now, _)) = remote_line(target, branch, line)
761 && now != *tip
762 {
763 return Err(RkError::refusal(
764 Diagnostic::new(
765 Reason::RemoteConflict,
766 format!(
767 "{branch} moved to {now} while this mint ran; the push of {} was rejected",
768 version.tag()
769 ),
770 )
771 .expected(format!("{branch} still at {tip}"))
772 .action(format!(
773 "read the new tip, then rk lines mint {line} --expect {now} --apply"
774 ))
775 .target_state("the remote is unchanged; the local commit is not pushed"),
776 ));
777 }
778 let server: Vec<String> = String::from_utf8_lossy(&pushed.stderr)
779 .lines()
780 .map(str::trim)
781 .filter(|row| !row.is_empty())
782 .map(str::to_owned)
783 .collect();
784 Err(RkError::subprocess(
785 Diagnostic::new(Reason::SubprocessFailed, server.join("; "))
786 .expected(format!("a push of {branch} the remote accepts"))
787 .target_state("the remote is unchanged; the local commit is not pushed"),
788 ))
789}
790
791fn bot_identity(target: &Utf8Path) -> Result<(String, String), RkError> {
797 let engine = crate::release_engine::Context::load(target)?;
798 crate::release_engine::forge::Client::resolve(engine.forge, target, &engine.repo)?.identity()
799}
800
801fn write_manifest_and_lock(
804 target: &Utf8Path,
805 driver: &str,
806 file: &str,
807 version: Version,
808) -> Result<Vec<String>, RkError> {
809 let spelled = version.spelled(driver);
810 let manifest = versioned_manifest(target, driver, &spelled)?;
813 let lock_tracked = driver == "rust"
816 && git(target, &["ls-files", "--error-unmatch", "--", "Cargo.lock"])?
817 .status
818 .success();
819 let lock = if lock_tracked {
820 let old = adapter::manifest_version(target.as_std_path(), driver).unwrap_or_default();
821 let name = adapter::package_name(target.as_std_path(), driver).unwrap_or_default();
822 versioned_lock(target, &name, &old, &spelled)?
823 } else {
824 None
825 };
826 std::fs::write(target.join(file), manifest).map_err(RkError::Io)?;
827 let mut written = vec![file.to_owned()];
828 if let Some(lock) = lock {
829 std::fs::write(target.join("Cargo.lock"), lock).map_err(RkError::Io)?;
830 written.push("Cargo.lock".to_owned());
831 }
832 Ok(written)
833}
834
835#[derive(Debug, Clone)]
837struct RemoteTag {
838 version: Version,
839 commit: String,
840}
841
842fn recorded_driver(target: &Utf8Path) -> Result<String, RkError> {
844 crate::landing::manifest::load(target)?
845 .and_then(|manifest| manifest.profile.release.driver)
846 .ok_or_else(|| {
847 RkError::refusal(
848 Diagnostic::new(
849 Reason::StateDrift,
850 "the landing record names no release driver, so no version file is known",
851 )
852 .expected("a target release-kit landed, checked out at the line")
853 .target_state("unchanged"),
854 )
855 })
856}
857
858fn remote_line(
862 target: &Utf8Path,
863 branch: &str,
864 line: &str,
865) -> Result<(String, Vec<RemoteTag>), RkError> {
866 let head = format!("refs/heads/{branch}");
867 let tags = format!("refs/tags/v{line}.*");
868 let output = git(target, &["ls-remote", "origin", &head, &tags])?;
869 if !output.status.success() {
870 return Err(RkError::refusal(
871 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
872 .expected("an origin remote git can read")
873 .target_state("unchanged"),
874 ));
875 }
876 let mut tip = None;
877 let mut commits = std::collections::BTreeMap::new();
880 for row in String::from_utf8_lossy(&output.stdout).lines() {
881 let Some((sha, name)) = row.split_once('\t') else {
882 continue;
883 };
884 if name == head {
885 tip = Some(sha.to_owned());
886 } else if let Some(peeled) = name.strip_suffix("^{}") {
887 commits.insert(peeled.to_owned(), sha.to_owned());
888 } else {
889 commits
890 .entry(name.to_owned())
891 .or_insert_with(|| sha.to_owned());
892 }
893 }
894 let versions = commits
895 .into_iter()
896 .filter_map(|(name, commit)| {
897 let version = name
898 .strip_prefix("refs/tags/v")
899 .and_then(Version::parse)
900 .filter(|version| version.rc.is_none() || name.contains("-rc."))?;
901 Some(RemoteTag { version, commit })
902 })
903 .collect();
904 let tip = tip.ok_or_else(|| {
905 RkError::refusal(
906 Diagnostic::new(Reason::TargetNotFound, format!("origin holds no {branch}"))
907 .expected("a line published to the origin remote")
908 .action(format!("git push -u origin {branch} publishes it"))
909 .target_state("unchanged"),
910 )
911 })?;
912 Ok((tip, versions))
913}
914
915fn refuse_a_moved_line(
919 target: &Utf8Path,
920 branch: &str,
921 tip: &str,
922 expect: &str,
923) -> Result<(), RkError> {
924 let expected = resolve_commit(target, expect).unwrap_or_else(|_| expect.to_owned());
925 if tip != expected {
926 return Err(RkError::refusal(
927 Diagnostic::new(
928 Reason::StateDrift,
929 format!("{branch} is at {tip} on the remote, not at the expected {expected}"),
930 )
931 .expected(format!("{branch} at {expected}"))
932 .action(format!(
933 "read the line again, then mint with --expect {tip}"
934 ))
935 .target_state("unchanged"),
936 ));
937 }
938 let head = resolve_commit(target, "HEAD")?;
939 if head != expected {
940 return Err(RkError::refusal(
941 Diagnostic::new(
942 Reason::StateDrift,
943 format!(
944 "the checkout is at {head}, not at the expected tip {expected} of {branch}"
945 ),
946 )
947 .expected(format!("a checkout of {branch} at {expected}"))
948 .target_state("unchanged"),
949 ));
950 }
951 let status = git(
952 target,
953 &["status", "--porcelain", "--untracked-files=normal"],
954 )?;
955 if !status.status.success() || !status.stdout.is_empty() {
956 return Err(RkError::refusal(
957 Diagnostic::new(
958 Reason::StateDrift,
959 "the working tree carries uncommitted or untracked changes",
960 )
961 .expected("a clean checkout of the line")
962 .target_state("unchanged"),
963 ));
964 }
965 Ok(())
966}
967
968fn versioned_manifest(target: &Utf8Path, driver: &str, version: &str) -> Result<String, RkError> {
971 let (file, table) = adapter::version_file(driver)
972 .ok_or_else(|| RkError::Usage(format!("no version file for {driver}")))?;
973 let Some(table) = table else {
974 return Ok(format!("{version}\n"));
975 };
976 let text = std::fs::read_to_string(target.join(file)).map_err(RkError::Io)?;
977 let mut document: toml_edit::DocumentMut = text.parse().map_err(|err| {
978 RkError::refusal(
979 Diagnostic::new(Reason::StateDrift, format!("{file} does not parse: {err}"))
980 .expected(format!("a {file} with a [{table}] version"))
981 .target_state("unchanged"),
982 )
983 })?;
984 let Some(slot) = document
985 .get_mut(table)
986 .and_then(toml_edit::Item::as_table_like_mut)
987 .and_then(|table| table.get_mut("version"))
988 .and_then(toml_edit::Item::as_value_mut)
989 .filter(|value| value.is_str())
990 else {
991 return Err(RkError::refusal(
992 Diagnostic::new(
993 Reason::StateDrift,
994 format!("{file} carries no literal [{table}] version to write"),
995 )
996 .expected(format!("a {file} whose [{table}] version is a string"))
997 .target_state("unchanged"),
998 ));
999 };
1000 let decor = slot.decor().clone();
1001 *slot = toml_edit::Value::from(version);
1002 *slot.decor_mut() = decor;
1003 Ok(document.to_string())
1004}
1005
1006fn versioned_lock(
1015 target: &Utf8Path,
1016 name: &str,
1017 old: &str,
1018 new: &str,
1019) -> Result<Option<String>, RkError> {
1020 let path = target.join("Cargo.lock");
1021 if !path.exists() {
1022 return Ok(None);
1023 }
1024 let text = std::fs::read_to_string(&path).map_err(RkError::Io)?;
1025 let refuse = |detail: String| {
1026 RkError::refusal(
1027 Diagnostic::new(Reason::StateDrift, detail)
1028 .expected(format!(
1029 "a Cargo.lock cargo wrote, with one {name} {old} entry and no source"
1030 ))
1031 .action("run cargo generate-lockfile or cargo update --workspace, commit it, then mint again")
1032 .target_state("unchanged"),
1033 )
1034 };
1035 let mut document: toml_edit::DocumentMut = text
1036 .parse()
1037 .map_err(|err| refuse(format!("Cargo.lock does not parse: {err}")))?;
1038 let Some(packages) = document
1039 .get_mut("package")
1040 .and_then(toml_edit::Item::as_array_of_tables_mut)
1041 else {
1042 return Err(refuse("Cargo.lock lists no packages".to_owned()));
1043 };
1044 let old_reference = format!("{name} {old}");
1045 let new_reference = format!("{name} {new}");
1046 let mut own = 0;
1047 for package in packages.iter_mut() {
1048 let is_own = package.get("name").and_then(toml_edit::Item::as_str) == Some(name)
1049 && package.get("version").and_then(toml_edit::Item::as_str) == Some(old)
1050 && !package.contains_key("source");
1051 if is_own {
1052 own += 1;
1053 package["version"] = toml_edit::value(new);
1054 }
1055 if let Some(dependencies) = package
1056 .get_mut("dependencies")
1057 .and_then(toml_edit::Item::as_array_mut)
1058 {
1059 for reference in dependencies.iter_mut() {
1060 if reference.as_str() == Some(old_reference.as_str()) {
1061 let decor = reference.decor().clone();
1062 *reference = toml_edit::Value::from(new_reference.as_str());
1063 *reference.decor_mut() = decor;
1064 }
1065 }
1066 }
1067 }
1068 if own != 1 {
1069 return Err(refuse(format!(
1070 "Cargo.lock holds {own} entries of {name} {old} with no source"
1071 )));
1072 }
1073 Ok(Some(document.to_string()))
1074}
1075
1076struct Sides<'a> {
1079 before: &'a str,
1080 after: &'a str,
1081 own_before: &'a str,
1082 own_after: &'a str,
1083}
1084
1085fn same_but_version(driver: &str, file: &str, name: &str, sides: &Sides<'_>) -> bool {
1088 if file == "Cargo.lock" {
1089 return masked_lock(sides.before, name, sides.own_before)
1090 .is_some_and(|before| Some(before) == masked_lock(sides.after, name, sides.own_after));
1091 }
1092 if file == "uv.lock" {
1093 return masked_uv_lock(sides.before, name, sides.own_before).is_some_and(|before| {
1094 Some(before) == masked_uv_lock(sides.after, name, sides.own_after)
1095 });
1096 }
1097 match adapter::version_file(driver) {
1098 Some((_, None)) => {
1099 Version::parse(sides.before.trim()).is_some()
1100 && Version::parse(sides.after.trim()).is_some()
1101 }
1102 Some((_, Some(table))) => {
1103 let masked = |text: &str| -> Option<toml::Table> {
1104 let mut value: toml::Table = toml::from_str(text).ok()?;
1105 value.get_mut(table)?.as_table_mut()?.remove("version")?;
1106 Some(value)
1107 };
1108 masked(sides.before).is_some_and(|before| Some(before) == masked(sides.after))
1109 }
1110 None => false,
1111 }
1112}
1113
1114fn masked_lock(text: &str, name: &str, own: &str) -> Option<toml::Table> {
1119 let mut value: toml::Table = toml::from_str(text).ok()?;
1120 let qualified = format!("{name} {own}");
1121 let packages = value.get_mut("package")?.as_array_mut()?;
1122 for package in packages.iter_mut() {
1123 let package = package.as_table_mut()?;
1124 let is_own = package.get("name").and_then(toml::Value::as_str) == Some(name)
1125 && package.get("version").and_then(toml::Value::as_str) == Some(own)
1126 && !package.contains_key("source");
1127 if is_own {
1128 package.remove("version");
1129 }
1130 if let Some(dependencies) = package
1131 .get_mut("dependencies")
1132 .and_then(toml::Value::as_array_mut)
1133 {
1134 for reference in dependencies.iter_mut() {
1135 if reference.as_str() == Some(qualified.as_str()) {
1136 *reference = toml::Value::String(name.to_owned());
1137 }
1138 }
1139 }
1140 }
1141 Some(value)
1142}
1143
1144fn masked_uv_lock(text: &str, name: &str, own: &str) -> Option<toml::Table> {
1150 let normalized = |raw: &str| -> String {
1151 let mut out = String::new();
1152 for c in raw.chars() {
1153 if matches!(c, '-' | '_' | '.') {
1154 if !out.ends_with('-') {
1155 out.push('-');
1156 }
1157 } else {
1158 out.push(c.to_ascii_lowercase());
1159 }
1160 }
1161 out
1162 };
1163 let wanted = normalized(name);
1164 let mut value: toml::Table = toml::from_str(text).ok()?;
1165 let packages = value.get_mut("package")?.as_array_mut()?;
1166 for package in packages.iter_mut() {
1167 let package = package.as_table_mut()?;
1168 let local = package
1169 .get("source")
1170 .and_then(toml::Value::as_table)
1171 .is_some_and(|source| {
1172 source.contains_key("editable") || source.contains_key("virtual")
1173 });
1174 let is_own = local
1175 && package
1176 .get("name")
1177 .and_then(toml::Value::as_str)
1178 .is_some_and(|found| normalized(found) == wanted)
1179 && package.get("version").and_then(toml::Value::as_str) == Some(own);
1180 if is_own {
1181 package.remove("version");
1182 }
1183 }
1184 Some(value)
1185}
1186
1187fn untested_commits(
1192 context: &MintContext<'_>,
1193 base: &str,
1194 tip: &str,
1195) -> Result<Vec<String>, RkError> {
1196 let MintContext { target, driver, .. } = context;
1197 let refuse = |detail: String| {
1198 RkError::refusal(
1199 Diagnostic::new(Reason::StateDrift, detail)
1200 .expected("a checkout that holds the line and its candidate's commit")
1201 .action("fetch the line and its tags, then promote again")
1202 .target_state("unchanged"),
1203 )
1204 };
1205 let range = format!("{base}..{tip}");
1206 let listed = git(target, &["rev-list", "--reverse", &range, "--"])?;
1207 if !listed.status.success() {
1208 return Err(refuse(last_line(&listed.stderr)));
1209 }
1210 let name = adapter::package_name(target.as_std_path(), driver).unwrap_or_default();
1211 let mut untested = Vec::new();
1212 for commit in String::from_utf8_lossy(&listed.stdout).lines() {
1213 if !version_only(context, &name, commit)? {
1214 let subject = git(target, &["log", "-1", "--format=%h %s", commit])?;
1215 untested.push(String::from_utf8_lossy(&subject.stdout).trim().to_owned());
1216 }
1217 }
1218 Ok(untested)
1219}
1220
1221fn version_only(context: &MintContext<'_>, name: &str, commit: &str) -> Result<bool, RkError> {
1223 let MintContext {
1224 target,
1225 driver,
1226 file,
1227 ..
1228 } = context;
1229 let parent = format!("{commit}^1");
1230 let changed = git(target, &["diff", "--name-only", &parent, commit, "--"])?;
1231 if !changed.status.success() {
1232 return Ok(false);
1233 }
1234 let own_at = |rev: &str| -> Option<String> {
1235 let text = show(target, rev, file)?;
1236 adapter::version_in(driver, &text)
1237 };
1238 for path in String::from_utf8_lossy(&changed.stdout).lines() {
1239 let lockfile = (*driver == "rust" && path == "Cargo.lock")
1240 || (*driver == "python" && path == "uv.lock");
1241 if path != *file && !lockfile {
1242 return Ok(false);
1243 }
1244 let (Some(before), Some(after)) = (show(target, &parent, path), show(target, commit, path))
1245 else {
1246 return Ok(false);
1247 };
1248 let (own_before, own_after) = if lockfile {
1249 match (own_at(&parent), own_at(commit)) {
1250 (Some(before), Some(after)) => (before, after),
1251 _ => return Ok(false),
1252 }
1253 } else {
1254 (String::new(), String::new())
1255 };
1256 let sides = Sides {
1257 before: &before,
1258 after: &after,
1259 own_before: &own_before,
1260 own_after: &own_after,
1261 };
1262 if !same_but_version(driver, path, name, &sides) {
1263 return Ok(false);
1264 }
1265 }
1266 Ok(true)
1267}
1268
1269fn show(target: &Utf8Path, rev: &str, path: &str) -> Option<String> {
1271 let spec = format!("{rev}:{path}");
1272 let output = git(target, &["show", &spec]).ok()?;
1273 output
1274 .status
1275 .success()
1276 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1277}
1278
1279fn run_git(target: &Utf8Path, args: &[&str], expected: &str) -> Result<(), RkError> {
1281 let output = git(target, args)?;
1282 if output.status.success() {
1283 return Ok(());
1284 }
1285 Err(RkError::subprocess(
1286 Diagnostic::new(Reason::SubprocessFailed, last_line(&output.stderr))
1287 .expected(expected.to_owned())
1288 .target_state("the version file is written and not pushed"),
1289 ))
1290}
1291
1292fn empty_candidate_message(target: &Utf8Path) -> &'static str {
1296 let driver = crate::landing::manifest::load(target)
1297 .ok()
1298 .flatten()
1299 .and_then(|manifest| manifest.profile.release.driver);
1300 match driver.as_deref() {
1301 Some("rust" | "bash" | "python") => {
1302 "no candidate is tagged on the line yet; rk lines mint previews the first one, and the release workflow's candidate job mints it"
1303 }
1304 Some(_) => {
1305 "no candidate is tagged on the line; release-kit lands no candidate job for this driver, so the line's release request stays the human gate"
1306 }
1307 None => {
1308 "no candidate is tagged on the line yet; the target records no release driver, so rk binding <tech> names its candidate path"
1309 }
1310 }
1311}
1312
1313#[derive(Debug, Serialize)]
1315struct RetireReport {
1316 schema: &'static str,
1318 mode: &'static str,
1320 branch: String,
1322 #[serde(skip_serializing_if = "Option::is_none")]
1324 seat: Option<String>,
1325 next: Vec<String>,
1327}
1328
1329fn retire(line: &str, target: &Utf8Path, apply: bool, out: Output) -> Result<(), RkError> {
1330 let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
1331 if !branch_exists(target, &branch)? {
1332 return Err(RkError::refusal(
1333 Diagnostic::new(
1334 Reason::TargetNotFound,
1335 format!("no local {branch} to retire"),
1336 )
1337 .expected("a local release line")
1338 .action(format!(
1339 "the remote half stays yours either way: git push origin --delete {branch}"
1340 ))
1341 .target_state("unchanged"),
1342 ));
1343 }
1344 let uncovered = uncovered_commits(target, &branch)?;
1347 if !uncovered.is_empty() {
1348 return Err(RkError::refusal(
1349 Diagnostic::new(
1350 Reason::DestructiveRefusal,
1351 format!(
1352 "{branch} holds {} commit(s) no tag reaches, {} first",
1353 uncovered.len(),
1354 uncovered[0]
1355 ),
1356 )
1357 .expected("every line-only commit reachable from a tag")
1358 .action("tag what the line still owes — the release automation mints tags — or accept losing the commits is not offered")
1359 .target_state("unchanged"),
1360 ));
1361 }
1362 let tip = resolve_commit(target, &branch)?;
1363 let seats = inventory(target)?;
1364 let seat = seats
1365 .iter()
1366 .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
1367 .map(|seat| seat.path.clone());
1368 if !apply {
1369 if let Some(path) = &seat {
1370 out.result_line(format!(
1371 "would remove the seat {path}, then delete {branch}"
1372 ));
1373 } else {
1374 out.result_line(format!("would delete {branch} ({tip})"));
1375 }
1376 let next = vec![format!("rk lines retire {line} --target {target} --apply")];
1377 out.next(&next);
1378 return out.emit(&RetireReport {
1379 schema: "rk.lines-retire/1",
1380 mode: "preview",
1381 branch,
1382 seat: seat.map(|path| path.to_string()),
1383 next,
1384 });
1385 }
1386 if let Some(path) = &seat {
1390 let removed = git(target, &["worktree", "remove", path.as_str()])?;
1391 if !removed.status.success() {
1392 return Err(RkError::refusal(
1393 Diagnostic::new(Reason::DestructiveRefusal, last_line(&removed.stderr))
1394 .expected("a clean, unlocked seat")
1395 .action(format!(
1396 "resolve what the seat holds, then rerun; the branch {branch} survives"
1397 ))
1398 .target_state("unchanged"),
1399 ));
1400 }
1401 out.result_line(format!("removed the seat {path}"));
1402 }
1403 match maintenance::delete_branch(target, &branch, &tip) {
1404 Deletion::Deleted => out.result_line(format!("deleted {branch} ({tip})")),
1405 Deletion::ConfigSurvived { detail } => {
1406 out.result_line(format!("deleted {branch} ({tip}); {detail}"));
1407 }
1408 Deletion::Refused { detail } => {
1409 return Err(RkError::refusal(
1410 Diagnostic::new(Reason::StateDrift, detail)
1411 .expected("a tip that did not move after verification")
1412 .target_state("the branch survives"),
1413 ));
1414 }
1415 }
1416 let next = vec![format!(
1417 "git push origin --delete {branch} retires the remote half; the tags keep the line recoverable"
1418 )];
1419 out.next(&next);
1420 out.emit(&RetireReport {
1421 schema: "rk.lines-retire/1",
1422 mode: "apply",
1423 branch,
1424 seat: seat.map(|path| path.to_string()),
1425 next,
1426 })
1427}
1428
1429fn line_branch(line: &str, prefix: &str) -> Result<String, RkError> {
1431 let well_formed = line.split_once('.').is_some_and(|(major, minor)| {
1432 !major.is_empty()
1433 && !minor.is_empty()
1434 && major.bytes().all(|b| b.is_ascii_digit())
1435 && minor.bytes().all(|b| b.is_ascii_digit())
1436 });
1437 if !well_formed {
1438 return Err(RkError::Usage(format!(
1439 "'{line}' is not a line; a line is <major>.<minor>, as in 1.1"
1440 )));
1441 }
1442 Ok(format!("{prefix}{line}"))
1443}
1444
1445fn ref_names(target: &Utf8Path, prefix: &str) -> Result<Vec<String>, RkError> {
1447 let output = git(
1448 target,
1449 &["for-each-ref", "--format=%(refname:short)", prefix],
1450 )?;
1451 if !output.status.success() {
1452 return Err(RkError::refusal(
1453 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1454 .expected("a git repository at the target"),
1455 ));
1456 }
1457 Ok(String::from_utf8_lossy(&output.stdout)
1458 .lines()
1459 .map(str::to_owned)
1460 .collect())
1461}
1462
1463fn newest_tag(target: &Utf8Path, line: &str, candidates: bool) -> Result<Option<String>, RkError> {
1465 let pattern = format!("v{line}.*");
1466 let output = git(target, &["tag", "-l", &pattern, "--sort=-v:refname"])?;
1467 if !output.status.success() {
1468 return Err(RkError::refusal(
1469 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1470 .expected("a git repository at the target"),
1471 ));
1472 }
1473 Ok(String::from_utf8_lossy(&output.stdout)
1474 .lines()
1475 .find(|tag| tag.contains("-rc.") == candidates)
1476 .map(str::to_owned))
1477}
1478
1479fn uncovered_commits(target: &Utf8Path, branch: &str) -> Result<Vec<String>, RkError> {
1485 let prefix = crate::config::line_prefix_of(target.as_std_path())?;
1486 let trunk = crate::config::trunk_of(target.as_std_path())?;
1487 let line = branch.strip_prefix(&prefix).unwrap_or(branch);
1488 let mut args = vec![
1489 "rev-list".to_owned(),
1490 branch.to_owned(),
1491 "--not".to_owned(),
1492 format!("--tags=v{line}.*"),
1493 ];
1494 for reference in [trunk.clone(), format!("origin/{trunk}")] {
1495 if resolve_commit(target, &reference).is_ok() {
1496 args.push(reference);
1497 }
1498 }
1499 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1500 let output = git(target, &arg_refs)?;
1501 if !output.status.success() {
1502 return Err(RkError::refusal(
1503 Diagnostic::new(Reason::StateDrift, last_line(&output.stderr))
1504 .expected("a readable branch history"),
1505 ));
1506 }
1507 Ok(String::from_utf8_lossy(&output.stdout)
1508 .lines()
1509 .map(str::to_owned)
1510 .collect())
1511}
1512
1513fn branch_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
1515 let ref_name = format!("refs/heads/{branch}");
1516 Ok(
1517 git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
1518 .status
1519 .success(),
1520 )
1521}
1522
1523fn remote_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
1525 let ref_name = format!("refs/remotes/origin/{branch}");
1526 Ok(
1527 git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
1528 .status
1529 .success(),
1530 )
1531}
1532
1533fn resolve_commit(target: &Utf8Path, name: &str) -> Result<String, RkError> {
1535 let spec = format!("{name}^{{commit}}");
1536 let output = git(target, &["rev-parse", "--verify", "--quiet", &spec])?;
1537 if !output.status.success() {
1538 return Err(RkError::refusal(
1539 Diagnostic::new(
1540 Reason::Usage,
1541 format!("'{name}' does not resolve to a commit"),
1542 )
1543 .expected("a base git can resolve — fetch the tags first")
1544 .target_state("unchanged"),
1545 ));
1546 }
1547 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1548}
1549
1550fn inventory(target: &Utf8Path) -> Result<Vec<Worktree>, RkError> {
1552 let output = git(target, &["worktree", "list", "--porcelain", "-z"])?;
1553 if !output.status.success() {
1554 return Err(RkError::refusal(
1555 Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
1556 .expected("a git repository at the target"),
1557 ));
1558 }
1559 parse_worktrees(&output.stdout).map_err(|detail| {
1560 RkError::refusal(
1561 Diagnostic::new(Reason::StateDrift, detail).expected("a parseable worktree inventory"),
1562 )
1563 })
1564}
1565
1566fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1568 let mut command = std::process::Command::new(crate::probes::git_bin());
1569 for var in maintenance::GIT_HOOK_VARS {
1570 command.env_remove(var);
1571 }
1572 command
1573 .arg("-C")
1574 .arg(target.as_std_path())
1575 .args(args)
1576 .output()
1577 .map_err(|source| {
1578 RkError::subprocess(
1579 Diagnostic::new(
1580 Reason::SubprocessSpawn,
1581 format!("git did not run: {source}"),
1582 )
1583 .expected("git installed and on PATH"),
1584 )
1585 })
1586}
1587
1588fn last_line(bytes: &[u8]) -> String {
1590 String::from_utf8_lossy(bytes)
1591 .lines()
1592 .rev()
1593 .find(|line| !line.trim().is_empty())
1594 .unwrap_or("git reported no detail")
1595 .trim()
1596 .to_owned()
1597}
1598
1599#[cfg(test)]
1600mod tests {
1601 use super::{MintDecision, Version, decide_mint};
1602
1603 fn version(text: &str) -> Version {
1604 Version::parse(text).expect("a version this verb reads")
1605 }
1606
1607 #[test]
1608 fn the_base_is_the_next_patch_of_the_line() {
1609 let tags = [version("1.4.0"), version("1.4.2"), version("1.5.0")];
1610 assert_eq!(
1611 decide_mint((1, 4), &tags, Some(version("1.4.2"))),
1612 Ok(MintDecision::Mint(version("1.4.3-rc.1")))
1613 );
1614 assert_eq!(
1616 decide_mint((2, 0), &tags, Some(version("1.9.4"))),
1617 Ok(MintDecision::Mint(version("2.0.0-rc.1")))
1618 );
1619 }
1620
1621 #[test]
1622 fn the_number_follows_the_remote_candidates_of_the_base() {
1623 let tags = [
1624 version("1.4.2"),
1625 version("1.4.2-rc.7"),
1626 version("1.4.3-rc.1"),
1627 version("1.4.3-rc.2"),
1628 ];
1629 assert_eq!(
1630 decide_mint((1, 4), &tags, Some(version("1.4.3-rc.2"))),
1631 Ok(MintDecision::Mint(version("1.4.3-rc.3")))
1632 );
1633 let promoted = [version("1.4.3-rc.2"), version("1.4.3")];
1635 assert_eq!(
1636 decide_mint((1, 4), &promoted, Some(version("1.4.3"))),
1637 Ok(MintDecision::Mint(version("1.4.4-rc.1")))
1638 );
1639 }
1640
1641 #[test]
1642 fn an_untagged_candidate_of_the_base_is_pending() {
1643 let tags = [version("1.4.2"), version("1.4.3-rc.1")];
1644 assert_eq!(
1645 decide_mint((1, 4), &tags, Some(version("1.4.3-rc.2"))),
1646 Ok(MintDecision::Pending(version("1.4.3-rc.2")))
1647 );
1648 let released = [version("1.4.3")];
1651 assert!(decide_mint((1, 4), &released, Some(version("1.4.3-rc.2"))).is_err());
1652 }
1653}