1use 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
24pub 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#[derive(Debug, Serialize)]
51struct LineRow {
52 line: String,
54 branch: String,
56 presence: &'static str,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 newest_release: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 newest_candidate: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
68 tag_covered: Option<bool>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 seat: Option<String>,
72}
73
74#[derive(Debug, Serialize)]
76struct ListReport {
77 schema: &'static str,
79 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#[derive(Debug, Serialize)]
151struct OpenReport {
152 schema: &'static str,
154 mode: &'static str,
156 branch: String,
158 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 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#[derive(Debug, Serialize)]
246struct RcReport {
247 schema: &'static str,
249 line: String,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 newest_release: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 newest_candidate: Option<String>,
257 #[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#[derive(Debug, Serialize)]
297struct RetireReport {
298 schema: &'static str,
300 mode: &'static str,
302 branch: String,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 seat: Option<String>,
307 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 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 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
411fn 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
427fn 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
445fn 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
461fn 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
488fn 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
498fn 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
515fn 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
531fn 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
553fn 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}