Skip to main content

release_plz_core/
next_ver.rs

1use crate::cargo::run_cargo;
2use crate::command::git::{GitRepo, GitWorkTree};
3use crate::registry_packages::{PackagesCollection, RegistryPackage};
4use crate::release_regex;
5use crate::tera::default_tag_name_template;
6use crate::tmp_repo::TempRepo;
7use crate::update_request::UpdateRequest;
8use crate::updater::Updater;
9use crate::{
10    PackagesUpdate, Project,
11    changelog_parser::{self, ChangelogRelease},
12    copy_dir::copy_dir,
13    fs_utils::{Utf8TempDir, strip_prefix, to_utf8_path},
14    package_path::manifest_dir,
15    registry_packages::{self},
16    semver_check::SemverCheck,
17};
18use anyhow::Context;
19use cargo_metadata::TargetKind;
20use cargo_metadata::{
21    Metadata, MetadataCommand, Package,
22    camino::{Utf8Path, Utf8PathBuf},
23    semver::Version,
24};
25use cargo_utils::get_manifest_metadata;
26use chrono::NaiveDate;
27use std::collections::BTreeMap;
28use std::path::PathBuf;
29use toml_edit::TableLike;
30use tracing::{debug, info, instrument, trace};
31
32// Used to indicate that this is a dummy commit with no corresponding ID available.
33// It should be at least 7 characters long to avoid a panic in git-cliff
34// (Git-cliff assumes it's a valid commit ID).
35pub(crate) const NO_COMMIT_ID: &str = "0000000";
36
37#[derive(Debug, Clone)]
38pub struct ReleaseMetadata {
39    /// Template for the git tag created by release-plz.
40    pub tag_name_template: Option<String>,
41    /// Template for the git release name created by release-plz.
42    pub release_name_template: Option<String>,
43}
44
45pub trait ReleaseMetadataBuilder {
46    fn get_release_metadata(&self, package_name: &str) -> Option<ReleaseMetadata>;
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct ChangelogRequest {
51    /// When the new release is published. If unspecified, current date is used.
52    pub release_date: Option<NaiveDate>,
53    pub changelog_config: Option<git_cliff_core::config::Config>,
54}
55
56impl ReleaseMetadataBuilder for UpdateRequest {
57    fn get_release_metadata(&self, package_name: &str) -> Option<ReleaseMetadata> {
58        let config = self.get_package_config(package_name);
59        config.generic.release.then(|| ReleaseMetadata {
60            tag_name_template: config.generic.tag_name_template.clone(),
61            release_name_template: None,
62        })
63    }
64}
65
66/// Create a temporary worktree and its associated repo.
67///
68/// If using the CLI, working in a worktree is the same as working in a repo, but in git2 they are
69/// considered different objects with different methods so we return both. The drop order for these
70/// doesn't actually matter, because the repo will become invalid when the worktree drops. But we
71/// typically want to drop the repo first just to avoid the possibility of someone using an invalid
72/// repo.
73fn get_temp_worktree_and_repo(
74    original_repo: &mut GitRepo,
75    package_name: &str,
76) -> anyhow::Result<(GitRepo, GitWorkTree)> {
77    // Clean up any existing worktree with this name
78    original_repo
79        .cleanup_worktree_if_exists(package_name)
80        .context("cleanup existing worktree")?;
81
82    // make a worktree for the package
83    let worktree = original_repo
84        .temp_worktree(Some(package_name), package_name)
85        .context("build worktree for package")?;
86
87    // create repo at new worktree
88    // git2 worktrees don't really contain any functionality, so we have to create a repo
89    // using that path
90    let repo = GitRepo::open(worktree.path()).context("open repo for package")?;
91
92    Ok((repo, worktree))
93}
94
95/// Process a single `git_only` package: find its release tag, checkout that commit,
96/// run `cargo package`, and return the package metadata.
97///
98/// Returns `None` if no release tag is found (package will be treated as initial release).
99#[instrument(skip_all, fields(package_name = %package.name))]
100fn process_git_only_package(
101    package: &Package,
102    unreleased_project_repo: &mut GitRepo,
103    input: &UpdateRequest,
104    is_multi_package: bool,
105) -> anyhow::Result<Option<(RegistryPackage, GitWorkTree)>> {
106    // Get the release tag template, falling back to default based on project structure
107    let template = input
108        .get_package_tag_name(&package.name)
109        .unwrap_or_else(|| default_tag_name_template(is_multi_package));
110
111    let release_regex =
112        release_regex::get_release_regex(&template, &package.name).context("get release regex")?;
113    debug!(
114        "looking for tags matching pattern: {}",
115        release_regex.to_string()
116    );
117
118    // Get the temporary worktree and repo that we run cargo package in
119    let (mut repo, worktree) = get_temp_worktree_and_repo(unreleased_project_repo, &package.name)
120        .context("get worktree and repo for package")?;
121
122    let Some((release_tag, version)) = repo
123        .get_release_tag(&release_regex, &package.name)
124        .context("get release tag")?
125    else {
126        info!(
127            "No release tag found matching pattern `{release_regex}`. \
128             Package {} will be treated as initial release.",
129            package.name
130        );
131        return Ok(None);
132    };
133
134    info!(
135        "Latest release of package {}: tag `{release_tag}` (version {version})",
136        package.name
137    );
138
139    // Get the commit associated with the release tag
140    let release_commit = repo
141        .get_tag_commit(&release_tag)
142        .context("get release tag commit")?;
143
144    // Checkout that commit in the worktree
145    repo.checkout_commit(&release_commit)
146        .context("checkout release commit for package")?;
147
148    // Run cargo package so we have our finalized package.
149    // In git_only mode we always package the whole workspace to make sure
150    // local path dependencies are materialized as local tarballs.
151    run_cargo_package(&worktree).context("run cargo package")?;
152
153    // Get the package metadata
154    let single_package = get_cargo_package(&worktree, &package.name).with_context(|| {
155        format!(
156            "get cargo package {} from worktree at {:?}",
157            package.name,
158            worktree.path()
159        )
160    })?;
161
162    let registry_package = RegistryPackage::new(single_package, Some(release_commit));
163    Ok(Some((registry_package, worktree)))
164}
165
166/// Run cargo package within a worktree
167fn run_cargo_package(worktree: &GitWorkTree) -> anyhow::Result<()> {
168    let worktree_path = to_utf8_path(worktree.path())?;
169    let output = run_cargo(worktree_path, &["package", "--allow-dirty", "--workspace"])
170        .context("run cargo package in worktree")?;
171
172    if !output.status.success() {
173        anyhow::bail!("cargo package failed: {:?}", output.stderr);
174    }
175
176    Ok(())
177}
178
179fn get_cargo_package(worktree: &GitWorkTree, package_name: &str) -> anyhow::Result<Package> {
180    let worktree_path = to_utf8_path(worktree.path())?;
181    let manifest_path = worktree_path.join("Cargo.toml");
182
183    // Use current_dir so that CARGO_TARGET_DIR resolves correctly relative to worktree
184    let rust_package = MetadataCommand::new()
185        .current_dir(worktree_path.as_std_path())
186        .no_deps()
187        .manifest_path(&manifest_path)
188        .exec()
189        .context("get cargo metadata for worktree")?;
190
191    let package_details = rust_package
192        .packages
193        .iter()
194        .find(|x| x.name == package_name)
195        .with_context(|| format!("Failed to find package {package_name:?}"))?;
196
197    let package_path = rust_package.target_directory.join(format!(
198        "package/{}-{}",
199        package_details.name, package_details.version
200    ));
201    debug!("package for {package_name} is at {package_path}");
202
203    let single_package_manifest = package_path.join("Cargo.toml");
204    let single_package_meta = get_manifest_metadata(&single_package_manifest)
205        .context("get cargo metadata for package")?;
206
207    let single_package = single_package_meta
208        .workspace_packages()
209        .into_iter()
210        .find(|p| p.name == package_name)
211        .context("Couldn't find the package")?
212        .clone();
213
214    Ok(single_package)
215}
216
217/// Determine next version of packages.
218///
219/// Returns:
220/// - Any packages that need to be updated
221/// - A temporary repository, i.e. an isolated copy of the repository used for git operations
222#[instrument(skip_all)]
223pub async fn next_versions(input: &UpdateRequest) -> anyhow::Result<(PackagesUpdate, TempRepo)> {
224    let overrides = input.packages_config().overridden_packages();
225    let local_project = Project::new(
226        input.local_manifest(),
227        input.single_package(),
228        &overrides,
229        input.cargo_metadata(),
230        input,
231    )?;
232    let updater = Updater {
233        project: &local_project,
234        req: input,
235    };
236
237    // Separate packages based on per-package git_only configuration
238    let workspace_packages = input.cargo_metadata().workspace_packages();
239    let (git_only_packages, registry_packages_list): (Vec<_>, Vec<_>) = workspace_packages
240        .iter()
241        .partition(|p| input.should_use_git_only(&p.name));
242
243    let is_multi_package = local_project.publishable_packages().len() > 1;
244
245    // Process git_only packages (version determined from git tags).
246    // Worktrees must be kept alive until we're done with the packages.
247    let (mut all_packages, _worktrees) =
248        collect_git_only_packages(git_only_packages, input, is_multi_package)?;
249
250    // Process registry packages (version determined from registry)
251    let (registry_pkgs, registry_collection) = collect_registry_packages(
252        registry_packages_list,
253        &local_project.publishable_packages(),
254        input,
255    )?;
256    all_packages.extend(registry_pkgs);
257
258    // NOTE: We reuse registry_collection here instead of instantiating a new object
259    // because otherwise the temp dir contained within it gets dropped and cleaned up.
260    let release_packages = registry_collection.with_packages(all_packages);
261
262    // Create a temporary isolated repository for git operations.
263    // This ensures that git checkouts and other operations don't affect the user's working directory.
264    let repository = local_project
265        .get_repo()
266        .context("failed to determine local project repository")?;
267
268    let repo_is_clean_result = repository.repo.is_clean();
269    if !input.allow_dirty() {
270        repo_is_clean_result?;
271    } else if repo_is_clean_result.is_err() {
272        // Stash uncommitted changes so we can freely check out other commits.
273        // This function runs inside a temporary repository, so this has no
274        // effects on the original repository of the user.
275        repository.repo.git(&[
276            "stash",
277            "push",
278            "--include-untracked",
279            "-m",
280            "uncommitted changes stashed by release-plz",
281        ])?;
282    }
283
284    let packages_to_update = updater
285        .packages_to_update(&release_packages, &repository.repo, input.local_manifest())
286        .await?;
287    Ok((packages_to_update, repository))
288}
289
290/// Process all `git_only` packages and return their metadata.
291///
292/// Returns:
293/// - A map of package name to `RegistryPackage`
294/// - A list of worktrees that must be kept alive until we're done with the packages
295fn collect_git_only_packages(
296    git_only_packages: Vec<&Package>,
297    input: &UpdateRequest,
298    is_multi_package: bool,
299) -> anyhow::Result<(BTreeMap<String, RegistryPackage>, Vec<GitWorkTree>)> {
300    if git_only_packages.is_empty() {
301        return Ok((BTreeMap::new(), Vec::new()));
302    }
303
304    debug!(
305        "Processing {} packages in git_only mode",
306        git_only_packages.len()
307    );
308
309    let mut all_packages = BTreeMap::new();
310    // NOTE: We need to prevent the worktrees from being dropped because their Drop
311    // implementation cleans up the worktrees.
312    // See the note on the custom worktree Drop impl for more details.
313    let mut worktrees = Vec::new();
314
315    let mut unreleased_project_repo = GitRepo::open(
316        input
317            .local_manifest_dir()
318            .context("get local manifest dir")?,
319    )
320    .context("create unreleased repo for spinning worktrees")?;
321
322    for package in git_only_packages {
323        if let Some((registry_package, worktree)) = process_git_only_package(
324            package,
325            &mut unreleased_project_repo,
326            input,
327            is_multi_package,
328        )? {
329            all_packages.insert(registry_package.package.name.to_string(), registry_package);
330            worktrees.push(worktree);
331        }
332    }
333
334    Ok((all_packages, worktrees))
335}
336
337/// Fetch packages from the registry and return their metadata.
338///
339/// Returns:
340/// - A map of package name to `RegistryPackage`
341/// - The `PackagesCollection` (must be kept alive because it owns the temp dir)
342fn collect_registry_packages(
343    registry_packages_list: Vec<&Package>,
344    publishable_packages: &[&Package],
345    input: &UpdateRequest,
346) -> anyhow::Result<(BTreeMap<String, RegistryPackage>, PackagesCollection)> {
347    if registry_packages_list.is_empty() {
348        return Ok((BTreeMap::new(), PackagesCollection::default()));
349    }
350
351    debug!(
352        "Processing {} packages from registry",
353        registry_packages_list.len()
354    );
355
356    // Filter to only publishable packages
357    let publishable_registry_packages: Vec<&Package> = registry_packages_list
358        .into_iter()
359        .filter(|p| {
360            publishable_packages
361                .iter()
362                .any(|pub_pkg| pub_pkg.name == p.name)
363        })
364        .collect();
365
366    if publishable_registry_packages.is_empty() {
367        return Ok((BTreeMap::new(), PackagesCollection::default()));
368    }
369
370    // Retrieve the latest published version of the packages.
371    // Release-plz will compare the registry packages with the local packages
372    // to determine the new commits.
373    let registry_packages = registry_packages::get_registry_packages(
374        input.registry_manifest(),
375        &publishable_registry_packages,
376        input.registry(),
377    )?;
378
379    let mut all_packages = BTreeMap::new();
380    for package_name in publishable_registry_packages.iter().map(|p| &p.name) {
381        if let Some(reg_pkg) = registry_packages.get_registry_package(package_name) {
382            all_packages.insert(
383                package_name.to_string(),
384                RegistryPackage::new(
385                    reg_pkg.package.clone(),
386                    reg_pkg.published_at_sha1().map(|s| s.to_string()),
387                ),
388            );
389        }
390    }
391
392    Ok((all_packages, registry_packages))
393}
394
395pub fn root_repo_path(local_manifest: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
396    let manifest_dir = manifest_dir(local_manifest)?;
397    root_repo_path_from_manifest_dir(manifest_dir)
398}
399
400pub fn root_repo_path_from_manifest_dir(manifest_dir: &Utf8Path) -> anyhow::Result<Utf8PathBuf> {
401    let root = git_cmd::git_in_dir(manifest_dir, &["rev-parse", "--show-toplevel"])?;
402    Ok(Utf8PathBuf::from(root))
403}
404
405pub fn new_manifest_dir_path(
406    old_project_root: &Utf8Path,
407    old_manifest_dir: &Utf8Path,
408    new_project_root: &Utf8Path,
409) -> anyhow::Result<Utf8PathBuf> {
410    let parent_root = old_project_root.parent().unwrap_or(old_project_root);
411    let relative_manifest_dir = strip_prefix(old_manifest_dir, parent_root)
412        .context("cannot strip prefix for manifest dir")?;
413    Ok(new_project_root.join(relative_manifest_dir))
414}
415
416#[derive(Debug, Clone)]
417pub struct UpdateResult {
418    /// Next version of the package.
419    pub version: Version,
420    /// New changelog.
421    pub changelog: Option<String>,
422    pub semver_check: SemverCheck,
423    pub new_changelog_entry: Option<String>,
424    /// The last released/published version from the registry.
425    /// This is set when the local version was already bumped (higher than registry version).
426    /// Used to generate correct version transitions in PR body (e.g., "0.1.0 -> 0.2.0")
427    /// instead of just showing "0.2.0" when `previous_version == next_version`.
428    pub registry_version: Option<Version>,
429}
430
431impl UpdateResult {
432    pub fn last_changes(&self) -> anyhow::Result<Option<ChangelogRelease>> {
433        match &self.changelog {
434            Some(c) => changelog_parser::last_release_from_str(c),
435            None => Ok(None),
436        }
437    }
438}
439
440pub fn workspace_packages(metadata: &Metadata) -> anyhow::Result<Vec<Package>> {
441    cargo_utils::workspace_members(metadata).map(|members| members.collect())
442}
443
444pub fn publishable_packages_from_manifest(
445    manifest: impl AsRef<Utf8Path>,
446) -> anyhow::Result<Vec<Package>> {
447    let metadata = cargo_utils::get_manifest_metadata(manifest.as_ref())?;
448    cargo_utils::workspace_members(&metadata)
449        .map(|members| members.filter(|p| p.is_publishable()).collect())
450}
451
452pub trait Publishable {
453    fn is_publishable(&self) -> bool;
454}
455
456impl Publishable for Package {
457    /// Return true if the package can be published to at least one register (e.g. crates.io).
458    fn is_publishable(&self) -> bool {
459        let res = if let Some(publish) = &self.publish {
460            // `publish.is_empty()` is:
461            // - true: when `publish` in Cargo.toml is `[]` or `false`.
462            // - false: when the package can be published only to certain registries.
463            //          E.g. when `publish` in Cargo.toml is `["my-reg"]` or `true`.
464            !publish.is_empty()
465        } else {
466            // If it's not an example, the package can be published anywhere
467            !is_example_package(self)
468        };
469        trace!("package {} is publishable: {res}", self.name);
470        res
471    }
472}
473
474fn is_example_package(package: &Package) -> bool {
475    package
476        .targets
477        .iter()
478        .all(|t| t.kind == [TargetKind::Example])
479}
480
481pub fn copy_to_temp_dir(target: &Utf8Path) -> anyhow::Result<Utf8TempDir> {
482    let tmp_dir = Utf8TempDir::new().context("cannot create temporary directory")?;
483    copy_dir(target, tmp_dir.path())
484        .with_context(|| format!("cannot copy directory {target:?} to {tmp_dir:?}"))?;
485    Ok(tmp_dir)
486}
487
488/// Check if `dependency` (contained in the Cargo.toml at `dependency_package_dir`) refers
489/// to the package at `package_dir`.
490/// I.e. if the absolute path of the dependency is the same as the absolute path of the package.
491pub(crate) fn is_dependency_referred_to_package(
492    dependency: &dyn TableLike,
493    package_dir: &Utf8Path,
494    dependency_package_dir: &Utf8Path,
495) -> bool {
496    canonicalized_path(dependency, package_dir)
497        .is_some_and(|dep_path| dep_path == dependency_package_dir)
498}
499
500/// Dependencies are expressed as relative paths in the Cargo.toml file.
501/// This function returns the absolute path of the dependency.
502///
503/// ## Args
504///
505/// - `package_dir`: directory containing the Cargo.toml where the dependency is listed
506/// - `dependency`: entry of the Cargo.toml
507fn canonicalized_path(dependency: &dyn TableLike, package_dir: &Utf8Path) -> Option<PathBuf> {
508    dependency
509        .get("path")
510        .and_then(|i| i.as_str())
511        .and_then(|relpath| dunce::canonicalize(package_dir.join(relpath)).ok())
512}