1use 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
23pub 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#[derive(Debug, Serialize)]
50struct LineRow {
51 line: String,
53 branch: String,
55 presence: &'static str,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 newest_release: Option<String>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 newest_candidate: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
67 tag_covered: Option<bool>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 seat: Option<String>,
71}
72
73#[derive(Debug, Serialize)]
75struct ListReport {
76 schema: &'static str,
78 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#[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 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 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#[derive(Debug, Serialize)]
275struct RcReport {
276 schema: &'static str,
278 line: String,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 newest_release: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 newest_candidate: Option<String>,
286 #[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#[derive(Debug, Serialize)]
326struct RetireReport {
327 schema: &'static str,
329 mode: &'static str,
331 branch: String,
333 #[serde(skip_serializing_if = "Option::is_none")]
335 seat: Option<String>,
336 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 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 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
440fn 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
456fn 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
474fn 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
490fn 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
522fn 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
532fn 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
542fn 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
559fn 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
575fn 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
597fn 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}