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