summaryrefslogtreecommitdiffstats
path: root/src/tools/cargo/crates/xtask-bump-check/src/xtask.rs
blob: f891523315d3b6ecc66ff6833ce78bd3758c054b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! ```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::HashMap;
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 pkg_name = referenced_member.name().as_str();
            let Some(changed_member) = changed_members.get(pkg_name) else {
                tracing::trace!("skipping {pkg_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-libsecret",
        "--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<HashMap<&'ws str, &'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 = HashMap::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.name().as_str(), *pkg);
                break;
            }
        }
    }

    tracing::trace!("changed_members: {:?}", changed_members.keys());
    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: &HashMap<&'a str, &'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 (name, member) in changed_members {
        let current = 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 {
            tracing::trace!(
                "`{name}@{current}` needs a bump because its should have a version newer than crates.io: {:?}`",
                possibilities
                    .iter()
                    .map(|s| format!("{}@{}", s.name(), s.version()))
                    .collect::<Vec<_>>(),
            );
            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();
}