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