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