diff --git a/.changeset/pacquet-npm-style-version.md b/.changeset/pacquet-npm-style-version.md new file mode 100644 index 0000000000..c2f3e38fac --- /dev/null +++ b/.changeset/pacquet-npm-style-version.md @@ -0,0 +1,5 @@ +--- +"pacquet": minor +--- + +`pnpm version` now supports the npm-style bump forms: `pnpm version ` and `pnpm 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. diff --git a/pnpm/crates/cli/src/cli_args/dispatch_query.rs b/pnpm/crates/cli/src/cli_args/dispatch_query.rs index f065426f12..49856d6750 100644 --- a/pnpm/crates/cli/src/cli_args/dispatch_query.rs +++ b/pnpm/crates/cli/src/cli_args/dispatch_query.rs @@ -253,8 +253,18 @@ pub(super) fn version<'a>( args: VersionArgs, ) -> miette::Result> { 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::(cfg, dir, recursive).await + } + ReporterType::Ndjson => args.run::(cfg, dir, recursive).await, + ReporterType::Silent => args.run::(cfg, dir, recursive).await, + } + })) } pub(super) fn deprecate<'a>( diff --git a/pnpm/crates/cli/src/cli_args/run.rs b/pnpm/crates/cli/src/cli_args/run.rs index c146b74f2f..a5726d284d 100644 --- a/pnpm/crates/cli/src/cli_args/run.rs +++ b/pnpm/crates/cli/src/cli_args/run.rs @@ -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 { diff --git a/pnpm/crates/cli/src/cli_args/version.rs b/pnpm/crates/cli/src/cli_args/version.rs index 55541f971b..41c603d531 100644 --- a/pnpm/crates/cli/src/cli_args/version.rs +++ b/pnpm/crates/cli/src/cli_args/version.rs @@ -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 ` 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, /// 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, + + /// 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, + + /// 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( + 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::(config, dir, recursive), } } + /// Apply an npm-style bump — `pnpm version ` — 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( + &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::(&git_cwd) + && !is_working_tree_clean::(&git_cwd) + { + return Err(VersionError::UncleanWorkingTree.into()); + } + + let mut changes: Vec = 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::(pkg_dir, &bump, config, dir)? + { + changes.push(change); + } + } + } else if let Some(change) = + self.bump_package_version::(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::(&git_cwd) { + self.commit_and_tag(&changes[0], &git_cwd)?; + } + + for change in &changes { + run_version_lifecycle_hook::("postversion", change, config, dir)?; + } + + if self.json { + let entries: Vec = 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( + &self, + pkg_dir: &Path, + bump: &Bump, + config: &Config, + init_cwd: &Path, + ) -> miette::Result> { + 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::("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::("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::>() + .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( + 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 = std::env::vars().collect(); + run_lifecycle_hook::(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 { + 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 { + 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.`, otherwise +/// restart at `preid.0`. +fn increment_prerelease(pre_release: &mut Vec, 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::() { + 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 = ::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; diff --git a/pnpm/crates/cli/src/cli_args/version/tests.rs b/pnpm/crates/cli/src/cli_args/version/tests.rs new file mode 100644 index 0000000000..ce70af338b --- /dev/null +++ b/pnpm/crates/cli/src/cli_args/version/tests.rs @@ -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"); +} diff --git a/pnpm/crates/cli/tests/version.rs b/pnpm/crates/cli/tests/version.rs index f934fca9e5..c7c7f148f2 100644 --- a/pnpm/crates/cli/tests/version.rs +++ b/pnpm/crates/cli/tests/version.rs @@ -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 ` — 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); +}