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, Workflow};
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 workflow: &'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: Workflow,
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(target: &Utf8Path, named: Option<&str>) -> Result<(Workflow, &'static str), RkError> {
153 let recorded = manifest::load(target)?.map(|held| held.parameters.workflow);
154 match (named, recorded) {
155 (Some(raw), Some(held)) => {
156 if Workflow::parse(raw)? != held {
157 return Err(RkError::refusal(
158 Diagnostic::new(
159 Reason::StateDrift,
160 format!(
161 "--workflow {raw} disagrees with the landing record, which states {}",
162 held.as_str()
163 ),
164 )
165 .expected("a flag that states the recorded mode, or no flag at all")
166 .action("rk upgrade --workflow <mode> --apply changes the recorded mode")
167 .target_state("unchanged"),
168 ));
169 }
170 Ok((held, "the landing record, restated by --workflow"))
171 }
172 (Some(raw), None) => Ok((Workflow::parse(raw)?, "the --workflow flag")),
173 (None, Some(held)) => Ok((held, "the landing record")),
174 (None, None) => Ok((Workflow::Worktree, "the default, with no landing record")),
179 }
180}
181
182fn reachable(forge: Forge, host: Option<&str>) -> Result<(), RkError> {
188 let Some(host) = host else { return Ok(()) };
189 if forge != Forge::Github || host.eq_ignore_ascii_case("github.com") {
190 return Ok(());
191 }
192 Err(RkError::refusal(
193 Diagnostic::new(
194 Reason::ForgeUnsupported,
195 format!("this clone's origin is {host}, and rk issue start reaches github.com alone"),
196 )
197 .expected("a github.com remote, or a GitLab project")
198 .action(
199 "start the branch with gh issue develop --repo <host>/<owner>/<name>, then rk worktree add it",
200 )
201 .target_state("unchanged"),
202 ))
203}
204
205fn ground(
208 target: &Utf8Path,
209 reference: &issue::Reference,
210 overrides: &Overrides<'_>,
211) -> Result<Ground, RkError> {
212 if !target.is_dir() {
213 return Err(RkError::missing(
214 Diagnostic::new(
215 Reason::TargetNotFound,
216 format!("target {target} is not a directory"),
217 )
218 .expected("an existing repository to act on"),
219 ));
220 }
221 let named = overrides
222 .forge
223 .map(|name| {
224 Forge::parse(name).ok_or_else(|| {
225 RkError::Usage(format!(
226 "unknown forge '{name}'; the forges are: github, gitlab"
227 ))
228 })
229 })
230 .transpose()?;
231 let detected = detect::detect(target.as_std_path());
232 issue::agrees(reference, &detected).map_err(RkError::Usage)?;
236 let Some(forge) = named.or(detected.forge) else {
237 let diagnostic = detected
238 .host
239 .as_ref()
240 .map_or_else(
241 || {
242 Diagnostic::new(
243 Reason::ForgeUndetected,
244 "no forge detected: the target has no origin remote",
245 )
246 },
247 |host| {
248 Diagnostic::new(
249 Reason::ForgeUndetected,
250 format!("no forge detected: the host {host} is not recognized"),
251 )
252 },
253 )
254 .expected("a github.com or gitlab remote, or an override")
255 .action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
256 return Err(if detected.host.is_some() {
257 RkError::refusal(diagnostic)
258 } else {
259 RkError::missing(diagnostic)
260 });
261 };
262 reachable(
266 forge,
267 detected.host.as_deref().or(reference.host.as_deref()),
268 )?;
269 contradicts(
274 "forge",
275 named.map(Forge::as_str),
276 detected.forge.map(Forge::as_str),
277 )?;
278 let Some(repo) = overrides
279 .repo
280 .map(str::to_owned)
281 .or_else(|| reference.repo.clone())
282 .or_else(|| detected.repo.clone())
283 else {
284 return Err(RkError::missing(
285 Diagnostic::new(
286 Reason::ForgeUndetected,
287 "no repository detected: the target has no origin remote",
288 )
289 .expected("an origin remote naming the project")
290 .action("pass --repo <owner/name>"),
291 ));
292 };
293 contradicts("repository", Some(repo.as_str()), detected.repo.as_deref())?;
294 contradicts("repository", Some(repo.as_str()), reference.repo.as_deref())?;
295 let (workflow, workflow_source) = mode_of(target, overrides.workflow)?;
296 Ok(Ground {
297 forge,
298 repo,
299 api_host: reference.host.clone(),
300 workflow,
301 workflow_source,
302 })
303}
304
305fn start(
307 target: &Utf8Path,
308 reference: &str,
309 overrides: &Overrides<'_>,
310 apply: bool,
311 out: Output,
312) -> Result<(), RkError> {
313 let reference = issue::parse_reference(reference).map_err(RkError::Usage)?;
314 let ground = ground(target, &reference, overrides)?;
315 let main = crate::commands::worktree::main_checkout(target)?;
319 probes::require_forge_cli(ground.forge)?;
322 let cli = resolve_cli(ground.forge)?;
323 let seatable = |branch: &str| -> Result<(), RkError> {
326 match ground.workflow {
327 Workflow::Worktree => {
328 crate::commands::worktree::plan_seat(target, branch, overrides.base, false)
329 .map(|_| ())
330 }
331 Workflow::Branches => branch_seatable(&main, branch),
332 }
333 };
334 let resolved = issue::resolve(
335 &cli,
336 target.as_std_path(),
337 &issue::Ask {
338 forge: ground.forge,
339 repo: &ground.repo,
340 reference: &reference,
341 host: ground.api_host.as_deref(),
342 base: overrides.base,
343 apply,
344 seatable: &seatable,
345 },
346 )?;
347 match ground.workflow {
348 Workflow::Worktree => seat_worktree(target, &ground, &resolved, overrides.base, apply, out),
349 Workflow::Branches => seat_branch(&main, &ground, &resolved, apply, out),
350 }
351}
352
353fn seat_worktree(
356 target: &Utf8Path,
357 ground: &Ground,
358 resolved: &Resolved,
359 base: Option<&str>,
360 apply: bool,
361 out: Output,
362) -> Result<(), RkError> {
363 let Some(branch) = resolved.branch.as_deref() else {
364 return report(out, ground, resolved, None, None, apply);
365 };
366 let seat = crate::commands::worktree::plan_seat(target, branch, base, apply)?;
367 let mut note = None;
368 let path = match seat {
369 crate::commands::worktree::Seat::Satisfied { path } => path,
370 crate::commands::worktree::Seat::Fresh {
371 path,
372 source,
373 detail,
374 } => {
375 if apply {
380 if let Some(why) = detail {
381 return Err(stale_refs(branch, resolved, &why));
382 }
383 }
384 if !matches!(source.kind, "adopted" | "remote") {
391 if apply {
392 return Err(unreachable_tip(branch, resolved));
393 }
394 note = Some(format!(
395 "origin/{branch} is not in this clone yet; the apply fetches first, and refuses rather than seat a branch from the trunk"
396 ));
397 }
398 if apply {
399 crate::commands::worktree::create_seat(target, &source)?;
400 }
401 path
402 }
403 };
404 report_with(out, ground, resolved, Some(path), None, apply, note)
405}
406
407fn unreachable_tip(branch: &str, resolved: &Resolved) -> RkError {
410 RkError::refusal(
411 Diagnostic::new(
412 Reason::StateDrift,
413 format!("the forge carries {branch} and this clone cannot reach its tip"),
414 )
415 .expected(format!(
416 "origin/{branch} present, or {branch} already local"
417 ))
418 .action("git fetch origin, then rerun")
419 .target_state(format!(
420 "unchanged; issue #{} keeps its branch at the forge",
421 resolved.number
422 )),
423 )
424}
425
426fn branch_seatable(main: &Utf8Path, branch: &str) -> Result<(), RkError> {
440 let trunk = crate::config::trunk_of(main.as_std_path())?;
441 if let Some(seat) = crate::commands::worktree::seat_of(main, branch)? {
442 if seat != main {
443 return Err(RkError::refusal(
444 Diagnostic::new(
445 Reason::StateDrift,
446 format!(
447 "branch {branch} is checked out at {seat}, and one branch has one seat"
448 ),
449 )
450 .expected("the branch free, or already in the main checkout")
451 .action(format!("git -C {seat} switch {trunk}, then rerun"))
452 .target_state("unchanged"),
453 ));
454 }
455 return Ok(());
458 }
459 let held = crate::commands::worktree::git(main, &["status", "--porcelain"])?;
460 if !held.status.success() || !held.stdout.is_empty() {
463 return Err(RkError::refusal(
464 Diagnostic::new(
465 Reason::StateDrift,
466 format!("{main} carries uncommitted work, and this mode checks {branch} out there"),
467 )
468 .expected("a clean main checkout to seat the branch in")
469 .action("commit or stash the work, then rerun")
470 .target_state("unchanged"),
471 ));
472 }
473 Ok(())
474}
475
476fn stale_refs(branch: &str, resolved: &Resolved, why: &str) -> RkError {
478 RkError::refusal(
479 Diagnostic::new(
480 Reason::StateDrift,
481 format!(
482 "this clone could not refresh from the forge, so its {branch} may be stale: {why}"
483 ),
484 )
485 .expected("a fetch that answered, so the seat starts from the tip the forge holds")
486 .action("git fetch origin, then rerun")
487 .target_state(format!(
488 "unchanged; issue #{} keeps its branch at the forge",
489 resolved.number
490 )),
491 )
492}
493
494fn seat_branch(
503 main: &Utf8Path,
504 ground: &Ground,
505 resolved: &Resolved,
506 apply: bool,
507 out: Output,
508) -> Result<(), RkError> {
509 let Some(branch) = resolved.branch.as_deref() else {
510 return report(out, ground, resolved, None, None, apply);
511 };
512 if !apply {
513 return report(out, ground, resolved, None, Some(branch.to_owned()), false);
514 }
515 let git = |args: &[&str]| crate::commands::worktree::git(main, args);
519 let fetched = git(&["fetch", "origin"])?;
520 if !fetched.status.success() {
521 return Err(stale_refs(branch, resolved, &last_line(&fetched.stderr)));
522 }
523 let local = git(&[
524 "rev-parse",
525 "--verify",
526 "--quiet",
527 "--end-of-options",
528 &format!("refs/heads/{branch}^{{commit}}"),
529 ])?;
530 let switched = if local.status.success() {
531 git(&["switch", branch])?
532 } else {
533 git(&[
534 "switch",
535 "--track",
536 "-c",
537 branch,
538 &format!("refs/remotes/origin/{branch}"),
539 ])?
540 };
541 if !switched.status.success() {
542 return Err(RkError::subprocess(
545 Diagnostic::new(
546 Reason::SubprocessFailed,
547 format!(
548 "git refused to check out {branch}: {}",
549 last_line(&switched.stderr)
550 ),
551 )
552 .expected("a working tree the checkout can move")
553 .target_state("the branch exists on the forge and is not checked out here"),
554 ));
555 }
556 report(out, ground, resolved, None, Some(branch.to_owned()), true)
557}
558
559fn report(
561 out: Output,
562 ground: &Ground,
563 resolved: &Resolved,
564 path: Option<Utf8PathBuf>,
565 checkout: Option<String>,
566 apply: bool,
567) -> Result<(), RkError> {
568 report_with(out, ground, resolved, path, checkout, apply, None)
569}
570
571fn report_with(
573 out: Output,
574 ground: &Ground,
575 resolved: &Resolved,
576 path: Option<Utf8PathBuf>,
577 checkout: Option<String>,
578 apply: bool,
579 note: Option<String>,
580) -> Result<(), RkError> {
581 let mode = if apply { "apply" } else { "preview" };
582 out.result_line(format!("issue: #{} {}", resolved.number, resolved.title));
583 out.result_line(format!(
584 "branch: {} ({})",
585 resolved.branch.as_deref().unwrap_or("named by the forge"),
586 match resolved.origin {
587 "already" => "already linked at the forge",
588 "forge" => "minted at the forge",
589 _ => "not minted yet",
590 }
591 ));
592 out.result_line(format!(
593 "seat: {} ({} says so)",
594 path.as_ref().map_or_else(
595 || checkout.as_deref().map_or_else(
596 || "unknown".to_owned(),
597 |branch| format!("checkout {branch}")
598 ),
599 ToString::to_string
600 ),
601 ground.workflow_source
602 ));
603 if !resolved.others.is_empty() {
604 out.warn(format!(
605 "the issue carries other linked branches, and the first was taken: {}",
606 resolved.others.join(", ")
607 ));
608 }
609 let detail = match (resolved.detail.clone(), note) {
610 (Some(had), Some(note)) => Some(format!("{had}; {note}")),
611 (Some(one), None) | (None, Some(one)) => Some(one),
612 (None, None) => None,
613 };
614 if let Some(detail) = &detail {
615 out.warn(detail);
616 }
617 let next = next_lines(ground, resolved, path.as_ref(), apply);
618 out.next(&next);
619 out.emit(&StartReport {
620 schema: "rk.issue-start/1",
621 mode,
622 forge: ground.forge.as_str(),
623 repo: ground.repo.clone(),
624 issue: resolved.number,
625 title: resolved.title.clone(),
626 branch: resolved.branch.clone(),
627 origin: resolved.origin,
628 workflow: ground.workflow.as_str(),
629 path: path.map(|path| path.to_string()),
630 checkout,
631 others: resolved.others.clone(),
632 detail,
633 next,
634 })
635}
636
637fn next_lines(
639 ground: &Ground,
640 resolved: &Resolved,
641 path: Option<&Utf8PathBuf>,
642 apply: bool,
643) -> Vec<String> {
644 if !apply {
645 return vec![format!(
646 "rk issue start {} --apply mints the branch and seats it",
647 resolved.number
648 )];
649 }
650 match (ground.workflow, path) {
651 (Workflow::Worktree, Some(path)) => vec![
652 format!("cd {path}"),
653 "rk worktree list reports every seat".to_owned(),
654 ],
655 _ => vec!["rk status reports what this target carries".to_owned()],
656 }
657}
658
659fn last_line(bytes: &[u8]) -> String {
661 String::from_utf8_lossy(bytes)
662 .lines()
663 .rev()
664 .find(|line| !line.trim().is_empty())
665 .unwrap_or("no output")
666 .to_owned()
667}