mirror of
https://github.com/rust-lang/cargo.git
synced 2025-09-28 11:20:36 +00:00
416 lines
14 KiB
Rust
416 lines
14 KiB
Rust
//! ```text
|
|
//! NAME
|
|
//! xtask-bump-check
|
|
//!
|
|
//! SYNOPSIS
|
|
//! xtask-bump-check --base-rev <REV> --head-rev <REV>
|
|
//!
|
|
//! DESCRIPTION
|
|
//! Checks if there is any member got changed since a base commit
|
|
//! but forgot to bump its version.
|
|
//! ```
|
|
|
|
use std::collections::HashSet;
|
|
use std::fmt::Write;
|
|
use std::fs;
|
|
use std::task;
|
|
|
|
use cargo::core::dependency::Dependency;
|
|
use cargo::core::registry::PackageRegistry;
|
|
use cargo::core::Package;
|
|
use cargo::core::QueryKind;
|
|
use cargo::core::Registry;
|
|
use cargo::core::SourceId;
|
|
use cargo::core::Workspace;
|
|
use cargo::util::command_prelude::*;
|
|
use cargo::util::ToSemver;
|
|
use cargo::CargoResult;
|
|
use cargo_util::ProcessBuilder;
|
|
|
|
const UPSTREAM_BRANCH: &str = "master";
|
|
const STATUS: &str = "BumpCheck";
|
|
|
|
pub fn cli() -> clap::Command {
|
|
clap::Command::new("xtask-bump-check")
|
|
.arg(
|
|
opt(
|
|
"verbose",
|
|
"Use verbose output (-vv very verbose/build.rs output)",
|
|
)
|
|
.short('v')
|
|
.action(ArgAction::Count)
|
|
.global(true),
|
|
)
|
|
.arg_quiet()
|
|
.arg(
|
|
opt("color", "Coloring: auto, always, never")
|
|
.value_name("WHEN")
|
|
.global(true),
|
|
)
|
|
.arg(opt("base-rev", "Git revision to lookup for a baseline"))
|
|
.arg(opt("head-rev", "Git revision with changes"))
|
|
.arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true))
|
|
.arg(flag("locked", "Require Cargo.lock is up to date").global(true))
|
|
.arg(flag("offline", "Run without accessing the network").global(true))
|
|
.arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true))
|
|
.arg(
|
|
Arg::new("unstable-features")
|
|
.help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details")
|
|
.short('Z')
|
|
.value_name("FLAG")
|
|
.action(ArgAction::Append)
|
|
.global(true),
|
|
)
|
|
}
|
|
|
|
pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult {
|
|
config_configure(config, args)?;
|
|
|
|
bump_check(args, config)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult {
|
|
let verbose = args.verbose();
|
|
// quiet is unusual because it is redefined in some subcommands in order
|
|
// to provide custom help text.
|
|
let quiet = args.flag("quiet");
|
|
let color = args.get_one::<String>("color").map(String::as_str);
|
|
let frozen = args.flag("frozen");
|
|
let locked = args.flag("locked");
|
|
let offline = args.flag("offline");
|
|
let mut unstable_flags = vec![];
|
|
if let Some(values) = args.get_many::<String>("unstable-features") {
|
|
unstable_flags.extend(values.cloned());
|
|
}
|
|
let mut config_args = vec![];
|
|
if let Some(values) = args.get_many::<String>("config") {
|
|
config_args.extend(values.cloned());
|
|
}
|
|
config.configure(
|
|
verbose,
|
|
quiet,
|
|
color,
|
|
frozen,
|
|
locked,
|
|
offline,
|
|
&None,
|
|
&unstable_flags,
|
|
&config_args,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Main entry of `xtask-bump-check`.
|
|
///
|
|
/// Assumption: version number are incremental. We never have point release for old versions.
|
|
fn bump_check(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> CargoResult<()> {
|
|
let ws = args.workspace(config)?;
|
|
let repo = git2::Repository::open(ws.root())?;
|
|
let base_commit = get_base_commit(config, args, &repo)?;
|
|
let head_commit = get_head_commit(args, &repo)?;
|
|
let referenced_commit = get_referenced_commit(&repo, &base_commit)?;
|
|
let changed_members = changed(&ws, &repo, &base_commit, &head_commit)?;
|
|
let status = |msg: &str| config.shell().status(STATUS, msg);
|
|
|
|
status(&format!("base commit `{}`", base_commit.id()))?;
|
|
status(&format!("head commit `{}`", head_commit.id()))?;
|
|
|
|
let mut needs_bump = Vec::new();
|
|
|
|
check_crates_io(config, &changed_members, &mut needs_bump)?;
|
|
|
|
if let Some(referenced_commit) = referenced_commit.as_ref() {
|
|
status(&format!("compare against `{}`", referenced_commit.id()))?;
|
|
for referenced_member in checkout_ws(&ws, &repo, referenced_commit)?.members() {
|
|
let Some(changed_member) = changed_members.get(referenced_member) else {
|
|
let name = referenced_member.name().as_str();
|
|
tracing::trace!("skipping {name}, may be removed or not published");
|
|
continue;
|
|
};
|
|
|
|
if changed_member.version() <= referenced_member.version() {
|
|
needs_bump.push(*changed_member);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !needs_bump.is_empty() {
|
|
needs_bump.sort();
|
|
needs_bump.dedup();
|
|
let mut msg = String::new();
|
|
msg.push_str("Detected changes in these crates but no version bump found:\n");
|
|
for pkg in needs_bump {
|
|
writeln!(&mut msg, " {}@{}", pkg.name(), pkg.version())?;
|
|
}
|
|
msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml.");
|
|
anyhow::bail!(msg)
|
|
}
|
|
|
|
// Tracked by https://github.com/obi1kenobi/cargo-semver-checks/issues/511
|
|
let exclude_args = [
|
|
"--exclude",
|
|
"cargo-credential-1password",
|
|
"--exclude",
|
|
"cargo-credential-gnome-secret",
|
|
"--exclude",
|
|
"cargo-credential-macos-keychain",
|
|
"--exclude",
|
|
"cargo-credential-wincred",
|
|
];
|
|
|
|
// Even when we test against baseline-rev, we still need to make sure a
|
|
// change doesn't violate SemVer rules aginst crates.io releases. The
|
|
// possibility of this happening is nearly zero but no harm to check twice.
|
|
let mut cmd = ProcessBuilder::new("cargo");
|
|
cmd.arg("semver-checks")
|
|
.arg("check-release")
|
|
.arg("--workspace")
|
|
.args(&exclude_args);
|
|
config.shell().status("Running", &cmd)?;
|
|
cmd.exec()?;
|
|
|
|
if let Some(referenced_commit) = referenced_commit.as_ref() {
|
|
let mut cmd = ProcessBuilder::new("cargo");
|
|
cmd.arg("semver-checks")
|
|
.arg("--workspace")
|
|
.arg("--baseline-rev")
|
|
.arg(referenced_commit.id().to_string())
|
|
.args(&exclude_args);
|
|
config.shell().status("Running", &cmd)?;
|
|
cmd.exec()?;
|
|
}
|
|
|
|
status("no version bump needed for member crates.")?;
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
/// Returns the commit of upstream `master` branch if `base-rev` is missing.
|
|
fn get_base_commit<'a>(
|
|
config: &Config,
|
|
args: &clap::ArgMatches,
|
|
repo: &'a git2::Repository,
|
|
) -> CargoResult<git2::Commit<'a>> {
|
|
let base_commit = match args.get_one::<String>("base-rev") {
|
|
Some(sha) => {
|
|
let obj = repo.revparse_single(sha)?;
|
|
obj.peel_to_commit()?
|
|
}
|
|
None => {
|
|
let upstream_branches = repo
|
|
.branches(Some(git2::BranchType::Remote))?
|
|
.filter_map(|r| r.ok())
|
|
.filter(|(b, _)| {
|
|
b.name()
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_default()
|
|
.ends_with(&format!("/{UPSTREAM_BRANCH}"))
|
|
})
|
|
.map(|(b, _)| b)
|
|
.collect::<Vec<_>>();
|
|
if upstream_branches.is_empty() {
|
|
anyhow::bail!(
|
|
"could not find `base-sha` for `{UPSTREAM_BRANCH}`, pass it in directly"
|
|
);
|
|
}
|
|
let upstream_ref = upstream_branches[0].get();
|
|
if upstream_branches.len() > 1 {
|
|
let name = upstream_ref.name().expect("name is valid UTF-8");
|
|
let _ = config.shell().warn(format!(
|
|
"multiple `{UPSTREAM_BRANCH}` found, picking {name}"
|
|
));
|
|
}
|
|
upstream_ref.peel_to_commit()?
|
|
}
|
|
};
|
|
Ok(base_commit)
|
|
}
|
|
|
|
/// Returns `HEAD` of the Git repository if `head-rev` is missing.
|
|
fn get_head_commit<'a>(
|
|
args: &clap::ArgMatches,
|
|
repo: &'a git2::Repository,
|
|
) -> CargoResult<git2::Commit<'a>> {
|
|
let head_commit = match args.get_one::<String>("head-rev") {
|
|
Some(sha) => {
|
|
let head_obj = repo.revparse_single(sha)?;
|
|
head_obj.peel_to_commit()?
|
|
}
|
|
None => {
|
|
let head_ref = repo.head()?;
|
|
head_ref.peel_to_commit()?
|
|
}
|
|
};
|
|
Ok(head_commit)
|
|
}
|
|
|
|
/// Gets the referenced commit to compare if version bump needed.
|
|
///
|
|
/// * When merging into nightly, check the version with beta branch
|
|
/// * When merging into beta, check the version with stable branch
|
|
/// * When merging into stable, check against crates.io registry directly
|
|
fn get_referenced_commit<'a>(
|
|
repo: &'a git2::Repository,
|
|
base: &git2::Commit<'a>,
|
|
) -> CargoResult<Option<git2::Commit<'a>>> {
|
|
let [beta, stable] = beta_and_stable_branch(&repo)?;
|
|
let rev_id = base.id();
|
|
let stable_commit = stable.get().peel_to_commit()?;
|
|
let beta_commit = beta.get().peel_to_commit()?;
|
|
|
|
let referenced_commit = if rev_id == stable_commit.id() {
|
|
None
|
|
} else if rev_id == beta_commit.id() {
|
|
tracing::trace!("stable branch from `{}`", stable.name().unwrap().unwrap());
|
|
Some(stable_commit)
|
|
} else {
|
|
tracing::trace!("beta branch from `{}`", beta.name().unwrap().unwrap());
|
|
Some(beta_commit)
|
|
};
|
|
|
|
Ok(referenced_commit)
|
|
}
|
|
|
|
/// Get the current beta and stable branch in cargo repository.
|
|
///
|
|
/// Assumptions:
|
|
///
|
|
/// * The repository contains the full history of `<remote>/rust-1.*.0` branches.
|
|
/// * The version part of `<remote>/rust-1.*.0` always ends with a zero.
|
|
/// * The maximum version is for beta channel, and the second one is for stable.
|
|
fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> {
|
|
let mut release_branches = Vec::new();
|
|
for branch in repo.branches(Some(git2::BranchType::Remote))? {
|
|
let (branch, _) = branch?;
|
|
let name = branch.name()?.unwrap();
|
|
let Some((_, version)) = name.split_once("/rust-") else {
|
|
tracing::trace!("branch `{name}` is not in the format of `<remote>/rust-<semver>`");
|
|
continue;
|
|
};
|
|
let Ok(version) = version.to_semver() else {
|
|
tracing::trace!("branch `{name}` is not a valid semver: `{version}`");
|
|
continue;
|
|
};
|
|
release_branches.push((version, branch));
|
|
}
|
|
release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0));
|
|
release_branches.dedup_by(|a, b| a.0 == b.0);
|
|
|
|
let beta = release_branches.pop().unwrap();
|
|
let stable = release_branches.pop().unwrap();
|
|
|
|
assert_eq!(beta.0.major, 1);
|
|
assert_eq!(beta.0.patch, 0);
|
|
assert_eq!(stable.0.major, 1);
|
|
assert_eq!(stable.0.patch, 0);
|
|
assert_ne!(beta.0.minor, stable.0.minor);
|
|
|
|
Ok([beta.1, stable.1])
|
|
}
|
|
|
|
/// Lists all changed workspace members between two commits.
|
|
fn changed<'r, 'ws>(
|
|
ws: &'ws Workspace<'_>,
|
|
repo: &'r git2::Repository,
|
|
base_commit: &git2::Commit<'r>,
|
|
head: &git2::Commit<'r>,
|
|
) -> CargoResult<HashSet<&'ws Package>> {
|
|
let root_pkg_name = ws.current()?.name(); // `cargo` crate.
|
|
let ws_members = ws
|
|
.members()
|
|
.filter(|pkg| pkg.name() != root_pkg_name) // Only take care of sub crates here.
|
|
.filter(|pkg| pkg.publish() != &Some(vec![])) // filter out `publish = false`
|
|
.map(|pkg| {
|
|
// Having relative package root path so that we can compare with
|
|
// paths of changed files to determine which package has changed.
|
|
let relative_pkg_root = pkg.root().strip_prefix(ws.root()).unwrap();
|
|
(relative_pkg_root, pkg)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let base_tree = base_commit.as_object().peel_to_tree()?;
|
|
let head_tree = head.as_object().peel_to_tree()?;
|
|
let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Default::default())?;
|
|
|
|
let mut changed_members = HashSet::new();
|
|
|
|
for delta in diff.deltas() {
|
|
let old = delta.old_file().path().unwrap();
|
|
let new = delta.new_file().path().unwrap();
|
|
for (ref pkg_root, pkg) in ws_members.iter() {
|
|
if old.starts_with(pkg_root) || new.starts_with(pkg_root) {
|
|
changed_members.insert(*pkg);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(changed_members)
|
|
}
|
|
|
|
/// Compares version against published crates on crates.io.
|
|
///
|
|
/// Assumption: We always release a version larger than all existing versions.
|
|
fn check_crates_io<'a>(
|
|
config: &Config,
|
|
changed_members: &HashSet<&'a Package>,
|
|
needs_bump: &mut Vec<&'a Package>,
|
|
) -> CargoResult<()> {
|
|
let source_id = SourceId::crates_io(config)?;
|
|
let mut registry = PackageRegistry::new(config)?;
|
|
let _lock = config.acquire_package_cache_lock()?;
|
|
registry.lock_patches();
|
|
config.shell().status(
|
|
STATUS,
|
|
format_args!("compare against `{}`", source_id.display_registry_name()),
|
|
)?;
|
|
for member in changed_members {
|
|
let (name, current) = (member.name(), member.version());
|
|
let version_req = format!(">={current}");
|
|
let query = Dependency::parse(name, Some(&version_req), source_id)?;
|
|
let possibilities = loop {
|
|
// Exact to avoid returning all for path/git
|
|
match registry.query_vec(&query, QueryKind::Exact) {
|
|
task::Poll::Ready(res) => {
|
|
break res?;
|
|
}
|
|
task::Poll::Pending => registry.block_until_ready()?,
|
|
}
|
|
};
|
|
if possibilities.is_empty() {
|
|
tracing::trace!("dep `{name}` has no version greater than or equal to `{current}`");
|
|
} else {
|
|
needs_bump.push(member);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Checkouts a temporary workspace to do further version comparsions.
|
|
fn checkout_ws<'cfg, 'a>(
|
|
ws: &Workspace<'cfg>,
|
|
repo: &'a git2::Repository,
|
|
referenced_commit: &git2::Commit<'a>,
|
|
) -> CargoResult<Workspace<'cfg>> {
|
|
let repo_path = repo.path().as_os_str().to_str().unwrap();
|
|
// Put it under `target/cargo-<short-id>`
|
|
let short_id = &referenced_commit.id().to_string()[..7];
|
|
let checkout_path = ws.target_dir().join(format!("cargo-{short_id}"));
|
|
let checkout_path = checkout_path.as_path_unlocked();
|
|
let _ = fs::remove_dir_all(checkout_path);
|
|
let new_repo = git2::build::RepoBuilder::new()
|
|
.clone_local(git2::build::CloneLocal::Local)
|
|
.clone(repo_path, checkout_path)?;
|
|
let obj = new_repo.find_object(referenced_commit.id(), None)?;
|
|
new_repo.reset(&obj, git2::ResetType::Hard, None)?;
|
|
Workspace::new(&checkout_path.join("Cargo.toml"), ws.config())
|
|
}
|
|
|
|
#[test]
|
|
fn verify_cli() {
|
|
cli().debug_assert();
|
|
}
|