| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- //! `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 <cwd> <args…>`.
- Git { cwd: PathBuf, args: Vec<String> },
- /// `git -C <cwd> push <url> <branch>` (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<Step>,
- }
- /// 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<PathBuf> {
- 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<String> {
- 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<PromotePlan> {
- 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 <recipe> --from <url> --to <url> [--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;
|