//! `sm121 promote` — move a recipe across recipe repos (git mv, decomposed //! into per-repo commit + push). //! //! The target is updated FIRST (copy in + commit + push); only then is the //! recipe removed from the source. A failure in between therefore leaves //! the recipe duplicated (safe under the priority semantics — the source //! repo shadows the target) and never deleted. `--dry-run` (the global //! `-d`) prints the exact commands; `--apply` runs them. use crate::config::{Config, RecipeRepo}; use crate::recipe_repos::{cache_base, git, repo_cache_dir, sync_repo}; use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; /// One executable step of a promotion plan. #[derive(Debug, Clone)] pub struct Step { /// `"target"` (the repo the recipe moves INTO) or `"source"`. pub side: &'static str, /// Human-readable form (what `--dry-run` prints). pub description: String, /// What `execute` runs. pub run: Run, } #[derive(Debug, Clone)] pub enum Run { /// `cp -a` the recipe dir between the two checkouts (a git mv across /// repos has no single git command). Copy { from: PathBuf, to: PathBuf }, /// `git -C `. Git { cwd: PathBuf, args: Vec }, /// `git -C push ` (the URL may carry the token). Push { cwd: PathBuf, url: String, branch: String, }, } /// The full promotion plan (target steps first, then source steps). #[derive(Debug, Clone)] pub struct PromotePlan { pub recipe: String, pub from_url: String, pub to_url: String, pub from_checkout: PathBuf, pub to_checkout: PathBuf, pub steps: Vec, } /// Find a configured `[[recipe_repos]]` entry by exact URL. fn find_repo<'a>(config: &'a Config, url: &str) -> Result<&'a RecipeRepo> { config .recipe_repos .iter() .find(|r| r.url == url) .with_context( || fluent_i18n::t!("promote-repo-not-configured", { "url" => url.to_string() }), ) } /// The checkout dir of a repo, synced on demand (a promote must not plan /// against a missing checkout). With `sync_missing = false` (dry-run) a /// missing checkout is an error instead of a side-effecting clone. fn ensure_checkout(repo: &RecipeRepo, sync_missing: bool) -> Result { let dir = repo_cache_dir(&cache_base(), &repo.url); if !dir.join(".git").is_dir() { if !sync_missing { anyhow::bail!(fluent_i18n::t!( "promote-checkout-missing", { "url" => &repo.url } )); } sync_repo(repo).with_context( || fluent_i18n::t!("promote-checkout-sync-fail", { "url" => &repo.url }), )?; } Ok(dir) } fn branch_of(checkout: &Path) -> Result { let b = git(&[ "-C", &checkout.display().to_string(), "rev-parse", "--abbrev-ref", "HEAD", ])?; if b.is_empty() || b == "HEAD" { anyhow::bail!(fluent_i18n::t!( "promote-detached-head", { "path" => checkout.display().to_string() } )); } Ok(b) } fn shq(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } /// Build the promotion plan: validate the move (the recipe must exist in /// the source and NOT in the target — no silent overwrite), then the /// target-side copy + commit + push, then the source-side remove + commit /// + push. pub fn plan( config: &Config, recipe: &str, from_url: &str, to_url: &str, sync_missing: bool, ) -> Result { if from_url == to_url { anyhow::bail!(fluent_i18n::t!("promote-same-repo")); } let from = find_repo(config, from_url)?; let to = find_repo(config, to_url)?; let from_dir = ensure_checkout(from, sync_missing)?; let to_dir = ensure_checkout(to, sync_missing)?; let from_recipe = from_dir.join("recipes").join(recipe); let to_recipe = to_dir.join("recipes").join(recipe); if !from_recipe.is_dir() { anyhow::bail!(fluent_i18n::t!( "promote-recipe-not-in-source", { "recipe" => recipe.to_string(), "path" => from_dir.display().to_string() } )); } if to_recipe.exists() { anyhow::bail!(fluent_i18n::t!( "promote-recipe-in-target", { "recipe" => recipe.to_string() } )); } let from_branch = branch_of(&from_dir)?; let to_branch = branch_of(&to_dir)?; let mut steps = Vec::new(); steps.push(Step { side: "target", description: format!( "cp -a {} {}", shq(&from_recipe.display().to_string()), shq(&to_recipe.display().to_string()) ), run: Run::Copy { from: from_recipe.clone(), to: to_recipe.clone(), }, }); steps.push(Step { side: "target", description: format!("git -C {} add recipes/{}", to_dir.display(), recipe), run: Run::Git { cwd: to_dir.clone(), args: vec!["add".into(), format!("recipes/{recipe}")], }, }); let to_commit_msg = format!("promote {recipe}: add from {from_url}"); steps.push(Step { side: "target", description: format!( "git -C {} commit -m {}", to_dir.display(), shq(&to_commit_msg) ), run: Run::Git { cwd: to_dir.clone(), args: vec!["commit".into(), "-m".into(), to_commit_msg], }, }); steps.push(Step { side: "target", description: format!("git -C {} push {} {}", to_dir.display(), to.url, to_branch), run: Run::Push { cwd: to_dir.clone(), url: crate::recipe_repos::push_url(to), branch: to_branch.clone(), }, }); steps.push(Step { side: "source", description: format!("git -C {} rm -r recipes/{}", from_dir.display(), recipe), run: Run::Git { cwd: from_dir.clone(), args: vec!["rm".into(), "-r".into(), format!("recipes/{recipe}")], }, }); let from_commit_msg = format!("promote {recipe}: remove (promoted to {to_url})"); steps.push(Step { side: "source", description: format!( "git -C {} commit -m {}", from_dir.display(), shq(&from_commit_msg) ), run: Run::Git { cwd: from_dir.clone(), args: vec!["commit".into(), "-m".into(), from_commit_msg], }, }); steps.push(Step { side: "source", description: format!( "git -C {} push {} {}", from_dir.display(), from.url, from_branch ), run: Run::Push { cwd: from_dir.clone(), url: crate::recipe_repos::push_url(from), branch: from_branch, }, }); Ok(PromotePlan { recipe: recipe.to_string(), from_url: from_url.to_string(), to_url: to_url.to_string(), from_checkout: from_dir, to_checkout: to_dir, steps, }) } /// Run every step of the plan (in order; a failure stops the promote, /// leaving the recipe at least duplicated, never deleted). pub fn execute(plan: &PromotePlan) -> Result<()> { for step in &plan.steps { match &step.run { Run::Copy { from, to } => { std::fs::create_dir_all( to.parent() .context(fluent_i18n::t!("promote-target-dir-context"))?, )?; let out = std::process::Command::new("cp") .arg("-a") .arg(from) .arg(to) .output() .context(fluent_i18n::t!("promote-cp-context"))?; if !out.status.success() { anyhow::bail!(fluent_i18n::t!( "promote-cp-failed", { "err" => String::from_utf8_lossy(&out.stderr).trim().to_string() } )); } } Run::Git { cwd, args } => { let cwd_s = cwd.display().to_string(); let mut a: Vec<&str> = vec!["-C", &cwd_s]; a.extend(args.iter().map(String::as_str)); git(&a)?; } Run::Push { cwd, url, branch } => { git(&["-C", &cwd.display().to_string(), "push", url, branch])?; } } println!( "{}", fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description }) ); } Ok(()) } /// `sm121 promote --from --to [--apply]` dispatch: /// plan, print (default / `-d`), execute (`--apply`). pub fn run( config: &Config, recipe: &str, from_url: &str, to_url: &str, apply: bool, dry_run: bool, ) -> Result<()> { let plan = plan(config, recipe, from_url, to_url, !dry_run)?; if apply && !dry_run { println!( "{}", fluent_i18n::t!("promote-run-start", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url, "count" => plan.steps.len() }) ); println!( "{}", fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() }) ); println!( "{}", fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() }) ); execute(&plan)?; println!( "{}", fluent_i18n::t!("promote-done", { "recipe" => &plan.recipe }) ); return Ok(()); } println!( "{}", fluent_i18n::t!("promote-dry-head", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url }) ); println!( "{}", fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() }) ); println!( "{}", fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() }) ); for step in &plan.steps { println!( "{}", fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description }) ); } if apply { println!("{}", fluent_i18n::t!("promote-dry-suppressed")); } println!("{}", fluent_i18n::t!("promote-run-apply-hint")); Ok(()) } #[cfg(test)] #[path = "promote_tests.rs"] mod tests;