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