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