]> git.proxmox.com Git - rustc.git/blob - src/tools/cargo/crates/xtask-bump-check/src/xtask.rs
New upstream version 1.74.1+dfsg1
[rustc.git] / src / tools / cargo / crates / xtask-bump-check / src / xtask.rs
1 //! ```text
2 //! NAME
3 //! xtask-bump-check
4 //!
5 //! SYNOPSIS
6 //! xtask-bump-check --base-rev <REV> --head-rev <REV>
7 //!
8 //! DESCRIPTION
9 //! Checks if there is any member got changed since a base commit
10 //! but forgot to bump its version.
11 //! ```
12
13 use std::collections::HashMap;
14 use std::fmt::Write;
15 use std::fs;
16 use std::task;
17
18 use cargo::core::dependency::Dependency;
19 use cargo::core::registry::PackageRegistry;
20 use cargo::core::Package;
21 use cargo::core::Registry;
22 use cargo::core::SourceId;
23 use cargo::core::Workspace;
24 use cargo::sources::source::QueryKind;
25 use cargo::util::command_prelude::*;
26 use cargo::util::ToSemver;
27 use cargo::CargoResult;
28 use cargo_util::ProcessBuilder;
29
30 const UPSTREAM_BRANCH: &str = "master";
31 const STATUS: &str = "BumpCheck";
32
33 pub fn cli() -> clap::Command {
34 clap::Command::new("xtask-bump-check")
35 .arg(
36 opt(
37 "verbose",
38 "Use verbose output (-vv very verbose/build.rs output)",
39 )
40 .short('v')
41 .action(ArgAction::Count)
42 .global(true),
43 )
44 .arg_quiet()
45 .arg(
46 opt("color", "Coloring: auto, always, never")
47 .value_name("WHEN")
48 .global(true),
49 )
50 .arg(opt("base-rev", "Git revision to lookup for a baseline"))
51 .arg(opt("head-rev", "Git revision with changes"))
52 .arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true))
53 .arg(flag("locked", "Require Cargo.lock is up to date").global(true))
54 .arg(flag("offline", "Run without accessing the network").global(true))
55 .arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true))
56 .arg(
57 Arg::new("unstable-features")
58 .help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details")
59 .short('Z')
60 .value_name("FLAG")
61 .action(ArgAction::Append)
62 .global(true),
63 )
64 }
65
66 pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult {
67 config_configure(config, args)?;
68
69 bump_check(args, config)?;
70
71 Ok(())
72 }
73
74 fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult {
75 let verbose = args.verbose();
76 // quiet is unusual because it is redefined in some subcommands in order
77 // to provide custom help text.
78 let quiet = args.flag("quiet");
79 let color = args.get_one::<String>("color").map(String::as_str);
80 let frozen = args.flag("frozen");
81 let locked = args.flag("locked");
82 let offline = args.flag("offline");
83 let mut unstable_flags = vec![];
84 if let Some(values) = args.get_many::<String>("unstable-features") {
85 unstable_flags.extend(values.cloned());
86 }
87 let mut config_args = vec![];
88 if let Some(values) = args.get_many::<String>("config") {
89 config_args.extend(values.cloned());
90 }
91 config.configure(
92 verbose,
93 quiet,
94 color,
95 frozen,
96 locked,
97 offline,
98 &None,
99 &unstable_flags,
100 &config_args,
101 )?;
102 Ok(())
103 }
104
105 /// Main entry of `xtask-bump-check`.
106 ///
107 /// Assumption: version number are incremental. We never have point release for old versions.
108 fn bump_check(args: &clap::ArgMatches, config: &cargo::util::Config) -> CargoResult<()> {
109 let ws = args.workspace(config)?;
110 let repo = git2::Repository::open(ws.root())?;
111 let base_commit = get_base_commit(config, args, &repo)?;
112 let head_commit = get_head_commit(args, &repo)?;
113 let referenced_commit = get_referenced_commit(&repo, &base_commit)?;
114 let changed_members = changed(&ws, &repo, &base_commit, &head_commit)?;
115 let status = |msg: &str| config.shell().status(STATUS, msg);
116
117 status(&format!("base commit `{}`", base_commit.id()))?;
118 status(&format!("head commit `{}`", head_commit.id()))?;
119
120 let mut needs_bump = Vec::new();
121
122 check_crates_io(config, &changed_members, &mut needs_bump)?;
123
124 if let Some(referenced_commit) = referenced_commit.as_ref() {
125 status(&format!("compare against `{}`", referenced_commit.id()))?;
126 for referenced_member in checkout_ws(&ws, &repo, referenced_commit)?.members() {
127 let pkg_name = referenced_member.name().as_str();
128 let Some(changed_member) = changed_members.get(pkg_name) else {
129 tracing::trace!("skipping {pkg_name}, may be removed or not published");
130 continue;
131 };
132
133 if changed_member.version() <= referenced_member.version() {
134 needs_bump.push(*changed_member);
135 }
136 }
137 }
138
139 if !needs_bump.is_empty() {
140 needs_bump.sort();
141 needs_bump.dedup();
142 let mut msg = String::new();
143 msg.push_str("Detected changes in these crates but no version bump found:\n");
144 for pkg in needs_bump {
145 writeln!(&mut msg, " {}@{}", pkg.name(), pkg.version())?;
146 }
147 msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml.");
148 anyhow::bail!(msg)
149 }
150
151 // Tracked by https://github.com/obi1kenobi/cargo-semver-checks/issues/511
152 let exclude_args = [
153 "--exclude",
154 "cargo-credential-1password",
155 "--exclude",
156 "cargo-credential-libsecret",
157 "--exclude",
158 "cargo-credential-macos-keychain",
159 "--exclude",
160 "cargo-credential-wincred",
161 ];
162
163 // Even when we test against baseline-rev, we still need to make sure a
164 // change doesn't violate SemVer rules against crates.io releases. The
165 // possibility of this happening is nearly zero but no harm to check twice.
166 let mut cmd = ProcessBuilder::new("cargo");
167 cmd.arg("semver-checks")
168 .arg("check-release")
169 .arg("--workspace")
170 .args(&exclude_args);
171 config.shell().status("Running", &cmd)?;
172 cmd.exec()?;
173
174 if let Some(referenced_commit) = referenced_commit.as_ref() {
175 let mut cmd = ProcessBuilder::new("cargo");
176 cmd.arg("semver-checks")
177 .arg("--workspace")
178 .arg("--baseline-rev")
179 .arg(referenced_commit.id().to_string())
180 .args(&exclude_args);
181 config.shell().status("Running", &cmd)?;
182 cmd.exec()?;
183 }
184
185 status("no version bump needed for member crates.")?;
186
187 Ok(())
188 }
189
190 /// Returns the commit of upstream `master` branch if `base-rev` is missing.
191 fn get_base_commit<'a>(
192 config: &Config,
193 args: &clap::ArgMatches,
194 repo: &'a git2::Repository,
195 ) -> CargoResult<git2::Commit<'a>> {
196 let base_commit = match args.get_one::<String>("base-rev") {
197 Some(sha) => {
198 let obj = repo.revparse_single(sha)?;
199 obj.peel_to_commit()?
200 }
201 None => {
202 let upstream_branches = repo
203 .branches(Some(git2::BranchType::Remote))?
204 .filter_map(|r| r.ok())
205 .filter(|(b, _)| {
206 b.name()
207 .ok()
208 .flatten()
209 .unwrap_or_default()
210 .ends_with(&format!("/{UPSTREAM_BRANCH}"))
211 })
212 .map(|(b, _)| b)
213 .collect::<Vec<_>>();
214 if upstream_branches.is_empty() {
215 anyhow::bail!(
216 "could not find `base-sha` for `{UPSTREAM_BRANCH}`, pass it in directly"
217 );
218 }
219 let upstream_ref = upstream_branches[0].get();
220 if upstream_branches.len() > 1 {
221 let name = upstream_ref.name().expect("name is valid UTF-8");
222 let _ = config.shell().warn(format!(
223 "multiple `{UPSTREAM_BRANCH}` found, picking {name}"
224 ));
225 }
226 upstream_ref.peel_to_commit()?
227 }
228 };
229 Ok(base_commit)
230 }
231
232 /// Returns `HEAD` of the Git repository if `head-rev` is missing.
233 fn get_head_commit<'a>(
234 args: &clap::ArgMatches,
235 repo: &'a git2::Repository,
236 ) -> CargoResult<git2::Commit<'a>> {
237 let head_commit = match args.get_one::<String>("head-rev") {
238 Some(sha) => {
239 let head_obj = repo.revparse_single(sha)?;
240 head_obj.peel_to_commit()?
241 }
242 None => {
243 let head_ref = repo.head()?;
244 head_ref.peel_to_commit()?
245 }
246 };
247 Ok(head_commit)
248 }
249
250 /// Gets the referenced commit to compare if version bump needed.
251 ///
252 /// * When merging into nightly, check the version with beta branch
253 /// * When merging into beta, check the version with stable branch
254 /// * When merging into stable, check against crates.io registry directly
255 fn get_referenced_commit<'a>(
256 repo: &'a git2::Repository,
257 base: &git2::Commit<'a>,
258 ) -> CargoResult<Option<git2::Commit<'a>>> {
259 let [beta, stable] = beta_and_stable_branch(repo)?;
260 let rev_id = base.id();
261 let stable_commit = stable.get().peel_to_commit()?;
262 let beta_commit = beta.get().peel_to_commit()?;
263
264 let referenced_commit = if rev_id == stable_commit.id() {
265 None
266 } else if rev_id == beta_commit.id() {
267 tracing::trace!("stable branch from `{}`", stable.name().unwrap().unwrap());
268 Some(stable_commit)
269 } else {
270 tracing::trace!("beta branch from `{}`", beta.name().unwrap().unwrap());
271 Some(beta_commit)
272 };
273
274 Ok(referenced_commit)
275 }
276
277 /// Get the current beta and stable branch in cargo repository.
278 ///
279 /// Assumptions:
280 ///
281 /// * The repository contains the full history of `<remote>/rust-1.*.0` branches.
282 /// * The version part of `<remote>/rust-1.*.0` always ends with a zero.
283 /// * The maximum version is for beta channel, and the second one is for stable.
284 fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> {
285 let mut release_branches = Vec::new();
286 for branch in repo.branches(Some(git2::BranchType::Remote))? {
287 let (branch, _) = branch?;
288 let name = branch.name()?.unwrap();
289 let Some((_, version)) = name.split_once("/rust-") else {
290 tracing::trace!("branch `{name}` is not in the format of `<remote>/rust-<semver>`");
291 continue;
292 };
293 let Ok(version) = version.to_semver() else {
294 tracing::trace!("branch `{name}` is not a valid semver: `{version}`");
295 continue;
296 };
297 release_branches.push((version, branch));
298 }
299 release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0));
300 release_branches.dedup_by(|a, b| a.0 == b.0);
301
302 let beta = release_branches.pop().unwrap();
303 let stable = release_branches.pop().unwrap();
304
305 assert_eq!(beta.0.major, 1);
306 assert_eq!(beta.0.patch, 0);
307 assert_eq!(stable.0.major, 1);
308 assert_eq!(stable.0.patch, 0);
309 assert_ne!(beta.0.minor, stable.0.minor);
310
311 Ok([beta.1, stable.1])
312 }
313
314 /// Lists all changed workspace members between two commits.
315 fn changed<'r, 'ws>(
316 ws: &'ws Workspace<'_>,
317 repo: &'r git2::Repository,
318 base_commit: &git2::Commit<'r>,
319 head: &git2::Commit<'r>,
320 ) -> CargoResult<HashMap<&'ws str, &'ws Package>> {
321 let root_pkg_name = ws.current()?.name(); // `cargo` crate.
322 let ws_members = ws
323 .members()
324 .filter(|pkg| pkg.name() != root_pkg_name) // Only take care of sub crates here.
325 .filter(|pkg| pkg.publish() != &Some(vec![])) // filter out `publish = false`
326 .map(|pkg| {
327 // Having relative package root path so that we can compare with
328 // paths of changed files to determine which package has changed.
329 let relative_pkg_root = pkg.root().strip_prefix(ws.root()).unwrap();
330 (relative_pkg_root, pkg)
331 })
332 .collect::<Vec<_>>();
333 let base_tree = base_commit.as_object().peel_to_tree()?;
334 let head_tree = head.as_object().peel_to_tree()?;
335 let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Default::default())?;
336
337 let mut changed_members = HashMap::new();
338
339 for delta in diff.deltas() {
340 let old = delta.old_file().path().unwrap();
341 let new = delta.new_file().path().unwrap();
342 for (ref pkg_root, pkg) in ws_members.iter() {
343 if old.starts_with(pkg_root) || new.starts_with(pkg_root) {
344 changed_members.insert(pkg.name().as_str(), *pkg);
345 break;
346 }
347 }
348 }
349
350 tracing::trace!("changed_members: {:?}", changed_members.keys());
351 Ok(changed_members)
352 }
353
354 /// Compares version against published crates on crates.io.
355 ///
356 /// Assumption: We always release a version larger than all existing versions.
357 fn check_crates_io<'a>(
358 config: &Config,
359 changed_members: &HashMap<&'a str, &'a Package>,
360 needs_bump: &mut Vec<&'a Package>,
361 ) -> CargoResult<()> {
362 let source_id = SourceId::crates_io(config)?;
363 let mut registry = PackageRegistry::new(config)?;
364 let _lock = config.acquire_package_cache_lock()?;
365 registry.lock_patches();
366 config.shell().status(
367 STATUS,
368 format_args!("compare against `{}`", source_id.display_registry_name()),
369 )?;
370 for (name, member) in changed_members {
371 let current = member.version();
372 let version_req = format!(">={current}");
373 let query = Dependency::parse(*name, Some(&version_req), source_id)?;
374 let possibilities = loop {
375 // Exact to avoid returning all for path/git
376 match registry.query_vec(&query, QueryKind::Exact) {
377 task::Poll::Ready(res) => {
378 break res?;
379 }
380 task::Poll::Pending => registry.block_until_ready()?,
381 }
382 };
383 if possibilities.is_empty() {
384 tracing::trace!("dep `{name}` has no version greater than or equal to `{current}`");
385 } else {
386 tracing::trace!(
387 "`{name}@{current}` needs a bump because its should have a version newer than crates.io: {:?}`",
388 possibilities
389 .iter()
390 .map(|s| format!("{}@{}", s.name(), s.version()))
391 .collect::<Vec<_>>(),
392 );
393 needs_bump.push(member);
394 }
395 }
396
397 Ok(())
398 }
399
400 /// Checkouts a temporary workspace to do further version comparisons.
401 fn checkout_ws<'cfg, 'a>(
402 ws: &Workspace<'cfg>,
403 repo: &'a git2::Repository,
404 referenced_commit: &git2::Commit<'a>,
405 ) -> CargoResult<Workspace<'cfg>> {
406 let repo_path = repo.path().as_os_str().to_str().unwrap();
407 // Put it under `target/cargo-<short-id>`
408 let short_id = &referenced_commit.id().to_string()[..7];
409 let checkout_path = ws.target_dir().join(format!("cargo-{short_id}"));
410 let checkout_path = checkout_path.as_path_unlocked();
411 let _ = fs::remove_dir_all(checkout_path);
412 let new_repo = git2::build::RepoBuilder::new()
413 .clone_local(git2::build::CloneLocal::Local)
414 .clone(repo_path, checkout_path)?;
415 let obj = new_repo.find_object(referenced_commit.id(), None)?;
416 new_repo.reset(&obj, git2::ResetType::Hard, None)?;
417 Workspace::new(&checkout_path.join("Cargo.toml"), ws.config())
418 }
419
420 #[test]
421 fn verify_cli() {
422 cli().debug_assert();
423 }