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