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 three 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`); its
7//! candidates and releases are tags automation minted, which `rc` only
8//! reads; and it retires only behind its tags, seat before branch, with
9//! the remote deletion left to the operator
10//! (`maintenance:a-line-is-never-retired-before-its-tags`). Every
11//! mutating verb previews by default.
12
13use camino::Utf8Path;
14use serde::Serialize;
15
16use crate::cli::lines::{LinesAction, LinesArgs};
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::maintenance::{self, Deletion};
20use crate::output::Output;
21use crate::worktree::{Worktree, parse_worktrees};
22
23/// Route one `rk lines` invocation.
24///
25/// # Errors
26///
27/// Each verb's own refusals; every failure is typed.
28pub fn run(args: &LinesArgs) -> Result<(), RkError> {
29    match &args.action {
30        LinesAction::List { target, json } => list(target, Output::new(*json)),
31        LinesAction::Open {
32            line,
33            base,
34            target,
35            apply,
36            json,
37        } => open(line, base.as_deref(), target, *apply, Output::new(*json)),
38        LinesAction::Rc { line, target, json } => rc(line, target, Output::new(*json)),
39        LinesAction::Retire {
40            line,
41            target,
42            apply,
43            json,
44        } => retire(line, target, *apply, Output::new(*json)),
45    }
46}
47
48/// One line's row in the inventory.
49#[derive(Debug, Clone, Serialize)]
50pub struct LineRow {
51    /// The `<major>.<minor>` name.
52    pub line: String,
53    /// The branch, `release/<line>`.
54    pub branch: String,
55    /// Where the branch exists: `local`, `remote`, or `both`.
56    pub presence: &'static str,
57    /// The newest `v<line>.*` release tag, absent while none exists.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub newest_release: Option<String>,
60    /// The newest `v<line>.*-rc.*` candidate tag, absent while none exists.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub newest_candidate: Option<String>,
63    /// Whether every commit the local branch holds beyond the trunk is
64    /// reachable from a tag — what makes a retirement safe. Absent for a
65    /// remote-only line, which this clone cannot judge.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub tag_covered: Option<bool>,
68    /// The worktree seating the branch, where one exists.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub seat: Option<String>,
71}
72
73/// The machine form of `rk lines list`.
74#[derive(Debug, Serialize)]
75struct ListReport {
76    /// The shape version of this document.
77    schema: &'static str,
78    /// Every line, sorted by name.
79    lines: Vec<LineRow>,
80}
81
82fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
83    let rows = rows(target)?;
84    if rows.is_empty() {
85        out.result_line("no release lines; the trunk is the only line alive");
86    }
87    for row in &rows {
88        let mut parts = vec![format!("{} ({})", row.branch, row.presence)];
89        if let Some(tag) = &row.newest_release {
90            parts.push(format!("newest release {tag}"));
91        }
92        if let Some(tag) = &row.newest_candidate {
93            parts.push(format!("newest candidate {tag}"));
94        }
95        match row.tag_covered {
96            Some(true) => parts.push("tag-covered".to_owned()),
97            Some(false) => parts.push("commits beyond the tags; not retirable".to_owned()),
98            None => {}
99        }
100        if let Some(seat) = &row.seat {
101            parts.push(format!("seated at {seat}"));
102        }
103        out.result_line(parts.join(" — "));
104    }
105    out.emit(&ListReport {
106        schema: "rk.lines-list/1",
107        lines: rows,
108    })
109}
110
111/// Every release line at `target`, sorted by name, read offline from the
112/// clone: the inventory `rk lines list` prints and `rk release status`
113/// reports.
114///
115/// # Errors
116///
117/// A configuration that does not load, and a git call that fails.
118pub fn rows(target: &Utf8Path) -> Result<Vec<LineRow>, RkError> {
119    let prefix = crate::config::line_prefix_of(target.as_std_path())?;
120    let local = ref_names(target, &format!("refs/heads/{prefix}"))?;
121    let remote: Vec<String> = ref_names(target, &format!("refs/remotes/origin/{prefix}"))?
122        .into_iter()
123        .filter_map(|name| name.strip_prefix("origin/").map(str::to_owned))
124        .collect();
125    let mut names: Vec<String> = local.iter().chain(remote.iter()).cloned().collect();
126    names.sort();
127    names.dedup();
128    let seats = inventory(target)?;
129    let mut rows = Vec::new();
130    for branch in names {
131        let Some(line) = branch.strip_prefix(&prefix) else {
132            continue;
133        };
134        let is_local = local.contains(&branch);
135        let presence = match (is_local, remote.contains(&branch)) {
136            (true, true) => "both",
137            (true, false) => "local",
138            _ => "remote",
139        };
140        rows.push(LineRow {
141            line: line.to_owned(),
142            branch: branch.clone(),
143            presence,
144            newest_release: newest_tag(target, line, false)?,
145            newest_candidate: newest_tag(target, line, true)?,
146            tag_covered: if is_local {
147                Some(uncovered_commits(target, &branch)?.is_empty())
148            } else {
149                None
150            },
151            seat: seats
152                .iter()
153                .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
154                .map(|seat| seat.path.to_string()),
155        });
156    }
157    Ok(rows)
158}
159
160/// The machine form of `rk lines open`, on every path: the seat is the
161/// worktree verb's own job, named as the next action rather than run here,
162/// so the verb's one schema holds in both workflow modes.
163#[derive(Debug, Serialize)]
164struct OpenReport {
165    /// The shape version of this document.
166    schema: &'static str,
167    /// `preview`, `created`, or `satisfied`.
168    mode: &'static str,
169    /// The branch the verb acted on.
170    branch: String,
171    /// What follows, in order.
172    next: Vec<String>,
173}
174
175fn open(
176    line: &str,
177    base: Option<&str>,
178    target: &Utf8Path,
179    apply: bool,
180    out: Output,
181) -> Result<(), RkError> {
182    let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
183    let Some(base) = base else {
184        return Err(RkError::Usage(format!(
185            "a line is a snapshot of a chosen commit, so {branch} takes no default base; pass --base \"v<version>\", the tag it patches"
186        )));
187    };
188    if branch_exists(target, &branch)? {
189        out.result_line(format!(
190            "satisfied: {branch} already exists; the open adopts it"
191        ));
192        let next = vec![format!(
193            "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
194        )];
195        return out.emit(&OpenReport {
196            schema: "rk.lines-open/1",
197            mode: "satisfied",
198            branch,
199            next,
200        });
201    }
202    // A remote-only line is adopted at its own tip, never recreated from
203    // the base: the line may have advanced past the tag it was cut from,
204    // and a local branch behind the remote cannot push.
205    let remote = format!("origin/{branch}");
206    if remote_exists(target, &branch)? {
207        if !apply {
208            out.result_line(format!(
209                "DRY RUN: would adopt {branch} from {remote}, tracking it at the remote tip; --base is not used, because the line already exists"
210            ));
211            let next = vec![format!(
212                "rk lines open {line} --base \"{base}\" --target {target} --apply"
213            )];
214            out.next(&next);
215            return out.emit(&OpenReport {
216                schema: "rk.lines-open/1",
217                mode: "preview",
218                branch,
219                next,
220            });
221        }
222        let tracked = git(target, &["branch", "--track", &branch, &remote])?;
223        if !tracked.status.success() {
224            return Err(RkError::refusal(
225                Diagnostic::new(Reason::StateDrift, last_line(&tracked.stderr))
226                    .expected("a tracking branch git can create from the remote line")
227                    .target_state("unchanged"),
228            ));
229        }
230        out.result_line(format!(
231            "adopted {branch} from {remote} at the remote tip; --base was not used, because the line already exists"
232        ));
233        let next = vec![format!(
234            "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
235        )];
236        out.next(&next);
237        return out.emit(&OpenReport {
238            schema: "rk.lines-open/1",
239            mode: "created",
240            branch,
241            next,
242        });
243    }
244    let resolved = resolve_commit(target, base)?;
245    if !apply {
246        out.result_line(format!(
247            "DRY RUN: would create {branch} at {base} ({resolved})"
248        ));
249        let next = vec![format!(
250            "rk lines open {line} --base \"{base}\" --target {target} --apply"
251        )];
252        out.next(&next);
253        return out.emit(&OpenReport {
254            schema: "rk.lines-open/1",
255            mode: "preview",
256            branch,
257            next,
258        });
259    }
260    let created = git(target, &["branch", &branch, &resolved])?;
261    if !created.status.success() {
262        return Err(RkError::refusal(
263            Diagnostic::new(Reason::StateDrift, last_line(&created.stderr))
264                .expected("a branch git can create at the named base")
265                .target_state("unchanged"),
266        ));
267    }
268    out.result_line(format!("created {branch} at {base} ({resolved})"));
269    let next = vec![
270        format!(
271            "rk worktree add {branch} --apply seats it; in the branches mode, git checkout {branch} works in the main checkout"
272        ),
273        format!("git push -u origin {branch} publishes it with its upstream set"),
274        "rk setup step protect-release-lines --apply protects every line, once per repository"
275            .to_owned(),
276    ];
277    out.next(&next);
278    out.emit(&OpenReport {
279        schema: "rk.lines-open/1",
280        mode: "created",
281        branch,
282        next,
283    })
284}
285
286/// The machine form of `rk lines rc`.
287#[derive(Debug, Serialize)]
288struct RcReport {
289    /// The shape version of this document.
290    schema: &'static str,
291    /// The line the verb read.
292    line: String,
293    /// The newest `v<line>.*` release tag, absent while none exists.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    newest_release: Option<String>,
296    /// The newest candidate tag, absent while none exists.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    newest_candidate: Option<String>,
299    /// The number the next candidate takes, absent while none exists yet.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    next_candidate: Option<u64>,
302}
303
304fn rc(line: &str, target: &Utf8Path, out: Output) -> Result<(), RkError> {
305    line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
306    let newest_release = newest_tag(target, line, false)?;
307    let newest_candidate = newest_tag(target, line, true)?;
308    let next_candidate = newest_candidate
309        .as_deref()
310        .and_then(|tag| tag.rsplit_once("-rc.")?.1.parse::<u64>().ok())
311        .map(|n| n + 1);
312    match &newest_candidate {
313        Some(tag) => {
314            out.result_line(format!("newest candidate {tag}"));
315            if let Some(next) = next_candidate {
316                out.result_line(format!(
317                    "the next candidate is rc.{next}; an rc number is single-use"
318                ));
319            }
320        }
321        None => out.result_line(
322            "no candidate is tagged on the line; this verb only reads them — a candidate arrives where the binding wires an rc path, and minting one is open work otherwise",
323        ),
324    }
325    if let Some(tag) = &newest_release {
326        out.result_line(format!("newest release {tag}"));
327    }
328    out.emit(&RcReport {
329        schema: "rk.lines-rc/1",
330        line: line.to_owned(),
331        newest_release,
332        newest_candidate,
333        next_candidate,
334    })
335}
336
337/// The machine form of `rk lines retire`.
338#[derive(Debug, Serialize)]
339struct RetireReport {
340    /// The shape version of this document.
341    schema: &'static str,
342    /// `preview` or `apply`.
343    mode: &'static str,
344    /// The branch the verb acted on.
345    branch: String,
346    /// The seat that stood — removed under apply — where one existed.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    seat: Option<String>,
349    /// What stays the operator's, in order.
350    next: Vec<String>,
351}
352
353fn retire(line: &str, target: &Utf8Path, apply: bool, out: Output) -> Result<(), RkError> {
354    let branch = line_branch(line, &crate::config::line_prefix_of(target.as_std_path())?)?;
355    if !branch_exists(target, &branch)? {
356        return Err(RkError::refusal(
357            Diagnostic::new(
358                Reason::TargetNotFound,
359                format!("no local {branch} to retire"),
360            )
361            .expected("a local release line")
362            .action(format!(
363                "the remote half stays yours either way: git push origin --delete {branch}"
364            ))
365            .target_state("unchanged"),
366        ));
367    }
368    // The tag gate: every commit the line holds beyond the trunk must be
369    // reachable from a tag, or the deletion garbage-collects the line.
370    let uncovered = uncovered_commits(target, &branch)?;
371    if !uncovered.is_empty() {
372        return Err(RkError::refusal(
373            Diagnostic::new(
374                Reason::DestructiveRefusal,
375                format!(
376                    "{branch} holds {} commit(s) no tag reaches, {} first",
377                    uncovered.len(),
378                    uncovered[0]
379                ),
380            )
381            .expected("every line-only commit reachable from a tag")
382            .action("tag what the line still owes — the release automation mints tags — or accept losing the commits is not offered")
383            .target_state("unchanged"),
384        ));
385    }
386    let tip = resolve_commit(target, &branch)?;
387    let seats = inventory(target)?;
388    let seat = seats
389        .iter()
390        .find(|seat| seat.branch.as_deref() == Some(branch.as_str()))
391        .map(|seat| seat.path.clone());
392    if !apply {
393        if let Some(path) = &seat {
394            out.result_line(format!(
395                "would remove the seat {path}, then delete {branch}"
396            ));
397        } else {
398            out.result_line(format!("would delete {branch} ({tip})"));
399        }
400        let next = vec![format!("rk lines retire {line} --target {target} --apply")];
401        out.next(&next);
402        return out.emit(&RetireReport {
403            schema: "rk.lines-retire/1",
404            mode: "preview",
405            branch,
406            seat: seat.map(|path| path.to_string()),
407            next,
408        });
409    }
410    // Seat before branch: a worktree holds the checkout, so the branch
411    // deletion below would otherwise refuse — and git itself refuses a
412    // dirty or locked seat, which is the guard this verb wants.
413    if let Some(path) = &seat {
414        let removed = git(target, &["worktree", "remove", path.as_str()])?;
415        if !removed.status.success() {
416            return Err(RkError::refusal(
417                Diagnostic::new(Reason::DestructiveRefusal, last_line(&removed.stderr))
418                    .expected("a clean, unlocked seat")
419                    .action(format!(
420                        "resolve what the seat holds, then rerun; the branch {branch} survives"
421                    ))
422                    .target_state("unchanged"),
423            ));
424        }
425        out.result_line(format!("removed the seat {path}"));
426    }
427    match maintenance::delete_branch(target, &branch, &tip) {
428        Deletion::Deleted => out.result_line(format!("deleted {branch} ({tip})")),
429        Deletion::ConfigSurvived { detail } => {
430            out.result_line(format!("deleted {branch} ({tip}); {detail}"));
431        }
432        Deletion::Refused { detail } => {
433            return Err(RkError::refusal(
434                Diagnostic::new(Reason::StateDrift, detail)
435                    .expected("a tip that did not move after verification")
436                    .target_state("the branch survives"),
437            ));
438        }
439    }
440    let next = vec![format!(
441        "git push origin --delete {branch} retires the remote half; the tags keep the line recoverable"
442    )];
443    out.next(&next);
444    out.emit(&RetireReport {
445        schema: "rk.lines-retire/1",
446        mode: "apply",
447        branch,
448        seat: seat.map(|path| path.to_string()),
449        next,
450    })
451}
452
453/// `<prefix><line>` for a well-formed `<major>.<minor>`.
454fn line_branch(line: &str, prefix: &str) -> Result<String, RkError> {
455    let well_formed = line.split_once('.').is_some_and(|(major, minor)| {
456        !major.is_empty()
457            && !minor.is_empty()
458            && major.bytes().all(|b| b.is_ascii_digit())
459            && minor.bytes().all(|b| b.is_ascii_digit())
460    });
461    if !well_formed {
462        return Err(RkError::Usage(format!(
463            "'{line}' is not a line; a line is <major>.<minor>, as in 1.1"
464        )));
465    }
466    Ok(format!("{prefix}{line}"))
467}
468
469/// The short names under one ref prefix.
470fn ref_names(target: &Utf8Path, prefix: &str) -> Result<Vec<String>, RkError> {
471    let output = git(
472        target,
473        &["for-each-ref", "--format=%(refname:short)", prefix],
474    )?;
475    if !output.status.success() {
476        return Err(RkError::refusal(
477            Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
478                .expected("a git repository at the target"),
479        ));
480    }
481    Ok(String::from_utf8_lossy(&output.stdout)
482        .lines()
483        .map(str::to_owned)
484        .collect())
485}
486
487/// The newest `v<line>.*` tag, candidates or releases.
488fn newest_tag(target: &Utf8Path, line: &str, candidates: bool) -> Result<Option<String>, RkError> {
489    let pattern = format!("v{line}.*");
490    let output = git(target, &["tag", "-l", &pattern, "--sort=-v:refname"])?;
491    if !output.status.success() {
492        return Err(RkError::refusal(
493            Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
494                .expected("a git repository at the target"),
495        ));
496    }
497    Ok(String::from_utf8_lossy(&output.stdout)
498        .lines()
499        .find(|tag| tag.contains("-rc.") == candidates)
500        .map(str::to_owned))
501}
502
503/// The commits the branch holds that neither the line's own tags nor a
504/// trunk ref reaches. The negation is the line's `v<line>.*` pattern, not
505/// every tag: an unrelated tag that happens to reach the tip is not what
506/// makes a retirement safe, per
507/// `maintenance:a-line-is-never-retired-before-its-tags`.
508fn uncovered_commits(target: &Utf8Path, branch: &str) -> Result<Vec<String>, RkError> {
509    let prefix = crate::config::line_prefix_of(target.as_std_path())?;
510    let trunk = crate::config::trunk_of(target.as_std_path())?;
511    let line = branch.strip_prefix(&prefix).unwrap_or(branch);
512    let mut args = vec![
513        "rev-list".to_owned(),
514        branch.to_owned(),
515        "--not".to_owned(),
516        format!("--tags=v{line}.*"),
517    ];
518    for reference in [trunk.clone(), format!("origin/{trunk}")] {
519        if resolve_commit(target, &reference).is_ok() {
520            args.push(reference);
521        }
522    }
523    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
524    let output = git(target, &arg_refs)?;
525    if !output.status.success() {
526        return Err(RkError::refusal(
527            Diagnostic::new(Reason::StateDrift, last_line(&output.stderr))
528                .expected("a readable branch history"),
529        ));
530    }
531    Ok(String::from_utf8_lossy(&output.stdout)
532        .lines()
533        .map(str::to_owned)
534        .collect())
535}
536
537/// Whether a local branch exists.
538fn branch_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
539    let ref_name = format!("refs/heads/{branch}");
540    Ok(
541        git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
542            .status
543            .success(),
544    )
545}
546
547/// Whether the origin remote tracks the branch.
548fn remote_exists(target: &Utf8Path, branch: &str) -> Result<bool, RkError> {
549    let ref_name = format!("refs/remotes/origin/{branch}");
550    Ok(
551        git(target, &["show-ref", "--verify", "--quiet", &ref_name])?
552            .status
553            .success(),
554    )
555}
556
557/// One commit-ish resolved to its commit, or a refusal naming it.
558fn resolve_commit(target: &Utf8Path, name: &str) -> Result<String, RkError> {
559    let spec = format!("{name}^{{commit}}");
560    let output = git(target, &["rev-parse", "--verify", "--quiet", &spec])?;
561    if !output.status.success() {
562        return Err(RkError::refusal(
563            Diagnostic::new(
564                Reason::Usage,
565                format!("'{name}' does not resolve to a commit"),
566            )
567            .expected("a base git can resolve — fetch the tags first")
568            .target_state("unchanged"),
569        ));
570    }
571    Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
572}
573
574/// The worktree inventory, for the seat lookups.
575fn inventory(target: &Utf8Path) -> Result<Vec<Worktree>, RkError> {
576    let output = git(target, &["worktree", "list", "--porcelain", "-z"])?;
577    if !output.status.success() {
578        return Err(RkError::refusal(
579            Diagnostic::new(Reason::TargetNotFound, last_line(&output.stderr))
580                .expected("a git repository at the target"),
581        ));
582    }
583    parse_worktrees(&output.stdout).map_err(|detail| {
584        RkError::refusal(
585            Diagnostic::new(Reason::StateDrift, detail).expected("a parseable worktree inventory"),
586        )
587    })
588}
589
590/// Run one git command against the target, spawn failure typed.
591fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
592    let mut command = std::process::Command::new(crate::probes::git_bin());
593    for var in maintenance::GIT_HOOK_VARS {
594        command.env_remove(var);
595    }
596    command
597        .arg("-C")
598        .arg(target.as_std_path())
599        .args(args)
600        .output()
601        .map_err(|source| {
602            RkError::subprocess(
603                Diagnostic::new(
604                    Reason::SubprocessSpawn,
605                    format!("git did not run: {source}"),
606                )
607                .expected("git installed and on PATH"),
608            )
609        })
610}
611
612/// The last non-empty stderr line, for a one-line detail.
613fn last_line(bytes: &[u8]) -> String {
614    String::from_utf8_lossy(bytes)
615        .lines()
616        .rev()
617        .find(|line| !line.trim().is_empty())
618        .unwrap_or("git reported no detail")
619        .trim()
620        .to_owned()
621}