Skip to main content

release_kit/commands/
lines.rs

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