promote.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. //! `sm121 promote` — move a recipe across recipe repos (git mv, decomposed
  2. //! into per-repo commit + push).
  3. //!
  4. //! The target is updated FIRST (copy in + commit + push); only then is the
  5. //! recipe removed from the source. A failure in between therefore leaves
  6. //! the recipe duplicated (safe under the priority semantics — the source
  7. //! repo shadows the target) and never deleted. `--dry-run` (the global
  8. //! `-d`) prints the exact commands; `--apply` runs them.
  9. use crate::config::{Config, RecipeRepo};
  10. use crate::recipe_repos::{cache_base, git, repo_cache_dir, sync_repo};
  11. use anyhow::{Context, Result};
  12. use std::path::{Path, PathBuf};
  13. /// One executable step of a promotion plan.
  14. #[derive(Debug, Clone)]
  15. pub struct Step {
  16. /// `"target"` (the repo the recipe moves INTO) or `"source"`.
  17. pub side: &'static str,
  18. /// Human-readable form (what `--dry-run` prints).
  19. pub description: String,
  20. /// What `execute` runs.
  21. pub run: Run,
  22. }
  23. #[derive(Debug, Clone)]
  24. pub enum Run {
  25. /// `cp -a` the recipe dir between the two checkouts (a git mv across
  26. /// repos has no single git command).
  27. Copy { from: PathBuf, to: PathBuf },
  28. /// `git -C <cwd> <args…>`.
  29. Git { cwd: PathBuf, args: Vec<String> },
  30. /// `git -C <cwd> push <url> <branch>` (the URL may carry the token).
  31. Push {
  32. cwd: PathBuf,
  33. url: String,
  34. branch: String,
  35. },
  36. }
  37. /// The full promotion plan (target steps first, then source steps).
  38. #[derive(Debug, Clone)]
  39. pub struct PromotePlan {
  40. pub recipe: String,
  41. pub from_url: String,
  42. pub to_url: String,
  43. pub from_checkout: PathBuf,
  44. pub to_checkout: PathBuf,
  45. pub steps: Vec<Step>,
  46. }
  47. /// Find a configured `[[recipe_repos]]` entry by exact URL.
  48. fn find_repo<'a>(config: &'a Config, url: &str) -> Result<&'a RecipeRepo> {
  49. config
  50. .recipe_repos
  51. .iter()
  52. .find(|r| r.url == url)
  53. .with_context(
  54. || fluent_i18n::t!("promote-repo-not-configured", { "url" => url.to_string() }),
  55. )
  56. }
  57. /// The checkout dir of a repo, synced on demand (a promote must not plan
  58. /// against a missing checkout). With `sync_missing = false` (dry-run) a
  59. /// missing checkout is an error instead of a side-effecting clone.
  60. fn ensure_checkout(repo: &RecipeRepo, sync_missing: bool) -> Result<PathBuf> {
  61. let dir = repo_cache_dir(&cache_base(), &repo.url);
  62. if !dir.join(".git").is_dir() {
  63. if !sync_missing {
  64. anyhow::bail!(fluent_i18n::t!(
  65. "promote-checkout-missing",
  66. { "url" => &repo.url }
  67. ));
  68. }
  69. sync_repo(repo).with_context(
  70. || fluent_i18n::t!("promote-checkout-sync-fail", { "url" => &repo.url }),
  71. )?;
  72. }
  73. Ok(dir)
  74. }
  75. fn branch_of(checkout: &Path) -> Result<String> {
  76. let b = git(&[
  77. "-C",
  78. &checkout.display().to_string(),
  79. "rev-parse",
  80. "--abbrev-ref",
  81. "HEAD",
  82. ])?;
  83. if b.is_empty() || b == "HEAD" {
  84. anyhow::bail!(fluent_i18n::t!(
  85. "promote-detached-head",
  86. { "path" => checkout.display().to_string() }
  87. ));
  88. }
  89. Ok(b)
  90. }
  91. fn shq(s: &str) -> String {
  92. format!("'{}'", s.replace('\'', "'\\''"))
  93. }
  94. /// Build the promotion plan: validate the move (the recipe must exist in
  95. /// the source and NOT in the target — no silent overwrite), then the
  96. /// target-side copy + commit + push, then the source-side remove + commit
  97. /// + push.
  98. pub fn plan(
  99. config: &Config,
  100. recipe: &str,
  101. from_url: &str,
  102. to_url: &str,
  103. sync_missing: bool,
  104. ) -> Result<PromotePlan> {
  105. if from_url == to_url {
  106. anyhow::bail!(fluent_i18n::t!("promote-same-repo"));
  107. }
  108. let from = find_repo(config, from_url)?;
  109. let to = find_repo(config, to_url)?;
  110. let from_dir = ensure_checkout(from, sync_missing)?;
  111. let to_dir = ensure_checkout(to, sync_missing)?;
  112. let from_recipe = from_dir.join("recipes").join(recipe);
  113. let to_recipe = to_dir.join("recipes").join(recipe);
  114. if !from_recipe.is_dir() {
  115. anyhow::bail!(fluent_i18n::t!(
  116. "promote-recipe-not-in-source",
  117. { "recipe" => recipe.to_string(), "path" => from_dir.display().to_string() }
  118. ));
  119. }
  120. if to_recipe.exists() {
  121. anyhow::bail!(fluent_i18n::t!(
  122. "promote-recipe-in-target",
  123. { "recipe" => recipe.to_string() }
  124. ));
  125. }
  126. let from_branch = branch_of(&from_dir)?;
  127. let to_branch = branch_of(&to_dir)?;
  128. let mut steps = Vec::new();
  129. steps.push(Step {
  130. side: "target",
  131. description: format!(
  132. "cp -a {} {}",
  133. shq(&from_recipe.display().to_string()),
  134. shq(&to_recipe.display().to_string())
  135. ),
  136. run: Run::Copy {
  137. from: from_recipe.clone(),
  138. to: to_recipe.clone(),
  139. },
  140. });
  141. steps.push(Step {
  142. side: "target",
  143. description: format!("git -C {} add recipes/{}", to_dir.display(), recipe),
  144. run: Run::Git {
  145. cwd: to_dir.clone(),
  146. args: vec!["add".into(), format!("recipes/{recipe}")],
  147. },
  148. });
  149. let to_commit_msg = format!("promote {recipe}: add from {from_url}");
  150. steps.push(Step {
  151. side: "target",
  152. description: format!(
  153. "git -C {} commit -m {}",
  154. to_dir.display(),
  155. shq(&to_commit_msg)
  156. ),
  157. run: Run::Git {
  158. cwd: to_dir.clone(),
  159. args: vec!["commit".into(), "-m".into(), to_commit_msg],
  160. },
  161. });
  162. steps.push(Step {
  163. side: "target",
  164. description: format!("git -C {} push {} {}", to_dir.display(), to.url, to_branch),
  165. run: Run::Push {
  166. cwd: to_dir.clone(),
  167. url: crate::recipe_repos::push_url(to),
  168. branch: to_branch.clone(),
  169. },
  170. });
  171. steps.push(Step {
  172. side: "source",
  173. description: format!("git -C {} rm -r recipes/{}", from_dir.display(), recipe),
  174. run: Run::Git {
  175. cwd: from_dir.clone(),
  176. args: vec!["rm".into(), "-r".into(), format!("recipes/{recipe}")],
  177. },
  178. });
  179. let from_commit_msg = format!("promote {recipe}: remove (promoted to {to_url})");
  180. steps.push(Step {
  181. side: "source",
  182. description: format!(
  183. "git -C {} commit -m {}",
  184. from_dir.display(),
  185. shq(&from_commit_msg)
  186. ),
  187. run: Run::Git {
  188. cwd: from_dir.clone(),
  189. args: vec!["commit".into(), "-m".into(), from_commit_msg],
  190. },
  191. });
  192. steps.push(Step {
  193. side: "source",
  194. description: format!(
  195. "git -C {} push {} {}",
  196. from_dir.display(),
  197. from.url,
  198. from_branch
  199. ),
  200. run: Run::Push {
  201. cwd: from_dir.clone(),
  202. url: crate::recipe_repos::push_url(from),
  203. branch: from_branch,
  204. },
  205. });
  206. Ok(PromotePlan {
  207. recipe: recipe.to_string(),
  208. from_url: from_url.to_string(),
  209. to_url: to_url.to_string(),
  210. from_checkout: from_dir,
  211. to_checkout: to_dir,
  212. steps,
  213. })
  214. }
  215. /// Run every step of the plan (in order; a failure stops the promote,
  216. /// leaving the recipe at least duplicated, never deleted).
  217. pub fn execute(plan: &PromotePlan) -> Result<()> {
  218. for step in &plan.steps {
  219. match &step.run {
  220. Run::Copy { from, to } => {
  221. std::fs::create_dir_all(
  222. to.parent()
  223. .context(fluent_i18n::t!("promote-target-dir-context"))?,
  224. )?;
  225. let out = std::process::Command::new("cp")
  226. .arg("-a")
  227. .arg(from)
  228. .arg(to)
  229. .output()
  230. .context(fluent_i18n::t!("promote-cp-context"))?;
  231. if !out.status.success() {
  232. anyhow::bail!(fluent_i18n::t!(
  233. "promote-cp-failed",
  234. { "err" => String::from_utf8_lossy(&out.stderr).trim().to_string() }
  235. ));
  236. }
  237. }
  238. Run::Git { cwd, args } => {
  239. let cwd_s = cwd.display().to_string();
  240. let mut a: Vec<&str> = vec!["-C", &cwd_s];
  241. a.extend(args.iter().map(String::as_str));
  242. git(&a)?;
  243. }
  244. Run::Push { cwd, url, branch } => {
  245. git(&["-C", &cwd.display().to_string(), "push", url, branch])?;
  246. }
  247. }
  248. println!(
  249. "{}",
  250. fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description })
  251. );
  252. }
  253. Ok(())
  254. }
  255. /// `sm121 promote <recipe> --from <url> --to <url> [--apply]` dispatch:
  256. /// plan, print (default / `-d`), execute (`--apply`).
  257. pub fn run(
  258. config: &Config,
  259. recipe: &str,
  260. from_url: &str,
  261. to_url: &str,
  262. apply: bool,
  263. dry_run: bool,
  264. ) -> Result<()> {
  265. let plan = plan(config, recipe, from_url, to_url, !dry_run)?;
  266. if apply && !dry_run {
  267. println!(
  268. "{}",
  269. fluent_i18n::t!("promote-run-start", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url, "count" => plan.steps.len() })
  270. );
  271. println!(
  272. "{}",
  273. fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() })
  274. );
  275. println!(
  276. "{}",
  277. fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() })
  278. );
  279. execute(&plan)?;
  280. println!(
  281. "{}",
  282. fluent_i18n::t!("promote-done", { "recipe" => &plan.recipe })
  283. );
  284. return Ok(());
  285. }
  286. println!(
  287. "{}",
  288. fluent_i18n::t!("promote-dry-head", { "recipe" => &plan.recipe, "from" => &plan.from_url, "to" => &plan.to_url })
  289. );
  290. println!(
  291. "{}",
  292. fluent_i18n::t!("promote-source-checkout", { "path" => plan.from_checkout.display().to_string() })
  293. );
  294. println!(
  295. "{}",
  296. fluent_i18n::t!("promote-target-checkout", { "path" => plan.to_checkout.display().to_string() })
  297. );
  298. for step in &plan.steps {
  299. println!(
  300. "{}",
  301. fluent_i18n::t!("promote-step-line", { "side" => step.side, "description" => &step.description })
  302. );
  303. }
  304. if apply {
  305. println!("{}", fluent_i18n::t!("promote-dry-suppressed"));
  306. }
  307. println!("{}", fluent_i18n::t!("promote-run-apply-hint"));
  308. Ok(())
  309. }
  310. #[cfg(test)]
  311. #[path = "promote_tests.rs"]
  312. mod tests;