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:
A authored and GitHub committed 2026-07-21 21:55:12 +02:00
1 parent 2e2c322adf
commit fd33859bcd
6 files changed
+1137 -15

No files matched your search

+5
View File
@@ -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.
+11 -1
View File
@@ -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>(
+1 -1
View File
@@ -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 {
+505 -12
View File
@@ -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(&current) 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(
&current_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");
}
+549 -1
View File
@@ -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);
}