1use camino::{Utf8Path, Utf8PathBuf};
12use serde::Serialize;
13
14use crate::cli::issue::{IssueAction, IssueArgs};
15use crate::detect::{self, Forge};
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18use crate::issue::{self, Resolved};
19use crate::landing::manifest::{self, CheckoutMode};
20use crate::output::Output;
21use crate::probes;
22use crate::setup::context::resolve_cli;
23
24#[derive(Debug, Serialize)]
26struct StartReport {
27 schema: &'static str,
29 mode: &'static str,
31 forge: &'static str,
33 repo: String,
35 issue: u64,
37 title: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 branch: Option<String>,
42 origin: &'static str,
44 checkout_mode: &'static str,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 path: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 checkout: Option<String>,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
54 others: Vec<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 detail: Option<String>,
58 next: Vec<String>,
60}
61
62pub fn run(args: &IssueArgs) -> Result<(), RkError> {
71 match &args.action {
72 IssueAction::Start {
73 issue,
74 target,
75 forge,
76 repo,
77 workflow,
78 base,
79 apply,
80 json,
81 } => start(
82 target,
83 issue,
84 &Overrides {
85 forge: forge.as_deref(),
86 repo: repo.as_deref(),
87 workflow: workflow.as_deref(),
88 base: base.as_deref(),
89 },
90 *apply,
91 Output::new(*json),
92 ),
93 }
94}
95
96struct Overrides<'a> {
99 forge: Option<&'a str>,
101 repo: Option<&'a str>,
103 workflow: Option<&'a str>,
105 base: Option<&'a str>,
107}
108
109struct Ground {
111 forge: Forge,
113 repo: String,
115 api_host: Option<String>,
124 workflow: CheckoutMode,
126 workflow_source: &'static str,
128}
129
130fn contradicts(what: &str, chosen: Option<&str>, known: Option<&str>) -> Result<(), RkError> {
135 let (Some(chosen), Some(known)) = (chosen, known) else {
136 return Ok(());
137 };
138 if chosen == known {
139 return Ok(());
140 }
141 Err(RkError::Usage(format!(
142 "the {what} to act on is {chosen} and this clone's is {known}; the branch would be minted on one project and seated in another"
143 )))
144}
145
146fn mode_of(
153 target: &Utf8Path,
154 named: Option<&str>,
155) -> Result<(CheckoutMode, &'static str), RkError> {
156 let recorded = manifest::load(target)?.map(|held| held.git.checkout_mode);
157 match (named, recorded) {
158 (Some(raw), Some(held)) => {
159 if CheckoutMode::parse(raw)? != held {
160 return Err(RkError::refusal(
161 Diagnostic::new(
162 Reason::StateDrift,
163 format!(
164 "--checkout-mode {raw} disagrees with the landing record, which states {}",
165 held.as_str()
166 ),
167 )
168 .expected("a flag that states the recorded mode, or no flag at all")
169 .action("rk upgrade --checkout-mode <mode> --apply changes the recorded mode")
170 .target_state("unchanged"),
171 ));
172 }
173 Ok((held, "the landing record, restated by --checkout-mode"))
174 }
175 (Some(raw), None) => Ok((CheckoutMode::parse(raw)?, "the --checkout-mode flag")),
176 (None, Some(held)) => Ok((held, "the landing record")),
177 (None, None) => Ok((
182 CheckoutMode::LinkedWorktree,
183 "the default, with no landing record",
184 )),
185 }
186}
187
188fn reachable(forge: Forge, host: Option<&str>) -> Result<(), RkError> {
194 let Some(host) = host else { return Ok(()) };
195 if forge != Forge::Github || host.eq_ignore_ascii_case("github.com") {
196 return Ok(());
197 }
198 Err(RkError::refusal(
199 Diagnostic::new(
200 Reason::ForgeUnsupported,
201 format!("this clone's origin is {host}, and rk issue start reaches github.com alone"),
202 )
203 .expected("a github.com remote, or a GitLab project")
204 .action(
205 "start the branch with gh issue develop --repo <host>/<owner>/<name>, then rk worktree add it",
206 )
207 .target_state("unchanged"),
208 ))
209}
210
211fn ground(
214 target: &Utf8Path,
215 reference: &issue::Reference,
216 overrides: &Overrides<'_>,
217) -> Result<Ground, RkError> {
218 if !target.is_dir() {
219 return Err(RkError::missing(
220 Diagnostic::new(
221 Reason::TargetNotFound,
222 format!("target {target} is not a directory"),
223 )
224 .expected("an existing repository to act on"),
225 ));
226 }
227 let named = overrides
228 .forge
229 .map(|name| {
230 Forge::parse(name).ok_or_else(|| {
231 RkError::Usage(format!(
232 "unknown forge '{name}'; the forges are: github, gitlab"
233 ))
234 })
235 })
236 .transpose()?;
237 let detected = detect::detect(target.as_std_path());
238 issue::agrees(reference, &detected).map_err(RkError::Usage)?;
242 let Some(forge) = named.or(detected.forge) else {
243 let diagnostic = detected
244 .host
245 .as_ref()
246 .map_or_else(
247 || {
248 Diagnostic::new(
249 Reason::ForgeUndetected,
250 "no forge detected: the target has no origin remote",
251 )
252 },
253 |host| {
254 Diagnostic::new(
255 Reason::ForgeUndetected,
256 format!("no forge detected: the host {host} is not recognized"),
257 )
258 },
259 )
260 .expected("a github.com or gitlab remote, or an override")
261 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
262 return Err(if detected.host.is_some() {
263 RkError::refusal(diagnostic)
264 } else {
265 RkError::missing(diagnostic)
266 });
267 };
268 reachable(
272 forge,
273 detected.host.as_deref().or(reference.host.as_deref()),
274 )?;
275 contradicts(
280 "forge",
281 named.map(Forge::as_str),
282 detected.forge.map(Forge::as_str),
283 )?;
284 let Some(repo) = overrides
285 .repo
286 .map(str::to_owned)
287 .or_else(|| reference.repo.clone())
288 .or_else(|| detected.repo.clone())
289 else {
290 return Err(RkError::missing(
291 Diagnostic::new(
292 Reason::ForgeUndetected,
293 "no repository detected: the target has no origin remote",
294 )
295 .expected("an origin remote naming the project")
296 .action("pass --repo <owner/name>"),
297 ));
298 };
299 contradicts("repository", Some(repo.as_str()), detected.repo.as_deref())?;
300 contradicts("repository", Some(repo.as_str()), reference.repo.as_deref())?;
301 let (workflow, workflow_source) = mode_of(target, overrides.workflow)?;
302 Ok(Ground {
303 forge,
304 repo,
305 api_host: reference.host.clone(),
306 workflow,
307 workflow_source,
308 })
309}
310
311fn start(
313 target: &Utf8Path,
314 reference: &str,
315 overrides: &Overrides<'_>,
316 apply: bool,
317 out: Output,
318) -> Result<(), RkError> {
319 let reference = issue::parse_reference(reference).map_err(RkError::Usage)?;
320 let ground = ground(target, &reference, overrides)?;
321 let main = crate::commands::worktree::main_checkout(target)?;
325 probes::require_forge_cli(ground.forge)?;
328 let cli = resolve_cli(ground.forge)?;
329 let seatable = |branch: &str| -> Result<(), RkError> {
332 match ground.workflow {
333 CheckoutMode::LinkedWorktree => {
334 crate::commands::worktree::plan_seat(target, branch, overrides.base, false)
335 .map(|_| ())
336 }
337 CheckoutMode::MainWorktree => branch_seatable(&main, branch),
338 }
339 };
340 let resolved = issue::resolve(
341 &cli,
342 target.as_std_path(),
343 &issue::Ask {
344 forge: ground.forge,
345 repo: &ground.repo,
346 reference: &reference,
347 host: ground.api_host.as_deref(),
348 base: overrides.base,
349 apply,
350 seatable: &seatable,
351 },
352 )?;
353 match ground.workflow {
354 CheckoutMode::LinkedWorktree => {
355 seat_worktree(target, &ground, &resolved, overrides.base, apply, out)
356 }
357 CheckoutMode::MainWorktree => seat_branch(&main, &ground, &resolved, apply, out),
358 }
359}
360
361fn seat_worktree(
364 target: &Utf8Path,
365 ground: &Ground,
366 resolved: &Resolved,
367 base: Option<&str>,
368 apply: bool,
369 out: Output,
370) -> Result<(), RkError> {
371 let Some(branch) = resolved.branch.as_deref() else {
372 return report(out, ground, resolved, None, None, apply);
373 };
374 let seat = crate::commands::worktree::plan_seat(target, branch, base, apply)?;
375 let mut note = None;
376 let path = match seat {
377 crate::commands::worktree::Seat::Satisfied { path } => path,
378 crate::commands::worktree::Seat::Fresh {
379 path,
380 source,
381 detail,
382 } => {
383 if apply && let Some(why) = detail {
388 return Err(stale_refs(branch, resolved, &why));
389 }
390 if !matches!(source.kind, "adopted" | "remote") {
397 if apply {
398 return Err(unreachable_tip(branch, resolved));
399 }
400 note = Some(format!(
401 "origin/{branch} is not in this clone yet; the apply fetches first, and refuses rather than seat a branch from the trunk"
402 ));
403 }
404 if apply {
405 crate::commands::worktree::create_seat(target, &source)?;
406 }
407 path
408 }
409 };
410 report_with(out, ground, resolved, Some(path), None, apply, note)
411}
412
413fn unreachable_tip(branch: &str, resolved: &Resolved) -> RkError {
416 RkError::refusal(
417 Diagnostic::new(
418 Reason::StateDrift,
419 format!("the forge carries {branch} and this clone cannot reach its tip"),
420 )
421 .expected(format!(
422 "origin/{branch} present, or {branch} already local"
423 ))
424 .action("git fetch origin, then rerun")
425 .target_state(format!(
426 "unchanged; issue #{} keeps its branch at the forge",
427 resolved.number
428 )),
429 )
430}
431
432fn branch_seatable(main: &Utf8Path, branch: &str) -> Result<(), RkError> {
446 let trunk = crate::config::trunk_of(main.as_std_path())?;
447 if let Some(seat) = crate::commands::worktree::seat_of(main, branch)? {
448 if seat != main {
449 return Err(RkError::refusal(
450 Diagnostic::new(
451 Reason::StateDrift,
452 format!(
453 "branch {branch} is checked out at {seat}, and one branch has one seat"
454 ),
455 )
456 .expected("the branch free, or already in the main checkout")
457 .action(format!("git -C {seat} switch {trunk}, then rerun"))
458 .target_state("unchanged"),
459 ));
460 }
461 return Ok(());
464 }
465 let held = crate::commands::worktree::git(main, &["status", "--porcelain"])?;
466 if !held.status.success() || !held.stdout.is_empty() {
469 return Err(RkError::refusal(
470 Diagnostic::new(
471 Reason::StateDrift,
472 format!("{main} carries uncommitted work, and this mode checks {branch} out there"),
473 )
474 .expected("a clean main checkout to seat the branch in")
475 .action("commit or stash the work, then rerun")
476 .target_state("unchanged"),
477 ));
478 }
479 Ok(())
480}
481
482fn stale_refs(branch: &str, resolved: &Resolved, why: &str) -> RkError {
484 RkError::refusal(
485 Diagnostic::new(
486 Reason::StateDrift,
487 format!(
488 "this clone could not refresh from the forge, so its {branch} may be stale: {why}"
489 ),
490 )
491 .expected("a fetch that answered, so the seat starts from the tip the forge holds")
492 .action("git fetch origin, then rerun")
493 .target_state(format!(
494 "unchanged; issue #{} keeps its branch at the forge",
495 resolved.number
496 )),
497 )
498}
499
500fn seat_branch(
509 main: &Utf8Path,
510 ground: &Ground,
511 resolved: &Resolved,
512 apply: bool,
513 out: Output,
514) -> Result<(), RkError> {
515 let Some(branch) = resolved.branch.as_deref() else {
516 return report(out, ground, resolved, None, None, apply);
517 };
518 if !apply {
519 return report(out, ground, resolved, None, Some(branch.to_owned()), false);
520 }
521 let git = |args: &[&str]| crate::commands::worktree::git(main, args);
525 let fetched = git(&["fetch", "origin"])?;
526 if !fetched.status.success() {
527 return Err(stale_refs(branch, resolved, &last_line(&fetched.stderr)));
528 }
529 let local = git(&[
530 "rev-parse",
531 "--verify",
532 "--quiet",
533 "--end-of-options",
534 &format!("refs/heads/{branch}^{{commit}}"),
535 ])?;
536 let switched = if local.status.success() {
537 git(&["switch", branch])?
538 } else {
539 git(&[
540 "switch",
541 "--track",
542 "-c",
543 branch,
544 &format!("refs/remotes/origin/{branch}"),
545 ])?
546 };
547 if !switched.status.success() {
548 return Err(RkError::subprocess(
551 Diagnostic::new(
552 Reason::SubprocessFailed,
553 format!(
554 "git refused to check out {branch}: {}",
555 last_line(&switched.stderr)
556 ),
557 )
558 .expected("a working tree the checkout can move")
559 .target_state("the branch exists on the forge and is not checked out here"),
560 ));
561 }
562 report(out, ground, resolved, None, Some(branch.to_owned()), true)
563}
564
565fn report(
567 out: Output,
568 ground: &Ground,
569 resolved: &Resolved,
570 path: Option<Utf8PathBuf>,
571 checkout: Option<String>,
572 apply: bool,
573) -> Result<(), RkError> {
574 report_with(out, ground, resolved, path, checkout, apply, None)
575}
576
577fn report_with(
579 out: Output,
580 ground: &Ground,
581 resolved: &Resolved,
582 path: Option<Utf8PathBuf>,
583 checkout: Option<String>,
584 apply: bool,
585 note: Option<String>,
586) -> Result<(), RkError> {
587 let mode = if apply { "apply" } else { "preview" };
588 out.result_line(format!("issue: #{} {}", resolved.number, resolved.title));
589 out.result_line(format!(
590 "branch: {} ({})",
591 resolved.branch.as_deref().unwrap_or("named by the forge"),
592 match resolved.origin {
593 "already" => "already linked at the forge",
594 "forge" => "minted at the forge",
595 _ => "not minted yet",
596 }
597 ));
598 out.result_line(format!(
599 "seat: {} ({} says so)",
600 path.as_ref().map_or_else(
601 || checkout.as_deref().map_or_else(
602 || "unknown".to_owned(),
603 |branch| format!("checkout {branch}")
604 ),
605 ToString::to_string
606 ),
607 ground.workflow_source
608 ));
609 if !resolved.others.is_empty() {
610 out.warn(format!(
611 "the issue carries other linked branches, and the first was taken: {}",
612 resolved.others.join(", ")
613 ));
614 }
615 let detail = match (resolved.detail.clone(), note) {
616 (Some(had), Some(note)) => Some(format!("{had}; {note}")),
617 (Some(one), None) | (None, Some(one)) => Some(one),
618 (None, None) => None,
619 };
620 if let Some(detail) = &detail {
621 out.warn(detail);
622 }
623 let next = next_lines(ground, resolved, path.as_ref(), apply);
624 out.next(&next);
625 out.emit(&StartReport {
626 schema: "rk.issue-start/2",
627 mode,
628 forge: ground.forge.as_str(),
629 repo: ground.repo.clone(),
630 issue: resolved.number,
631 title: resolved.title.clone(),
632 branch: resolved.branch.clone(),
633 origin: resolved.origin,
634 checkout_mode: ground.workflow.as_str(),
635 path: path.map(|path| path.to_string()),
636 checkout,
637 others: resolved.others.clone(),
638 detail,
639 next,
640 })
641}
642
643fn next_lines(
645 ground: &Ground,
646 resolved: &Resolved,
647 path: Option<&Utf8PathBuf>,
648 apply: bool,
649) -> Vec<String> {
650 if !apply {
651 return vec![format!(
652 "rk issue start {} --apply mints the branch and seats it",
653 resolved.number
654 )];
655 }
656 match (ground.workflow, path) {
657 (CheckoutMode::LinkedWorktree, Some(path)) => vec![
658 format!("cd {path}"),
659 "rk worktree list reports every seat".to_owned(),
660 ],
661 _ => vec!["rk status reports what this target carries".to_owned()],
662 }
663}
664
665fn last_line(bytes: &[u8]) -> String {
667 String::from_utf8_lossy(bytes)
668 .lines()
669 .rev()
670 .find(|line| !line.trim().is_empty())
671 .unwrap_or("no output")
672 .to_owned()
673}