use std::path::Path; use anyhow::{anyhow, Context}; use radicle::cob::patch::{MergeTarget, PatchId, PatchMut, Patches}; use radicle::git; use radicle::git::raw::Oid; use radicle::prelude::*; use radicle::storage::git::Repository; use crate::terminal as term; use crate::terminal::args::Error; use super::common; use super::{Options, Update}; const PATCH_MSG: &str = r#" "#; const REVISION_MSG: &str = r#" "#; /// Run patch creation. pub fn run( storage: &Repository, profile: &Profile, workdir: &git::raw::Repository, options: Options, ) -> anyhow::Result<()> { let project = storage.project_of(&profile.public_key).context(format!( "couldn't load project {} from local state", storage.id ))?; term::headline(&format!( "🌱 Creating patch for {}", term::format::highlight(&project.name) )); let signer = term::signer(profile)?; let mut patches = Patches::open(profile.public_key, storage)?; // `HEAD`; This is what we are proposing as a patch. let head = workdir.head()?; let head_oid = head.target().ok_or(anyhow!("invalid HEAD ref; aborting"))?; let head_commit = workdir.find_commit(head_oid)?; let head_branch = head .shorthand() .ok_or(anyhow!("cannot create patch from detached head; aborting"))?; // Make sure the `HEAD` commit can be found in the monorepo. Otherwise there // is no way for anyone to merge this patch. let mut spinner = term::spinner(format!( "Looking for HEAD ({}) in storage...", term::format::secondary(term::format::oid(head_oid)) )); if storage.commit(head_oid.into()).is_err() { if !options.push { spinner.failed(); term::blank(); return Err(Error::WithHint { err: anyhow!("Current branch head was not found in storage"), hint: "hint: run `git push rad` and try again", } .into()); } spinner.message("Pushing HEAD to storage..."); let output = git::run::<_, _, &str, &str>(Path::new("."), ["push", "rad"], [])?; if options.verbose { spinner.finish(); term::blob(output); } } spinner.finish(); // Determine the merge target for this patch. This can ben any tracked remote's "default" // branch, as well as your own (eg. `rad/master`). let mut spinner = term::spinner("Analyzing remotes..."); let targets = common::find_merge_targets(&head_oid, project.default_branch.as_refstr(), storage)?; // eg. `refs/namespaces//refs/heads/master` let (target_peer, target_oid) = match targets.not_merged.as_slice() { [] => { spinner.message("All tracked peers are up to date."); return Ok(()); } [target] => target, _ => { // TODO: Let user select which branch to use as a target. todo!(); } }; // TODO: Tell user how many peers don't have this change. spinner.finish(); // TODO: Handle case where `rad/master` isn't up to date with the target. // In that case we should warn the user that their master branch is not up // to date, and error out, unless the user specifies manually the merge // base. // The merge base is basically the commit at which the histories diverge. let base_oid = workdir.merge_base((*target_oid).into(), head_oid)?; let commits = common::patch_commits(workdir, &base_oid, &head_oid)?; let patch = match &options.update { Update::No => None, Update::Any => { let mut spinner = term::spinner("Finding patches to update..."); let mut result = common::find_unmerged_with_base( head_oid, **target_oid, base_oid, &patches, workdir, )?; if let Some((id, patch, clock)) = result.pop() { if result.is_empty() { spinner.message(format!( "Found existing patch {} {}", term::format::tertiary(term::format::cob(&id)), term::format::italic(patch.title()) )); spinner.finish(); term::blank(); Some((id, PatchMut::new(id, patch, clock, &mut patches))) } else { spinner.failed(); term::blank(); anyhow::bail!("More than one patch available to update, please specify an id with `rad patch --update `"); } } else { spinner.failed(); term::blank(); anyhow::bail!("No patches found that share a base, please create a new patch or specify the patch id manually"); } } Update::Patch(id) => { if let Ok(patch) = patches.get_mut(id) { Some((*id, patch)) } else { anyhow::bail!("Patch `{}` not found", id); } } }; if let Some((id, patch)) = patch { if term::confirm("Update?") { term::blank(); return update(patch, id, &base_oid, &head_oid, workdir, options, &signer); } else { anyhow::bail!("Patch update aborted by user"); } } // TODO: List matching working copy refs for all targets. term::blank(); term::info!( "{}/{} ({}) <- {}/{} ({})", term::format::dim(target_peer.id), term::format::highlight(&project.default_branch.to_string()), term::format::secondary(&term::format::oid(*target_oid)), term::format::dim(term::format::node(patches.public_key())), term::format::highlight(&head_branch.to_string()), term::format::secondary(&term::format::oid(head_oid)), ); // TODO: Test case where the target branch has been re-written passed the merge-base, since the fork was created // This can also happen *after* the patch is created. term::patch::print_commits_ahead_behind(workdir, head_oid, (*target_oid).into())?; // List commits in patch that aren't in the target branch. term::blank(); term::patch::list_commits(&commits)?; term::blank(); if !term::confirm("Continue?") { anyhow::bail!("patch proposal aborted by user"); } let message = head_commit .message() .ok_or(anyhow!("commit summary is not valid UTF-8; aborting"))?; let message = options.message.get(&format!("{}{}", message, PATCH_MSG)); let (title, description) = message.split_once("\n\n").unwrap_or((&message, "")); let (title, description) = (title.trim(), description.trim()); let description = description.replace(PATCH_MSG.trim(), ""); // Delete help message. if title.is_empty() { anyhow::bail!("a title must be given"); } let title_pretty = &term::format::dim(format!("╭─ {} ───────", title)); term::blank(); term::print(title_pretty); term::blank(); if description.is_empty() { term::print(term::format::italic("No description provided.")); } else { term::markdown(&description); } term::blank(); term::print(&term::format::dim(format!( "╰{}", "─".repeat(term::text_width(title_pretty) - 1) ))); term::blank(); if !term::confirm("Create patch?") { anyhow::bail!("patch proposal aborted by user"); } let patch = patches.create( title, &description, MergeTarget::default(), base_oid, head_oid, &[], &signer, )?; term::blank(); term::success!("Patch {} created 🌱", term::format::highlight(patch.id)); if options.sync { // TODO } Ok(()) } /// Update an existing patch with a new revision. fn update( mut patch: PatchMut, patch_id: PatchId, base: &Oid, head: &Oid, workdir: &git::raw::Repository, options: Options, signer: &G, ) -> anyhow::Result<()> { // TODO(cloudhead): Handle error. let (_, current_revision) = patch.latest().unwrap(); let current_version = patch.version(); if *current_revision.oid == *head { term::info!("Nothing to do, patch is already up to date."); return Ok(()); } term::info!( "{} {} ({}) -> {} ({})", term::format::tertiary(term::format::cob(&patch_id)), term::format::dim(format!("R{}", current_version)), term::format::secondary(term::format::oid(current_revision.oid)), term::format::dim(format!("R{}", current_version + 1)), term::format::secondary(term::format::oid(*head)), ); let message = options.message.get(REVISION_MSG); // Difference between the two revisions. term::patch::print_commits_ahead_behind(workdir, *head, *current_revision.oid)?; term::blank(); if !term::confirm("Continue?") { anyhow::bail!("patch update aborted by user"); } patch.update(message, *base, *head, signer)?; term::blank(); term::success!("Patch {} updated 🌱", term::format::highlight(patch_id)); term::blank(); if options.sync { // TODO } Ok(()) }