feat(pacquet): implement the npm-style pnpm version bump (#13200)
Replace the ERR_PNPM_NOT_IMPLEMENTED stub with the npm-style form: `pnpm version <major|minor|patch|premajor|preminor|prepatch|prerelease>` and `pnpm version <exact-version>` (a leading `v` is accepted and stripped, like semver.valid), also recursively with `-r`/`--filter`. The bare `pnpm version -r` change-intents release flow is untouched. The bump follows node-semver's inc() with its default identifier base of 0 — bumping major/minor/patch from a matching prerelease finalizes it, the pre* types start a `.0` prerelease honoring `--preid`, and `prerelease` increments the right-most numeric identifier. Options mirror the TypeScript CLI: `--allow-same-version`, `--message` with `%s` substitution, `--no-git-tag-version`, `--no-commit-hooks`, `--sign-git-tag`, `--tag-version-prefix` (default "v"), and `--json`. The single-package form records the bump as a commit plus an annotated (or GPG-signed) tag through the same RunCommand capability the publish git checks use; recursive runs skip the commit and tag since there is no single version to tag with. The preversion/version/postversion scripts run through pacquet-executor's run_lifecycle_hook — the same port of the upstream exec.lifecycle package the TypeScript handler calls — re-reading the manifest between stages so each hook sees the right version. Error codes match pnpm: ERR_PNPM_INVALID_VERSION_BUMP, ERR_PNPM_INVALID_VERSION, ERR_PNPM_VERSION_NOT_CHANGED, ERR_PNPM_NO_PACKAGES_TO_VERSION, ERR_PNPM_UNCLEAN_WORKING_TREE. The TypeScript CLI already ships this form, so only the pacquet side changes; no TypeScript change is needed. Covers the remaining `version` gap under the Stage 3 command surface in pnpm/pnpm#11633. Tests: a unit table driving inc() through the node-semver cases, plus integration tests against the built binary porting the upstream scenarios from releasing/commands/test/version/index.test.ts and pnpm/test/version.ts — bumps, preid, JSON output, explicit versions, same-version handling, lifecycle-hook ordering, the git commit/tag matrix, and recursive selection with and without --filter.
This commit is contained in:
6 files changed
+1137
-15
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"pacquet": minor
|
||||
---
|
||||
|
||||
`pnpm version` now supports the npm-style bump forms: `pnpm version <major|minor|patch|premajor|preminor|prepatch|prerelease>` and `pnpm version <exact-version>` (also recursively with `-r`), with `--preid`, `--allow-same-version`, `--message`, `--no-git-tag-version`, `--no-commit-hooks`, `--sign-git-tag`, `--tag-version-prefix`, and `--json`. The bump runs the `preversion`/`version`/`postversion` lifecycle scripts and records the new version as a git commit and tag.
|
||||
@@ -253,8 +253,18 @@ pub(super) fn version<'a>(
|
||||
args: VersionArgs,
|
||||
) -> miette::Result<CommandFuture<'a>> {
|
||||
let cfg: &Config = (ctx.config)()?;
|
||||
let dir = ctx.dir;
|
||||
let recursive = ctx.recursive;
|
||||
Ok(Box::pin(async move { args.run(cfg, recursive).await }))
|
||||
let reporter = ctx.reporter;
|
||||
Ok(Box::pin(async move {
|
||||
match reporter {
|
||||
ReporterType::Default | ReporterType::AppendOnly => {
|
||||
args.run::<DefaultReporter>(cfg, dir, recursive).await
|
||||
}
|
||||
ReporterType::Ndjson => args.run::<NdjsonReporter>(cfg, dir, recursive).await,
|
||||
ReporterType::Silent => args.run::<SilentReporter>(cfg, dir, recursive).await,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn deprecate<'a>(
|
||||
|
||||
@@ -412,7 +412,7 @@ pub(super) fn run_stage(
|
||||
Ok(Some(status))
|
||||
}
|
||||
|
||||
fn exec_scripts_prepend_node_path(
|
||||
pub(crate) fn exec_scripts_prepend_node_path(
|
||||
value: pacquet_config::ScriptsPrependNodePath,
|
||||
) -> ScriptsPrependNodePath {
|
||||
match value {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
use clap::Args;
|
||||
use derive_more::{Display, Error};
|
||||
use miette::Diagnostic;
|
||||
use miette::{Context, Diagnostic};
|
||||
use node_semver::{Identifier, Version};
|
||||
use pacquet_config::Config;
|
||||
use pacquet_publish::{Host, is_git_repo, is_working_tree_clean};
|
||||
use pacquet_executor::{RunPostinstallHooks, run_lifecycle_hook};
|
||||
use pacquet_package_manifest::PackageManifest;
|
||||
use pacquet_publish::{Host, RunCommand, is_git_repo, is_working_tree_clean};
|
||||
use pacquet_versioning::{
|
||||
AssembleReleasePlanOptions, apply_release_plan, assemble_release_plan, read_change_intents,
|
||||
read_ledger,
|
||||
};
|
||||
use std::{collections::HashSet, path::Path};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::cli_args::{
|
||||
change::{render_release_plan, to_engine_projects},
|
||||
@@ -15,12 +22,15 @@ use crate::cli_args::{
|
||||
recursive::{AutoExcludeRoot, discover_workspace_projects, select_recursive_projects},
|
||||
};
|
||||
|
||||
/// Bump package versions by applying the pending change intents. Run with
|
||||
/// `-r` and no version argument.
|
||||
/// Bump the version of a package: `pnpm version <bump|semver>` applies an
|
||||
/// npm-style bump to the current package (or, with `-r`, to every selected
|
||||
/// workspace package), while the bare `pnpm version -r` applies the pending
|
||||
/// change intents.
|
||||
#[derive(Debug, Args)]
|
||||
pub struct VersionArgs {
|
||||
/// A version to bump to. Passing one is not supported yet; run with
|
||||
/// `-r` and no argument to apply the pending change intents.
|
||||
/// A valid semver version (e.g. 1.2.3) or one of: major, minor, patch,
|
||||
/// premajor, preminor, prepatch, prerelease. Omit it and pass `-r` to
|
||||
/// apply the pending change intents instead.
|
||||
pub params: Vec<String>,
|
||||
|
||||
/// Print the release plan the pending change intents produce without
|
||||
@@ -31,6 +41,40 @@ pub struct VersionArgs {
|
||||
/// Don't check if the working tree is clean.
|
||||
#[clap(long = "no-git-checks")]
|
||||
pub no_git_checks: bool,
|
||||
|
||||
/// Sets the prerelease identifier (e.g. alpha, beta, rc).
|
||||
#[clap(long)]
|
||||
pub preid: Option<String>,
|
||||
|
||||
/// Allow bumping to the same version.
|
||||
#[clap(long = "allow-same-version")]
|
||||
pub allow_same_version: bool,
|
||||
|
||||
/// Commit message. "%s" is replaced with the new version. Default is "%s".
|
||||
#[clap(long)]
|
||||
pub message: Option<String>,
|
||||
|
||||
/// Don't create a commit or tag for the version bump. Git commits and
|
||||
/// tags are always skipped in recursive mode.
|
||||
#[clap(long = "no-git-tag-version")]
|
||||
pub no_git_tag_version: bool,
|
||||
|
||||
/// Skip running git commit hooks when committing the version bump.
|
||||
#[clap(long = "no-commit-hooks")]
|
||||
pub no_commit_hooks: bool,
|
||||
|
||||
/// Sign the generated git tag with GPG.
|
||||
#[clap(long = "sign-git-tag")]
|
||||
pub sign_git_tag: bool,
|
||||
|
||||
/// Sets the tag prefix. Default is "v". Set to empty string to remove
|
||||
/// the prefix.
|
||||
#[clap(long = "tag-version-prefix", default_value = "v")]
|
||||
pub tag_version_prefix: String,
|
||||
|
||||
/// Show information in JSON format.
|
||||
#[clap(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
/// Errors of `pnpm version`. Codes and messages match the TypeScript CLI.
|
||||
@@ -43,10 +87,30 @@ enum VersionError {
|
||||
MissingBump,
|
||||
|
||||
#[display(
|
||||
"The npm-style \"pnpm version {bump}\" form is not implemented in the Rust CLI yet. The bare \"pnpm version -r\" form that consumes change intents is available."
|
||||
"Invalid version argument: {raw}. Must be a valid semver version (e.g. 1.2.3) or one of: major, minor, patch, premajor, preminor, prepatch, prerelease"
|
||||
)]
|
||||
#[diagnostic(code(ERR_PNPM_NOT_IMPLEMENTED))]
|
||||
NpmStyleNotPorted { bump: String },
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_VERSION_BUMP))]
|
||||
InvalidBump { raw: String },
|
||||
|
||||
#[display("Invalid version in {dir}: {version}")]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_VERSION))]
|
||||
InvalidVersion { dir: String, version: String },
|
||||
|
||||
#[display("Version was not changed: {version}")]
|
||||
#[diagnostic(code(ERR_PNPM_VERSION_NOT_CHANGED))]
|
||||
VersionNotChanged { version: String },
|
||||
|
||||
#[display("No packages to version")]
|
||||
#[diagnostic(code(ERR_PNPM_NO_PACKAGES_TO_VERSION))]
|
||||
NoPackagesToVersion,
|
||||
|
||||
#[display("Cannot stage manifest outside of git cwd: {path}")]
|
||||
#[diagnostic(code(ERR_PNPM_INVALID_MANIFEST_PATH))]
|
||||
InvalidManifestPath { path: String },
|
||||
|
||||
#[display("git {args} failed: {stderr}")]
|
||||
#[diagnostic(code(ERR_PNPM_GIT_COMMAND_FAILED))]
|
||||
GitCommandFailed { args: String, stderr: String },
|
||||
|
||||
#[display(
|
||||
r#"The bare "pnpm version -r" form consumes change intents and is only supported in a workspace"#
|
||||
@@ -60,14 +124,215 @@ enum VersionError {
|
||||
}
|
||||
|
||||
impl VersionArgs {
|
||||
pub async fn run(self, config: &Config, recursive: bool) -> miette::Result<()> {
|
||||
pub async fn run<Reporter: pacquet_reporter::Reporter>(
|
||||
self,
|
||||
config: &Config,
|
||||
dir: &Path,
|
||||
recursive: bool,
|
||||
) -> miette::Result<()> {
|
||||
match self.params.first().map(String::as_str) {
|
||||
None if recursive => self.release_from_intents(config).await,
|
||||
None => Err(VersionError::MissingBump.into()),
|
||||
Some(bump) => Err(VersionError::NpmStyleNotPorted { bump: bump.to_string() }.into()),
|
||||
Some(_) => self.npm_style_bump::<Reporter>(config, dir, recursive),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an npm-style bump — `pnpm version <major|minor|…|x.y.z>` — to
|
||||
/// the package at `dir`, or to every selected workspace package when
|
||||
/// `recursive`. Mirrors the TypeScript handler: git-tree check, per-
|
||||
/// package bump with `preversion`/`version` hooks, a commit and tag for
|
||||
/// the single-package form, then `postversion` hooks and the report.
|
||||
fn npm_style_bump<Reporter: pacquet_reporter::Reporter>(
|
||||
&self,
|
||||
config: &Config,
|
||||
dir: &Path,
|
||||
recursive: bool,
|
||||
) -> miette::Result<()> {
|
||||
let raw = self.params[0].as_str();
|
||||
let bump = parse_bump(raw)?;
|
||||
|
||||
let git_cwd = config.workspace_dir.clone().unwrap_or_else(|| dir.to_path_buf());
|
||||
if config.git_checks
|
||||
&& !self.no_git_checks
|
||||
&& is_git_repo::<Host>(&git_cwd)
|
||||
&& !is_working_tree_clean::<Host>(&git_cwd)
|
||||
{
|
||||
return Err(VersionError::UncleanWorkingTree.into());
|
||||
}
|
||||
|
||||
let mut changes: Vec<VersionChange> = Vec::new();
|
||||
if recursive {
|
||||
let base = config.workspace_dir.clone().unwrap_or_else(|| dir.to_path_buf());
|
||||
let (projects, _) = discover_workspace_projects(&base)?;
|
||||
let selection =
|
||||
select_recursive_projects(&projects, config, &base, AutoExcludeRoot::Disabled)?;
|
||||
for pkg_dir in selection.selected.keys() {
|
||||
if let Some(change) =
|
||||
self.bump_package_version::<Reporter>(pkg_dir, &bump, config, dir)?
|
||||
{
|
||||
changes.push(change);
|
||||
}
|
||||
}
|
||||
} else if let Some(change) =
|
||||
self.bump_package_version::<Reporter>(dir, &bump, config, dir)?
|
||||
{
|
||||
changes.push(change);
|
||||
}
|
||||
|
||||
if changes.is_empty() {
|
||||
return Err(VersionError::NoPackagesToVersion.into());
|
||||
}
|
||||
|
||||
// In recursive mode, multiple packages can be bumped to different
|
||||
// versions in a single run, and there is no obvious single version to
|
||||
// tag the commit with. Skip the git commit and tag entirely then.
|
||||
if !recursive && !self.no_git_tag_version && is_git_repo::<Host>(&git_cwd) {
|
||||
self.commit_and_tag(&changes[0], &git_cwd)?;
|
||||
}
|
||||
|
||||
for change in &changes {
|
||||
run_version_lifecycle_hook::<Reporter>("postversion", change, config, dir)?;
|
||||
}
|
||||
|
||||
if self.json {
|
||||
let entries: Vec<Value> = changes
|
||||
.iter()
|
||||
.map(|change| {
|
||||
json!({
|
||||
"name": change.name,
|
||||
"currentVersion": change.current_version,
|
||||
"newVersion": change.new_version,
|
||||
"path": change.path,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&entries).expect("serialize changes"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut output = String::from("Version bumped successfully:\n");
|
||||
for change in &changes {
|
||||
writeln!(
|
||||
output,
|
||||
"{}: {} → {}",
|
||||
change.name, change.current_version, change.new_version,
|
||||
)
|
||||
.expect("write to string");
|
||||
}
|
||||
print!("{output}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump one package's manifest, running its `preversion` and `version`
|
||||
/// lifecycle hooks around the write. Returns `None` — bumping nothing —
|
||||
/// when the manifest has no name or no version.
|
||||
fn bump_package_version<Reporter: pacquet_reporter::Reporter>(
|
||||
&self,
|
||||
pkg_dir: &Path,
|
||||
bump: &Bump,
|
||||
config: &Config,
|
||||
init_cwd: &Path,
|
||||
) -> miette::Result<Option<VersionChange>> {
|
||||
let manifest_path = pkg_dir.join("package.json");
|
||||
let mut manifest = PackageManifest::from_path(manifest_path.clone())
|
||||
.wrap_err_with(|| format!("reading {}", manifest_path.display()))?;
|
||||
|
||||
let name = manifest.value().get("name").and_then(Value::as_str).unwrap_or_default();
|
||||
let current = manifest.value().get("version").and_then(Value::as_str).unwrap_or_default();
|
||||
if name.is_empty() || current.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let (name, current) = (name.to_string(), current.to_string());
|
||||
|
||||
let Ok(current_version) = Version::parse(¤t) else {
|
||||
return Err(VersionError::InvalidVersion {
|
||||
dir: pkg_dir.display().to_string(),
|
||||
version: current,
|
||||
}
|
||||
.into());
|
||||
};
|
||||
|
||||
let pre_change = VersionChange {
|
||||
name: name.clone(),
|
||||
current_version: current.clone(),
|
||||
new_version: current.clone(),
|
||||
path: pkg_dir.to_path_buf(),
|
||||
manifest_path: manifest_path.clone(),
|
||||
};
|
||||
run_version_lifecycle_hook::<Reporter>("preversion", &pre_change, config, init_cwd)?;
|
||||
|
||||
let new_version = match bump {
|
||||
Bump::Explicit(version) => version.clone(),
|
||||
// An empty --preid means "no preid", as in the TypeScript CLI,
|
||||
// where the empty string is falsy to semver's inc().
|
||||
Bump::Release(release) => inc(
|
||||
¤t_version,
|
||||
*release,
|
||||
self.preid.as_deref().filter(|preid| !preid.is_empty()),
|
||||
),
|
||||
}
|
||||
.to_string();
|
||||
|
||||
if new_version == current && !self.allow_same_version {
|
||||
return Err(VersionError::VersionNotChanged { version: current }.into());
|
||||
}
|
||||
|
||||
manifest
|
||||
.value_mut()
|
||||
.as_object_mut()
|
||||
.expect("package.json is an object — its version field was just read")
|
||||
.insert("version".to_string(), Value::String(new_version.clone()));
|
||||
manifest.save().wrap_err_with(|| format!("saving {}", manifest_path.display()))?;
|
||||
|
||||
let change = VersionChange {
|
||||
name,
|
||||
current_version: current,
|
||||
new_version,
|
||||
path: pkg_dir.to_path_buf(),
|
||||
manifest_path,
|
||||
};
|
||||
run_version_lifecycle_hook::<Reporter>("version", &change, config, init_cwd)?;
|
||||
Ok(Some(change))
|
||||
}
|
||||
|
||||
/// Stage the bumped manifest and record the bump as a commit plus an
|
||||
/// annotated (or signed) tag, mirroring the TypeScript `commitAndTag`.
|
||||
fn commit_and_tag(&self, change: &VersionChange, cwd: &Path) -> miette::Result<()> {
|
||||
let message = self.message.as_deref().unwrap_or("%s").replace("%s", &change.new_version);
|
||||
let tag_name = format!("{}{}", self.tag_version_prefix, change.new_version);
|
||||
|
||||
let Ok(relative) = change.manifest_path.strip_prefix(cwd) else {
|
||||
return Err(VersionError::InvalidManifestPath {
|
||||
path: change.manifest_path.display().to_string(),
|
||||
}
|
||||
.into());
|
||||
};
|
||||
let manifest_rel: String = relative
|
||||
.components()
|
||||
.map(|component| component.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
run_git(cwd, &["add", &manifest_rel])?;
|
||||
|
||||
let mut commit_args = vec!["commit", "-m", &message];
|
||||
if self.no_commit_hooks {
|
||||
commit_args.push("--no-verify");
|
||||
}
|
||||
// The manifest write can leave nothing staged on an
|
||||
// --allow-same-version run. Pass --allow-empty in that case to let
|
||||
// the tag point at the current HEAD as a deliberate marker.
|
||||
if self.allow_same_version {
|
||||
commit_args.push("--allow-empty");
|
||||
}
|
||||
run_git(cwd, &commit_args)?;
|
||||
|
||||
let mut tag_args = vec!["tag", if self.sign_git_tag { "-s" } else { "-a" }];
|
||||
tag_args.extend([tag_name.as_str(), "-m", &message]);
|
||||
run_git(cwd, &tag_args)
|
||||
}
|
||||
|
||||
async fn release_from_intents(&self, config: &Config) -> miette::Result<()> {
|
||||
let Some(workspace_dir) = config.workspace_dir.clone() else {
|
||||
return Err(VersionError::ReleaseOutsideWorkspace.into());
|
||||
@@ -162,6 +427,231 @@ impl VersionArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one `preversion` / `version` / `postversion` script of the bumped
|
||||
/// package, when the manifest declares it and scripts are not ignored.
|
||||
/// The manifest is re-read so the `version` and `postversion` hooks see
|
||||
/// the bumped version.
|
||||
fn run_version_lifecycle_hook<Reporter: pacquet_reporter::Reporter>(
|
||||
stage: &str,
|
||||
change: &VersionChange,
|
||||
config: &Config,
|
||||
init_cwd: &Path,
|
||||
) -> miette::Result<()> {
|
||||
if config.ignore_scripts {
|
||||
return Ok(());
|
||||
}
|
||||
let manifest = PackageManifest::from_path(change.manifest_path.clone())
|
||||
.wrap_err_with(|| format!("reading {}", change.manifest_path.display()))?;
|
||||
let Some(script) = manifest
|
||||
.value()
|
||||
.get("scripts")
|
||||
.and_then(|scripts| scripts.get(stage))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|script| !script.is_empty())
|
||||
.map(ToString::to_string)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let root_modules_dir = change.path.join(&config.modules_dir);
|
||||
let script_shell = config.script_shell.as_ref().map(PathBuf::from);
|
||||
let run_opts = RunPostinstallHooks {
|
||||
dep_path: &change.name,
|
||||
pkg_root: &change.path,
|
||||
root_modules_dir: &root_modules_dir,
|
||||
init_cwd,
|
||||
extra_bin_paths: &config.extra_bin_paths,
|
||||
extra_env: &config.extra_env,
|
||||
node_execpath: None,
|
||||
npm_execpath: None,
|
||||
node_gyp_path: None,
|
||||
user_agent: Some(&config.user_agent),
|
||||
unsafe_perm: config.unsafe_perm,
|
||||
node_gyp_bin: None,
|
||||
scripts_prepend_node_path: super::run::exec_scripts_prepend_node_path(
|
||||
config.scripts_prepend_node_path,
|
||||
),
|
||||
script_shell: script_shell.as_deref(),
|
||||
optional: false,
|
||||
};
|
||||
let parent_env: HashMap<String, String> = std::env::vars().collect();
|
||||
run_lifecycle_hook::<Reporter>(stage, &script, &run_opts, manifest.value(), &parent_env)
|
||||
.map_err(miette::Report::new)
|
||||
}
|
||||
|
||||
/// One package's version bump: what it was, what it became, and where its
|
||||
/// manifest lives.
|
||||
#[derive(Debug)]
|
||||
struct VersionChange {
|
||||
name: String,
|
||||
current_version: String,
|
||||
new_version: String,
|
||||
path: PathBuf,
|
||||
manifest_path: PathBuf,
|
||||
}
|
||||
|
||||
/// A parsed version argument: an exact version to set, or a release type to
|
||||
/// increment by.
|
||||
#[derive(Debug)]
|
||||
enum Bump {
|
||||
Explicit(Version),
|
||||
Release(ReleaseType),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ReleaseType {
|
||||
Major,
|
||||
Minor,
|
||||
Patch,
|
||||
Premajor,
|
||||
Preminor,
|
||||
Prepatch,
|
||||
Prerelease,
|
||||
}
|
||||
|
||||
/// Parse the version argument: a valid semver version wins (like upstream's
|
||||
/// `semver.valid`, so a leading `v` is accepted and stripped), then the
|
||||
/// release-type keywords.
|
||||
fn parse_bump(raw: &str) -> Result<Bump, VersionError> {
|
||||
if let Ok(version) = Version::parse(raw) {
|
||||
return Ok(Bump::Explicit(version));
|
||||
}
|
||||
let release = match raw {
|
||||
"major" => ReleaseType::Major,
|
||||
"minor" => ReleaseType::Minor,
|
||||
"patch" => ReleaseType::Patch,
|
||||
"premajor" => ReleaseType::Premajor,
|
||||
"preminor" => ReleaseType::Preminor,
|
||||
"prepatch" => ReleaseType::Prepatch,
|
||||
"prerelease" => ReleaseType::Prerelease,
|
||||
_ => return Err(VersionError::InvalidBump { raw: raw.to_string() }),
|
||||
};
|
||||
Ok(Bump::Release(release))
|
||||
}
|
||||
|
||||
/// Increment `version` by `release`, following node-semver's `inc()` with its
|
||||
/// default identifier base of `0`: bumping from a prerelease of the next
|
||||
/// major/minor/patch merely finalizes it, the `pre*` types start a `.0`
|
||||
/// prerelease (prefixed with `preid` when given), and `prerelease` increments
|
||||
/// the right-most numeric identifier.
|
||||
fn inc(version: &Version, release: ReleaseType, preid: Option<&str>) -> Version {
|
||||
let mut next = version.clone();
|
||||
next.build = Vec::new();
|
||||
match release {
|
||||
ReleaseType::Major => {
|
||||
if next.pre_release.is_empty() || next.minor != 0 || next.patch != 0 {
|
||||
next.major += 1;
|
||||
}
|
||||
next.minor = 0;
|
||||
next.patch = 0;
|
||||
next.pre_release = Vec::new();
|
||||
}
|
||||
ReleaseType::Minor => {
|
||||
if next.pre_release.is_empty() || next.patch != 0 {
|
||||
next.minor += 1;
|
||||
}
|
||||
next.patch = 0;
|
||||
next.pre_release = Vec::new();
|
||||
}
|
||||
ReleaseType::Patch => {
|
||||
if next.pre_release.is_empty() {
|
||||
next.patch += 1;
|
||||
}
|
||||
next.pre_release = Vec::new();
|
||||
}
|
||||
ReleaseType::Premajor => {
|
||||
next.major += 1;
|
||||
next.minor = 0;
|
||||
next.patch = 0;
|
||||
next.pre_release = initial_prerelease(preid);
|
||||
}
|
||||
ReleaseType::Preminor => {
|
||||
next.minor += 1;
|
||||
next.patch = 0;
|
||||
next.pre_release = initial_prerelease(preid);
|
||||
}
|
||||
ReleaseType::Prepatch => {
|
||||
next.patch += 1;
|
||||
next.pre_release = initial_prerelease(preid);
|
||||
}
|
||||
ReleaseType::Prerelease => {
|
||||
if next.pre_release.is_empty() {
|
||||
next.patch += 1;
|
||||
next.pre_release = initial_prerelease(preid);
|
||||
} else {
|
||||
increment_prerelease(&mut next.pre_release, preid);
|
||||
}
|
||||
}
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
/// The prerelease identifiers a fresh `pre*` bump starts with: `preid.0`, or
|
||||
/// a bare `0` without a preid.
|
||||
fn initial_prerelease(preid: Option<&str>) -> Vec<Identifier> {
|
||||
match preid {
|
||||
Some(preid) => vec![make_identifier(preid), Identifier::Numeric(0)],
|
||||
None => vec![Identifier::Numeric(0)],
|
||||
}
|
||||
}
|
||||
|
||||
/// Increment a non-empty prerelease in place: bump the right-most numeric
|
||||
/// identifier (appending `.0` when there is none), then — when a preid is
|
||||
/// given — keep the result only if it is already `preid.<number>`, otherwise
|
||||
/// restart at `preid.0`.
|
||||
fn increment_prerelease(pre_release: &mut Vec<Identifier>, preid: Option<&str>) {
|
||||
let mut bumped = false;
|
||||
for identifier in pre_release.iter_mut().rev() {
|
||||
if let Identifier::Numeric(number) = identifier {
|
||||
*number += 1;
|
||||
bumped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !bumped {
|
||||
pre_release.push(Identifier::Numeric(0));
|
||||
}
|
||||
|
||||
if let Some(preid) = preid {
|
||||
let first_matches =
|
||||
pre_release.first().is_some_and(|first| identifier_text(first) == preid);
|
||||
let second_is_numeric = matches!(pre_release.get(1), Some(Identifier::Numeric(_)));
|
||||
if !(first_matches && second_is_numeric) {
|
||||
*pre_release = vec![make_identifier(preid), Identifier::Numeric(0)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_identifier(text: &str) -> Identifier {
|
||||
match text.parse::<u64>() {
|
||||
Ok(number) => Identifier::Numeric(number),
|
||||
Err(_) => Identifier::AlphaNumeric(text.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier_text(identifier: &Identifier) -> String {
|
||||
match identifier {
|
||||
Identifier::Numeric(number) => number.to_string(),
|
||||
Identifier::AlphaNumeric(text) => text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a git command in `cwd`, failing with the command line and git's stderr
|
||||
/// when it exits non-zero.
|
||||
fn run_git(cwd: &Path, args: &[&str]) -> miette::Result<()> {
|
||||
let output = <Host as RunCommand>::run("git", args, Some(cwd)).map_err(|err| {
|
||||
VersionError::GitCommandFailed { args: args.join(" "), stderr: err.to_string() }
|
||||
})?;
|
||||
if !output.success {
|
||||
return Err(VersionError::GitCommandFailed {
|
||||
args: args.join(" "),
|
||||
stderr: output.stderr.trim().to_string(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The projects the active `--filter` selectors pick, in graph order, as
|
||||
/// `(name, workspace-relative dir)` pairs.
|
||||
pub(crate) fn selected_projects(
|
||||
@@ -187,3 +677,6 @@ pub(crate) fn selected_projects(
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,66 @@
|
||||
use node_semver::Version;
|
||||
|
||||
use super::{Bump, ReleaseType, inc, parse_bump};
|
||||
|
||||
fn version(text: &str) -> Version {
|
||||
text.parse().expect("valid version")
|
||||
}
|
||||
|
||||
/// The node-semver `inc()` table the bumps must reproduce, including the
|
||||
/// finalize-a-prerelease shortcuts and the preid handling.
|
||||
#[test]
|
||||
fn inc_matches_node_semver() {
|
||||
let cases: &[(&str, ReleaseType, Option<&str>, &str)] = &[
|
||||
("1.2.3", ReleaseType::Major, None, "2.0.0"),
|
||||
("2.0.0-alpha.1", ReleaseType::Major, None, "2.0.0"),
|
||||
("2.1.0-alpha.1", ReleaseType::Major, None, "3.0.0"),
|
||||
("1.2.3", ReleaseType::Minor, None, "1.3.0"),
|
||||
("1.3.0-beta", ReleaseType::Minor, None, "1.3.0"),
|
||||
("1.3.1-beta", ReleaseType::Minor, None, "1.4.0"),
|
||||
("1.2.3", ReleaseType::Patch, None, "1.2.4"),
|
||||
("1.2.4-rc.1", ReleaseType::Patch, None, "1.2.4"),
|
||||
("1.2.3", ReleaseType::Premajor, Some("alpha"), "2.0.0-alpha.0"),
|
||||
("1.2.3", ReleaseType::Premajor, None, "2.0.0-0"),
|
||||
("1.2.3", ReleaseType::Preminor, Some("alpha"), "1.3.0-alpha.0"),
|
||||
("1.2.3", ReleaseType::Prepatch, Some("alpha"), "1.2.4-alpha.0"),
|
||||
("1.0.0", ReleaseType::Prerelease, Some("alpha"), "1.0.1-alpha.0"),
|
||||
("1.0.1-alpha.0", ReleaseType::Prerelease, Some("alpha"), "1.0.1-alpha.1"),
|
||||
("1.0.1-alpha.1", ReleaseType::Prerelease, Some("beta"), "1.0.1-beta.0"),
|
||||
("1.0.0-beta", ReleaseType::Prerelease, Some("beta"), "1.0.0-beta.0"),
|
||||
("1.0.0-beta.fooblz", ReleaseType::Prerelease, Some("beta"), "1.0.0-beta.0"),
|
||||
("1.0.0", ReleaseType::Prerelease, None, "1.0.1-0"),
|
||||
("1.0.0-1", ReleaseType::Prerelease, None, "1.0.0-2"),
|
||||
("1.0.0+build.5", ReleaseType::Patch, None, "1.0.1"),
|
||||
];
|
||||
for (current, release, preid, expected) in cases {
|
||||
let bumped = inc(&version(current), *release, *preid).to_string();
|
||||
assert_eq!(&bumped, expected, "inc({current}, {release:?}, {preid:?})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_bump_accepts_versions_and_release_types() {
|
||||
let Ok(Bump::Explicit(explicit)) = parse_bump("1.2.3") else {
|
||||
panic!("1.2.3 should parse as an explicit version");
|
||||
};
|
||||
assert_eq!(explicit.to_string(), "1.2.3");
|
||||
|
||||
let Ok(Bump::Explicit(prerelease)) = parse_bump("2.0.0-beta.1") else {
|
||||
panic!("2.0.0-beta.1 should parse as an explicit version");
|
||||
};
|
||||
assert_eq!(prerelease.to_string(), "2.0.0-beta.1");
|
||||
|
||||
assert!(matches!(parse_bump("major"), Ok(Bump::Release(ReleaseType::Major))));
|
||||
assert!(matches!(parse_bump("prerelease"), Ok(Bump::Release(ReleaseType::Prerelease))));
|
||||
assert!(parse_bump("not-a-version").is_err(), "junk should be rejected");
|
||||
}
|
||||
|
||||
/// `semver.valid` accepts a leading `v` and returns the cleaned version, so
|
||||
/// the argument form `pnpm version v1.2.3` sets `1.2.3`.
|
||||
#[test]
|
||||
fn parse_bump_strips_a_leading_v() {
|
||||
let Ok(Bump::Explicit(explicit)) = parse_bump("v1.2.3") else {
|
||||
panic!("v1.2.3 should parse as an explicit version");
|
||||
};
|
||||
assert_eq!(explicit.to_string(), "1.2.3");
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
use command_extra::CommandExtra;
|
||||
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::{fs, path::Path, process::Command};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn version_flag_prints_the_bare_version() {
|
||||
@@ -62,3 +66,547 @@ fn test_command(mut command: Command, root: &Path) -> Command {
|
||||
command.env_remove("PNPM_CONFIG_PM_ON_FAIL");
|
||||
command
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// npm-style `pnpm version <bump|semver>` — ported from the upstream suites
|
||||
// releasing/commands/test/version/index.test.ts and pnpm/test/version.ts.
|
||||
// The spawned binary's stdout is a pipe, so reporter styling is plain text.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn pacquet_version(workspace: &Path, args: &[&str]) -> std::process::Output {
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
let mut command = Command::cargo_bin("pnpm").expect("find the pnpm binary");
|
||||
command.current_dir(workspace).arg("version").args(args);
|
||||
command.output().expect("run pacquet version")
|
||||
}
|
||||
|
||||
fn write_manifest(dir: &Path, json: &str) {
|
||||
fs::write(dir.join("package.json"), json).expect("write package.json");
|
||||
}
|
||||
|
||||
fn manifest_version(dir: &Path) -> String {
|
||||
let manifest: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(dir.join("package.json")).expect("read manifest"))
|
||||
.expect("parse manifest");
|
||||
manifest.get("version").and_then(serde_json::Value::as_str).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// `git init` plus the identity/signing config the commit and tag need.
|
||||
fn init_git(dir: &Path) {
|
||||
for args in [
|
||||
vec!["init", "-q"],
|
||||
vec!["config", "user.email", "x@y.z"],
|
||||
vec!["config", "user.name", "xyz"],
|
||||
vec!["config", "commit.gpgSign", "false"],
|
||||
vec!["config", "tag.gpgSign", "false"],
|
||||
] {
|
||||
let status = Command::new("git").args(&args).current_dir(dir).status().expect("run git");
|
||||
assert!(status.success(), "git {args:?} should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
fn git_commit_all(dir: &Path, message: &str) {
|
||||
for args in [vec!["add", "."], vec!["commit", "-q", "-m", message, "--no-gpg-sign"]] {
|
||||
let status = Command::new("git").args(&args).current_dir(dir).status().expect("run git");
|
||||
assert!(status.success(), "git {args:?} should succeed");
|
||||
}
|
||||
}
|
||||
|
||||
fn git_stdout(dir: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git").args(args).current_dir(dir).output().expect("run git");
|
||||
assert!(output.status.success(), "git {args:?} should succeed");
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn stderr_of(output: &std::process::Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_bump_type_fails() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
|
||||
for argument in ["invalid", "not-a-version"] {
|
||||
let output = pacquet_version(&workspace, &[argument]);
|
||||
assert!(!output.status.success(), "{argument} must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_INVALID_VERSION_BUMP"), "{argument}: {stderr}");
|
||||
}
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bump_without_recursive_fails() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &[]);
|
||||
|
||||
assert!(!output.status.success(), "a bare `pnpm version` must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_INVALID_VERSION_BUMP"), "{stderr}");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bumps_major_minor_and_patch() {
|
||||
for (bump, expected) in [("major", "2.0.0"), ("minor", "1.3.0"), ("patch", "1.2.4")] {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.2.3"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &[bump]);
|
||||
|
||||
assert!(output.status.success(), "{bump}: {}", stderr_of(&output));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains(&format!("1.2.3 → {expected}")), "{bump}: {stdout}");
|
||||
assert_eq!(manifest_version(&workspace), expected, "{bump}");
|
||||
drop(root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_bump_uses_the_preid() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &["prerelease", "--preid", "alpha"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("1.0.0 → 1.0.1-alpha.0"),
|
||||
"{:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
|
||||
// An empty --preid means "no preid" (it is falsy to the TypeScript CLI's
|
||||
// semver.inc), so the prerelease starts at a bare `-0`, never `-.0`.
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"2.0.0"}"#);
|
||||
let output = pacquet_version(&workspace, &["prerelease", "--preid", ""]);
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&workspace), "2.0.1-0");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_flag_reports_the_changes_as_json() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--json"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(String::from_utf8_lossy(&output.stdout).trim())
|
||||
.expect("stdout must be JSON");
|
||||
let entry = &parsed.as_array().expect("a JSON array")[0];
|
||||
assert_eq!(entry.get("name").and_then(serde_json::Value::as_str), Some("test-pkg"));
|
||||
assert_eq!(entry.get("currentVersion").and_then(serde_json::Value::as_str), Some("1.0.0"));
|
||||
assert_eq!(entry.get("newVersion").and_then(serde_json::Value::as_str), Some("1.0.1"));
|
||||
assert!(entry.get("manifestPath").is_none(), "manifestPath must not be reported");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_versions_are_set_verbatim() {
|
||||
for (argument, expected) in [("0.0.0", "0.0.0"), ("2.0.0-beta.1", "2.0.0-beta.1")] {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.2.3"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &[argument]);
|
||||
|
||||
assert!(output.status.success(), "{argument}: {}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&workspace), expected, "{argument}");
|
||||
drop(root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_version_fails_unless_allowed() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &["1.0.0"]);
|
||||
assert!(!output.status.success(), "bumping to the same version must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_VERSION_NOT_CHANGED"), "{stderr}");
|
||||
|
||||
let output = pacquet_version(&workspace, &["1.0.0", "--allow-same-version"]);
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("1.0.0 → 1.0.0"),
|
||||
"{:?}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
);
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_without_name_or_version_has_no_packages_to_version() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, "{}");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
|
||||
assert!(!output.status.success(), "an empty manifest must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_NO_PACKAGES_TO_VERSION"), "{stderr}");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_manifest_version_fails() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"not-a-version"}"#);
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
|
||||
assert!(!output.status.success(), "an invalid manifest version must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_INVALID_VERSION"), "{stderr}");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_scripts_run_in_order_around_the_bump() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let log_script = concat!(
|
||||
r#"node -e "require('fs').appendFileSync('lifecycle.log',"#,
|
||||
r#" process.env.npm_lifecycle_event + ':' + require('./package.json').version + '\n')""#,
|
||||
);
|
||||
write_manifest(
|
||||
&workspace,
|
||||
&serde_json::json!({
|
||||
"name": "test-pkg",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"preversion": log_script,
|
||||
"version": log_script,
|
||||
"postversion": log_script,
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
let log = fs::read_to_string(workspace.join("lifecycle.log")).expect("lifecycle log");
|
||||
assert_eq!(log, "preversion:1.0.0\nversion:1.0.1\npostversion:1.0.1\n");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_commit_and_tag_are_created_by_default() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "v1.0.1");
|
||||
assert_eq!(git_stdout(&workspace, &["log", "-1", "--pretty=%s"]), "1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_version_prefix_replaces_the_default_v() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--tag-version-prefix", "release-"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "release-1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_substitutes_the_new_version() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--message", "chore: release %s"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["log", "-1", "--pretty=%s"]), "chore: release 1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_git_tag_version_skips_the_commit_and_tag() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
let commits_before = git_stdout(&workspace, &["rev-list", "--count", "HEAD"]);
|
||||
|
||||
let output = pacquet_version(&workspace, &["0.0.0", "--no-git-tag-version"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "");
|
||||
assert_eq!(git_stdout(&workspace, &["rev-list", "--count", "HEAD"]), commits_before);
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_same_version_still_tags_via_an_empty_commit() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
|
||||
let output = pacquet_version(&workspace, &["1.0.0", "--allow-same-version"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "v1.0.0");
|
||||
assert_eq!(git_stdout(&workspace, &["log", "-1", "--pretty=%s"]), "1.0.0");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn no_commit_hooks_bypasses_a_failing_pre_commit_hook() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
let hook_path = workspace.join(".git").join("hooks").join("pre-commit");
|
||||
fs::write(&hook_path, "#!/bin/sh\nexit 1\n").expect("write pre-commit hook");
|
||||
fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))
|
||||
.expect("mark hook executable");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--no-commit-hooks"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "v1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclean_working_tree_fails_unless_git_checks_are_disabled() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
fs::write(workspace.join("dirty.txt"), "x").expect("dirty the tree");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
assert!(!output.status.success(), "an unclean tree must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_UNCLEAN_WORKING_TREE"), "{stderr}");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--no-git-checks", "--no-git-tag-version"]);
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&workspace), "1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
fn write_two_package_workspace(workspace: &Path) -> (PathBuf, PathBuf) {
|
||||
let pkg_a = workspace.join("packages").join("pkg-a");
|
||||
let pkg_b = workspace.join("packages").join("pkg-b");
|
||||
fs::create_dir_all(&pkg_a).expect("create pkg-a");
|
||||
fs::create_dir_all(&pkg_b).expect("create pkg-b");
|
||||
fs::write(workspace.join("pnpm-workspace.yaml"), "packages:\n - \"packages/*\"\n")
|
||||
.expect("write pnpm-workspace.yaml");
|
||||
write_manifest(workspace, r#"{"name":"my-workspace"}"#);
|
||||
write_manifest(&pkg_a, r#"{"name":"pkg-a","version":"1.0.0"}"#);
|
||||
write_manifest(&pkg_b, r#"{"name":"pkg-b","version":"2.3.0"}"#);
|
||||
(pkg_a, pkg_b)
|
||||
}
|
||||
|
||||
fn pacquet_recursive_version(workspace: &Path, args: &[&str]) -> std::process::Output {
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
let mut command = Command::cargo_bin("pnpm").expect("find the pnpm binary");
|
||||
command.current_dir(workspace).args(args);
|
||||
command.output().expect("run pacquet -r version")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_bumps_every_workspace_package() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, pkg_b) = write_two_package_workspace(&workspace);
|
||||
|
||||
let output =
|
||||
pacquet_recursive_version(&workspace, &["-r", "version", "minor", "--no-git-checks"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&pkg_a), "1.1.0");
|
||||
assert_eq!(manifest_version(&pkg_b), "2.4.0");
|
||||
// The versionless workspace root is skipped, not failed on.
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(!stdout.contains("my-workspace"), "{stdout}");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_filter_bumps_only_the_selected_package() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, pkg_b) = write_two_package_workspace(&workspace);
|
||||
|
||||
let output = pacquet_recursive_version(
|
||||
&workspace,
|
||||
&["-r", "--filter", "pkg-b", "version", "patch", "--no-git-checks"],
|
||||
);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&pkg_a), "1.0.0");
|
||||
assert_eq!(manifest_version(&pkg_b), "2.3.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_mode_skips_the_commit_and_tag() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, _) = write_two_package_workspace(&workspace);
|
||||
init_git(&workspace);
|
||||
git_commit_all(&workspace, "init");
|
||||
let commits_before = git_stdout(&workspace, &["rev-list", "--count", "HEAD"]);
|
||||
|
||||
let output = pacquet_recursive_version(&workspace, &["-r", "version", "patch"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(manifest_version(&pkg_a), "1.0.1");
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "");
|
||||
assert_eq!(git_stdout(&workspace, &["rev-list", "--count", "HEAD"]), commits_before);
|
||||
drop(root);
|
||||
}
|
||||
|
||||
/// The npm-style and change-intents forms share one command: a version
|
||||
/// argument selects the npm-style bump even inside a workspace, and without
|
||||
/// `--recursive` it touches only the current package — never the workspace
|
||||
/// members and never the pending change intents.
|
||||
#[test]
|
||||
fn npm_style_bump_in_a_workspace_without_recursive_bumps_only_the_root() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, pkg_b) = write_two_package_workspace(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"my-workspace","version":"1.0.0"}"#);
|
||||
fs::create_dir_all(workspace.join(".changeset")).expect("create .changeset");
|
||||
let intent = workspace.join(".changeset").join("calm-cats-smile.md");
|
||||
fs::write(&intent, "---\n\"pkg-a\": minor\n---\n\nA pending change intent.\n")
|
||||
.expect("write change intent");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--no-git-checks"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("my-workspace: 1.0.0 → 1.0.1"), "{stdout}");
|
||||
assert_eq!(manifest_version(&workspace), "1.0.1");
|
||||
// Workspace members are untouched without --recursive...
|
||||
assert_eq!(manifest_version(&pkg_a), "1.0.0");
|
||||
assert_eq!(manifest_version(&pkg_b), "2.3.0");
|
||||
// ...and the pending change intent is neither consumed nor deleted.
|
||||
assert!(intent.exists(), "the change intent must survive an npm-style bump");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_describes_the_bump_forms_and_flags() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
|
||||
let output = pacquet_version(&workspace, &["--help"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for needle in [
|
||||
"major",
|
||||
"minor",
|
||||
"patch",
|
||||
"prerelease",
|
||||
"--preid",
|
||||
"--allow-same-version",
|
||||
"--message",
|
||||
"--no-git-tag-version",
|
||||
"--no-commit-hooks",
|
||||
"--sign-git-tag",
|
||||
"--tag-version-prefix",
|
||||
"--json",
|
||||
] {
|
||||
assert!(stdout.contains(needle), "help must mention {needle}:\n{stdout}");
|
||||
}
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_with_an_empty_selection_bumps_nothing() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, pkg_b) = write_two_package_workspace(&workspace);
|
||||
|
||||
let output = pacquet_recursive_version(
|
||||
&workspace,
|
||||
&["-r", "--filter", "no-such-package", "version", "minor", "--no-git-checks"],
|
||||
);
|
||||
|
||||
assert!(!output.status.success(), "an empty selection must fail");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("ERR_PNPM_NO_PACKAGES_TO_VERSION"), "{stderr}");
|
||||
assert_eq!(manifest_version(&pkg_a), "1.0.0");
|
||||
assert_eq!(manifest_version(&pkg_b), "2.3.0");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_skips_members_without_a_name_or_version() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
let (pkg_a, pkg_b) = write_two_package_workspace(&workspace);
|
||||
write_manifest(&pkg_b, r#"{"private":true}"#);
|
||||
|
||||
let output =
|
||||
pacquet_recursive_version(&workspace, &["-r", "version", "patch", "--no-git-checks"]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("pkg-a"), "{stdout}");
|
||||
assert!(!stdout.contains("pkg-b"), "the versionless member must be skipped: {stdout}");
|
||||
assert_eq!(manifest_version(&pkg_a), "1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failing_git_commit_surfaces_the_git_error() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
let hook_path = workspace.join(".git").join("hooks").join("pre-commit");
|
||||
fs::write(&hook_path, "#!/bin/sh\necho refused by hook >&2\nexit 1\n")
|
||||
.expect("write pre-commit hook");
|
||||
fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))
|
||||
.expect("mark hook executable");
|
||||
|
||||
// Without --no-commit-hooks the failing hook fails the commit, and the
|
||||
// command reports the git failure instead of swallowing it.
|
||||
let output = pacquet_version(&workspace, &["patch"]);
|
||||
|
||||
assert!(!output.status.success(), "a failing git commit must fail the command");
|
||||
let stderr = stderr_of(&output);
|
||||
assert!(stderr.contains("git commit"), "{stderr}");
|
||||
assert!(stderr.contains("refused by hook"), "{stderr}");
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "", "no tag after a failed commit");
|
||||
drop(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_tag_version_prefix_removes_the_v() {
|
||||
let CommandTempCwd { root, workspace, .. } = CommandTempCwd::init();
|
||||
init_git(&workspace);
|
||||
write_manifest(&workspace, r#"{"name":"test-pkg","version":"1.0.0"}"#);
|
||||
git_commit_all(&workspace, "init");
|
||||
|
||||
let output = pacquet_version(&workspace, &["patch", "--tag-version-prefix", ""]);
|
||||
|
||||
assert!(output.status.success(), "{}", stderr_of(&output));
|
||||
assert_eq!(git_stdout(&workspace, &["tag", "--list"]), "1.0.1");
|
||||
drop(root);
|
||||
}
|
||||
Reference in new issue
Block a user