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 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#[derive(Debug, Serialize)]
152struct OpenReport {
153 schema: &'static str,
155 mode: &'static str,
157 branch: String,
159 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 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#[derive(Debug, Serialize)]
276struct RcReport {
277 schema: &'static str,
279 line: String,
281 #[serde(skip_serializing_if = "Option::is_none")]
283 newest_release: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
286 newest_candidate: Option<String>,
287 #[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#[derive(Debug, Serialize)]
327struct RetireReport {
328 schema: &'static str,
330 mode: &'static str,
332 branch: String,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 seat: Option<String>,
337 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 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 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
441fn 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
457fn 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
475fn 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
491fn 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
525fn 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
535fn 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
545fn 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
562fn 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
578fn 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
600fn 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}