use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::cli::issue::{IssueAction, IssueArgs};
use crate::detect::{self, Forge};
use crate::diagnostic::{Diagnostic, Reason};
use crate::error::RkError;
use crate::issue::{self, Resolved};
use crate::landing::manifest::{self, Workflow};
use crate::output::Output;
use crate::probes;
use crate::setup::context::{TRUNK_BRANCH, resolve_cli};
#[derive(Debug, Serialize)]
struct StartReport {
schema: &'static str,
mode: &'static str,
forge: &'static str,
repo: String,
issue: u64,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
origin: &'static str,
workflow: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
checkout: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
others: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
detail: Option<String>,
next: Vec<String>,
}
pub fn run(args: &IssueArgs) -> Result<(), RkError> {
match &args.action {
IssueAction::Start {
issue,
target,
forge,
repo,
workflow,
base,
apply,
json,
} => start(
target,
issue,
&Overrides {
forge: forge.as_deref(),
repo: repo.as_deref(),
workflow: workflow.as_deref(),
base: base.as_deref(),
},
*apply,
Output::new(*json),
),
}
}
struct Overrides<'a> {
forge: Option<&'a str>,
repo: Option<&'a str>,
workflow: Option<&'a str>,
base: Option<&'a str>,
}
struct Ground {
forge: Forge,
repo: String,
api_host: Option<String>,
workflow: Workflow,
workflow_source: &'static str,
}
fn contradicts(what: &str, chosen: Option<&str>, known: Option<&str>) -> Result<(), RkError> {
let (Some(chosen), Some(known)) = (chosen, known) else {
return Ok(());
};
if chosen == known {
return Ok(());
}
Err(RkError::Usage(format!(
"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"
)))
}
fn mode_of(target: &Utf8Path, named: Option<&str>) -> Result<(Workflow, &'static str), RkError> {
let recorded = manifest::load(target)?.map(|held| held.parameters.workflow);
match (named, recorded) {
(Some(raw), Some(held)) => {
if Workflow::parse(raw)? != held {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"--workflow {raw} disagrees with the landing record, which states {}",
held.as_str()
),
)
.expected("a flag that states the recorded mode, or no flag at all")
.action("rk upgrade --workflow <mode> --apply changes the recorded mode")
.target_state("unchanged"),
));
}
Ok((held, "the landing record, restated by --workflow"))
}
(Some(raw), None) => Ok((Workflow::parse(raw)?, "the --workflow flag")),
(None, Some(held)) => Ok((held, "the landing record")),
(None, None) => Ok((Workflow::Worktree, "the default, with no landing record")),
}
}
fn reachable(forge: Forge, host: Option<&str>) -> Result<(), RkError> {
let Some(host) = host else { return Ok(()) };
if forge != Forge::Github || host.eq_ignore_ascii_case("github.com") {
return Ok(());
}
Err(RkError::refusal(
Diagnostic::new(
Reason::ForgeUnsupported,
format!("this clone's origin is {host}, and rk issue start reaches github.com alone"),
)
.expected("a github.com remote, or a GitLab project")
.action(
"start the branch with gh issue develop --repo <host>/<owner>/<name>, then rk worktree add it",
)
.target_state("unchanged"),
))
}
fn ground(
target: &Utf8Path,
reference: &issue::Reference,
overrides: &Overrides<'_>,
) -> Result<Ground, RkError> {
if !target.is_dir() {
return Err(RkError::missing(
Diagnostic::new(
Reason::TargetNotFound,
format!("target {target} is not a directory"),
)
.expected("an existing repository to act on"),
));
}
let named = overrides
.forge
.map(|name| {
Forge::parse(name).ok_or_else(|| {
RkError::Usage(format!(
"unknown forge '{name}'; the forges are: github, gitlab"
))
})
})
.transpose()?;
let detected = detect::detect(target.as_std_path());
issue::agrees(reference, &detected).map_err(RkError::Usage)?;
let Some(forge) = named.or(detected.forge) else {
let diagnostic = detected
.host
.as_ref()
.map_or_else(
|| {
Diagnostic::new(
Reason::ForgeUndetected,
"no forge detected: the target has no origin remote",
)
},
|host| {
Diagnostic::new(
Reason::ForgeUndetected,
format!("no forge detected: the host {host} is not recognized"),
)
},
)
.expected("a github.com or gitlab remote, or an override")
.action("pass --forge <github|gitlab>, and --repo <path> if the remote is absent");
return Err(if detected.host.is_some() {
RkError::refusal(diagnostic)
} else {
RkError::missing(diagnostic)
});
};
reachable(
forge,
detected.host.as_deref().or(reference.host.as_deref()),
)?;
contradicts(
"forge",
named.map(Forge::as_str),
detected.forge.map(Forge::as_str),
)?;
let Some(repo) = overrides
.repo
.map(str::to_owned)
.or_else(|| reference.repo.clone())
.or_else(|| detected.repo.clone())
else {
return Err(RkError::missing(
Diagnostic::new(
Reason::ForgeUndetected,
"no repository detected: the target has no origin remote",
)
.expected("an origin remote naming the project")
.action("pass --repo <owner/name>"),
));
};
contradicts("repository", Some(repo.as_str()), detected.repo.as_deref())?;
contradicts("repository", Some(repo.as_str()), reference.repo.as_deref())?;
let (workflow, workflow_source) = mode_of(target, overrides.workflow)?;
Ok(Ground {
forge,
repo,
api_host: reference.host.clone(),
workflow,
workflow_source,
})
}
fn start(
target: &Utf8Path,
reference: &str,
overrides: &Overrides<'_>,
apply: bool,
out: Output,
) -> Result<(), RkError> {
let reference = issue::parse_reference(reference).map_err(RkError::Usage)?;
let ground = ground(target, &reference, overrides)?;
let main = crate::commands::worktree::main_checkout(target)?;
probes::require_forge_cli(ground.forge)?;
let cli = resolve_cli(ground.forge)?;
let seatable = |branch: &str| -> Result<(), RkError> {
match ground.workflow {
Workflow::Worktree => {
crate::commands::worktree::plan_seat(target, branch, overrides.base, false)
.map(|_| ())
}
Workflow::Branches => branch_seatable(&main, branch),
}
};
let resolved = issue::resolve(
&cli,
target.as_std_path(),
&issue::Ask {
forge: ground.forge,
repo: &ground.repo,
reference: &reference,
host: ground.api_host.as_deref(),
base: overrides.base,
apply,
seatable: &seatable,
},
)?;
match ground.workflow {
Workflow::Worktree => seat_worktree(target, &ground, &resolved, overrides.base, apply, out),
Workflow::Branches => seat_branch(&main, &ground, &resolved, apply, out),
}
}
fn seat_worktree(
target: &Utf8Path,
ground: &Ground,
resolved: &Resolved,
base: Option<&str>,
apply: bool,
out: Output,
) -> Result<(), RkError> {
let Some(branch) = resolved.branch.as_deref() else {
return report(out, ground, resolved, None, None, apply);
};
let seat = crate::commands::worktree::plan_seat(target, branch, base, apply)?;
let mut note = None;
let path = match seat {
crate::commands::worktree::Seat::Satisfied { path } => path,
crate::commands::worktree::Seat::Fresh {
path,
source,
detail,
} => {
if apply {
if let Some(why) = detail {
return Err(stale_refs(branch, resolved, &why));
}
}
if !matches!(source.kind, "adopted" | "remote") {
if apply {
return Err(unreachable_tip(branch, resolved));
}
note = Some(format!(
"origin/{branch} is not in this clone yet; the apply fetches first, and refuses rather than seat a branch from the trunk"
));
}
if apply {
crate::commands::worktree::create_seat(target, &source)?;
}
path
}
};
report_with(out, ground, resolved, Some(path), None, apply, note)
}
fn unreachable_tip(branch: &str, resolved: &Resolved) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!("the forge carries {branch} and this clone cannot reach its tip"),
)
.expected(format!(
"origin/{branch} present, or {branch} already local"
))
.action("git fetch origin, then rerun")
.target_state(format!(
"unchanged; issue #{} keeps its branch at the forge",
resolved.number
)),
)
}
fn branch_seatable(main: &Utf8Path, branch: &str) -> Result<(), RkError> {
if let Some(seat) = crate::commands::worktree::seat_of(main, branch)? {
if seat != main {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"branch {branch} is checked out at {seat}, and one branch has one seat"
),
)
.expected("the branch free, or already in the main checkout")
.action(format!("git -C {seat} switch {TRUNK_BRANCH}, then rerun"))
.target_state("unchanged"),
));
}
return Ok(());
}
let held = crate::commands::worktree::git(main, &["status", "--porcelain"])?;
if !held.status.success() || !held.stdout.is_empty() {
return Err(RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!("{main} carries uncommitted work, and this mode checks {branch} out there"),
)
.expected("a clean main checkout to seat the branch in")
.action("commit or stash the work, then rerun")
.target_state("unchanged"),
));
}
Ok(())
}
fn stale_refs(branch: &str, resolved: &Resolved, why: &str) -> RkError {
RkError::refusal(
Diagnostic::new(
Reason::StateDrift,
format!(
"this clone could not refresh from the forge, so its {branch} may be stale: {why}"
),
)
.expected("a fetch that answered, so the seat starts from the tip the forge holds")
.action("git fetch origin, then rerun")
.target_state(format!(
"unchanged; issue #{} keeps its branch at the forge",
resolved.number
)),
)
}
fn seat_branch(
main: &Utf8Path,
ground: &Ground,
resolved: &Resolved,
apply: bool,
out: Output,
) -> Result<(), RkError> {
let Some(branch) = resolved.branch.as_deref() else {
return report(out, ground, resolved, None, None, apply);
};
if !apply {
return report(out, ground, resolved, None, Some(branch.to_owned()), false);
}
let git = |args: &[&str]| crate::commands::worktree::git(main, args);
let fetched = git(&["fetch", "origin"])?;
if !fetched.status.success() {
return Err(stale_refs(branch, resolved, &last_line(&fetched.stderr)));
}
let local = git(&[
"rev-parse",
"--verify",
"--quiet",
"--end-of-options",
&format!("refs/heads/{branch}^{{commit}}"),
])?;
let switched = if local.status.success() {
git(&["switch", branch])?
} else {
git(&[
"switch",
"--track",
"-c",
branch,
&format!("refs/remotes/origin/{branch}"),
])?
};
if !switched.status.success() {
return Err(RkError::subprocess(
Diagnostic::new(
Reason::SubprocessFailed,
format!(
"git refused to check out {branch}: {}",
last_line(&switched.stderr)
),
)
.expected("a working tree the checkout can move")
.target_state("the branch exists on the forge and is not checked out here"),
));
}
report(out, ground, resolved, None, Some(branch.to_owned()), true)
}
fn report(
out: Output,
ground: &Ground,
resolved: &Resolved,
path: Option<Utf8PathBuf>,
checkout: Option<String>,
apply: bool,
) -> Result<(), RkError> {
report_with(out, ground, resolved, path, checkout, apply, None)
}
fn report_with(
out: Output,
ground: &Ground,
resolved: &Resolved,
path: Option<Utf8PathBuf>,
checkout: Option<String>,
apply: bool,
note: Option<String>,
) -> Result<(), RkError> {
let mode = if apply { "apply" } else { "preview" };
out.result_line(format!("issue: #{} {}", resolved.number, resolved.title));
out.result_line(format!(
"branch: {} ({})",
resolved.branch.as_deref().unwrap_or("named by the forge"),
match resolved.origin {
"already" => "already linked at the forge",
"forge" => "minted at the forge",
_ => "not minted yet",
}
));
out.result_line(format!(
"seat: {} ({} says so)",
path.as_ref().map_or_else(
|| checkout.as_deref().map_or_else(
|| "unknown".to_owned(),
|branch| format!("checkout {branch}")
),
ToString::to_string
),
ground.workflow_source
));
if !resolved.others.is_empty() {
out.warn(format!(
"the issue carries other linked branches, and the first was taken: {}",
resolved.others.join(", ")
));
}
let detail = match (resolved.detail.clone(), note) {
(Some(had), Some(note)) => Some(format!("{had}; {note}")),
(Some(one), None) | (None, Some(one)) => Some(one),
(None, None) => None,
};
if let Some(detail) = &detail {
out.warn(detail);
}
let next = next_lines(ground, resolved, path.as_ref(), apply);
out.next(&next);
out.emit(&StartReport {
schema: "rk.issue-start/1",
mode,
forge: ground.forge.as_str(),
repo: ground.repo.clone(),
issue: resolved.number,
title: resolved.title.clone(),
branch: resolved.branch.clone(),
origin: resolved.origin,
workflow: ground.workflow.as_str(),
path: path.map(|path| path.to_string()),
checkout,
others: resolved.others.clone(),
detail,
next,
})
}
fn next_lines(
ground: &Ground,
resolved: &Resolved,
path: Option<&Utf8PathBuf>,
apply: bool,
) -> Vec<String> {
if !apply {
return vec![format!(
"rk issue start {} --apply mints the branch and seats it",
resolved.number
)];
}
match (ground.workflow, path) {
(Workflow::Worktree, Some(path)) => vec![
format!("cd {path}"),
"rk worktree list reports every seat".to_owned(),
],
_ => vec!["rk status reports what this target carries".to_owned()],
}
}
fn last_line(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned()
}