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