diff --git a/.changeset/add-reads-tag-setting.md b/.changeset/add-reads-tag-setting.md new file mode 100644 index 0000000000..3460a6d7df --- /dev/null +++ b/.changeset/add-reads-tag-setting.md @@ -0,0 +1,5 @@ +--- +"pacquet": patch +--- + +`pnpm add ` without a version now installs the version under the dist-tag set by the `tag` setting. An `npm:` alias or a `jsr:` dependency without a version resolves that tag too. The setting is read from `pnpm-workspace.yaml`, the global config, `PNPM_CONFIG_TAG`, and `--config.tag`, and defaults to `latest`. diff --git a/pnpm/crates/cli/src/cli_args/dlx/cache.rs b/pnpm/crates/cli/src/cli_args/dlx/cache.rs index 066a1d62fd..dccf6a7aa2 100644 --- a/pnpm/crates/cli/src/cli_args/dlx/cache.rs +++ b/pnpm/crates/cli/src/cli_args/dlx/cache.rs @@ -407,6 +407,9 @@ pub(super) fn configure_cache_install( // with `patchedDependencies` fail on a patch file missing under the // cache dir. config.patched_dependencies = None; + // pnpm's dlx resolves a version-less package to `latest` whatever the + // caller's `tag` setting says, and the cache key holds no tag. + config.tag = None; // Build a *fresh* allow-list for the throwaway install — the dlx // packages themselves plus the CLI `--allow-build` entries — rather // than inheriting the caller project's `allow_builds` / diff --git a/pnpm/crates/cli/tests/suite/add/version_specifiers.rs b/pnpm/crates/cli/tests/suite/add/version_specifiers.rs index ce7aedb9c8..d89269cbc2 100644 --- a/pnpm/crates/cli/tests/suite/add/version_specifiers.rs +++ b/pnpm/crates/cli/tests/suite/add/version_specifiers.rs @@ -3,7 +3,8 @@ use super::{ add_with_settings, assert_eq, bravo_dep_mature_up_to_1_0_1_minimum_release_age, exec_pacquet_in_temp_cwd, prod_spec, set_minimum_release_age, }; -use assert_cmd::{assert::OutputAssertExt, cargo::CommandCargoExt}; +use crate::_utils::pacquet_in; +use assert_cmd::assert::OutputAssertExt; #[test] fn save_prefix_defaults_to_caret() { @@ -382,10 +383,123 @@ fn readd_with_a_new_specifier_keeps_the_picked_version() { drop((root, npmrc_info)); // cleanup } -fn pacquet_in(workspace: &std::path::Path) -> std::process::Command { - std::process::Command::cargo_bin("pnpm") - .expect("find the pnpm binary") - .with_current_dir(workspace) +/// The `tag` setting names the dist-tag a version-less `add` resolves. +#[test] +fn add_without_a_version_resolves_the_configured_tag() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + crate::_utils::append_workspace_yaml_key(&workspace, "tag", "dev"); + + pacquet_in(&workspace) + .with_args(["add", name, "--lockfile-only"]) + .assert() + .success(); + + assert_eq!(prod_spec(&workspace, name), "^100.0.0"); + assert_eq!(locked_version(&workspace, name), "100.0.0"); + drop((root, npmrc_info)); // cleanup +} + +#[test] +fn add_without_a_version_resolves_the_tag_from_a_config_override() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + + pacquet_in(&workspace) + .with_args(["add", name, "--lockfile-only", "--config.tag=dev"]) + .assert() + .success(); + + assert_eq!(prod_spec(&workspace, name), "^100.0.0"); + assert_eq!(locked_version(&workspace, name), "100.0.0"); + drop((root, npmrc_info)); // cleanup +} + +/// An `npm:` alias with no version resolves the `tag` setting too. +#[test] +fn install_resolves_a_versionless_npm_alias_to_the_configured_tag() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + crate::_utils::append_workspace_yaml_key(&workspace, "tag", "dev"); + std::fs::write( + workspace.join("package.json"), + serde_json::json!({ "dependencies": { "alias": format!("npm:{name}") } }).to_string(), + ) + .expect("write package.json"); + + pacquet_in(&workspace) + .with_args(["install", "--lockfile-only"]) + .assert() + .success(); + + assert_eq!(locked_version(&workspace, "alias"), format!("{name}@100.0.0")); + drop((root, npmrc_info)); // cleanup +} + +/// pnpm reads `@` as ``, so the empty version resolves the tag. +#[test] +fn add_with_an_empty_version_resolves_the_configured_tag() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + crate::_utils::append_workspace_yaml_key(&workspace, "tag", "dev"); + + pacquet_in(&workspace) + .with_args(["add", &format!("{name}@"), "--lockfile-only"]) + .assert() + .success(); + + assert_eq!(prod_spec(&workspace, name), "^100.0.0"); + assert_eq!(locked_version(&workspace, name), "100.0.0"); + drop((root, npmrc_info)); // cleanup +} + +/// An empty specifier in the manifest resolves the tag, as in pnpm. +#[test] +fn install_resolves_an_empty_specifier_to_the_configured_tag() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + crate::_utils::append_workspace_yaml_key(&workspace, "tag", "dev"); + std::fs::write( + workspace.join("package.json"), + serde_json::json!({ "dependencies": { (name): "" } }).to_string(), + ) + .expect("write package.json"); + + pacquet_in(&workspace) + .with_args(["install", "--lockfile-only"]) + .assert() + .success(); + + assert_eq!(locked_version(&workspace, name), "100.0.0"); + drop((root, npmrc_info)); // cleanup +} + +#[test] +fn add_without_a_version_resolves_the_tag_from_the_environment() { + let CommandTempCwd { root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry_with_own_storage(); + let name = "@pnpm.e2e/dep-of-pkg-with-1-dep"; + npmrc_info.set_dist_tag(name, "100.0.0", "dev"); + + pacquet_in(&workspace) + .with_env("PNPM_CONFIG_TAG", "dev") + .with_args(["add", name, "--lockfile-only"]) + .assert() + .success(); + + assert_eq!(prod_spec(&workspace, name), "^100.0.0"); + assert_eq!(locked_version(&workspace, name), "100.0.0"); + drop((root, npmrc_info)); // cleanup } fn locked_version(workspace: &std::path::Path, name: &str) -> String { diff --git a/pnpm/crates/cli/tests/suite/add_jsr.rs b/pnpm/crates/cli/tests/suite/add_jsr.rs index 1551f126d1..d2b442838d 100644 --- a/pnpm/crates/cli/tests/suite/add_jsr.rs +++ b/pnpm/crates/cli/tests/suite/add_jsr.rs @@ -16,8 +16,13 @@ use tempfile::TempDir; /// `npm.jsr.io`; point it at the mocked registry, which serves the /// `@jsr/pnpm-e2e__bar` fixture up to 2.0.0. fn setup() -> (TempDir, std::path::PathBuf, AddMockedRegistry) { - let CommandTempCwd { root, workspace, npmrc_info, .. } = - CommandTempCwd::init().add_mocked_registry(); + route_jsr_to_the_mocked_registry(CommandTempCwd::init().add_mocked_registry()) +} + +fn route_jsr_to_the_mocked_registry( + cwd: CommandTempCwd, +) -> (TempDir, std::path::PathBuf, AddMockedRegistry) { + let CommandTempCwd { root, workspace, npmrc_info, .. } = cwd; let npmrc = fs::read_to_string(&npmrc_info.npmrc_path).expect("read the harness .npmrc"); let jsr_registry = npmrc_info.mock_instance.url(); fs::write(&npmrc_info.npmrc_path, format!("{npmrc}@jsr:registry={jsr_registry}\n")) @@ -81,3 +86,27 @@ fn add_keeps_the_range_operator_a_jsr_selector_asks_for() { drop((root, anchor)); } + +/// A `jsr:` selector with no version resolves the `tag` setting. +#[test] +fn add_resolves_a_versionless_jsr_selector_to_the_configured_tag() { + let (root, workspace, anchor) = route_jsr_to_the_mocked_registry( + CommandTempCwd::init().add_mocked_registry_with_own_storage(), + ); + anchor.set_dist_tag("@jsr/pnpm-e2e__bar", "1.0.0", "dev"); + _utils::append_workspace_yaml_key(&workspace, "tag", "dev"); + + pacquet_in(&workspace) + .with_args(["add", "jsr:@pnpm-e2e/bar", "--lockfile-only"]) + .assert() + .success(); + + assert_eq!( + dependency_spec(&workspace, "dependencies", "@pnpm-e2e/bar").as_deref(), + Some("jsr:^1.0.0"), + ); + let lockfile = read_lockfile(&workspace.join(Lockfile::FILE_NAME)); + let entry = root_dependency(&lockfile, "@pnpm-e2e/bar"); + assert_eq!(entry.version.to_string(), "@jsr/pnpm-e2e__bar@1.0.0"); + drop((root, anchor)); +} diff --git a/pnpm/crates/cli/tests/suite/add_types.rs b/pnpm/crates/cli/tests/suite/add_types.rs index 32fcaaefaa..e533dbca93 100644 --- a/pnpm/crates/cli/tests/suite/add_types.rs +++ b/pnpm/crates/cli/tests/suite/add_types.rs @@ -524,7 +524,7 @@ fn jsr_sources_and_direct_jsr_registry_packages_use_their_own_metadata_policy() ( "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*", abbreviated, - 2 - usize::from(direct), + 1, ), ] { requests.push( diff --git a/pnpm/crates/cli/tests/suite/dlx.rs b/pnpm/crates/cli/tests/suite/dlx.rs index 36d9774274..ce2552a3f7 100644 --- a/pnpm/crates/cli/tests/suite/dlx.rs +++ b/pnpm/crates/cli/tests/suite/dlx.rs @@ -533,3 +533,24 @@ fn dlx_recovers_ignored_builds() { drop(root); } } + +/// pnpm's dlx resolves a version-less package to `latest`, so a caller's +/// `tag` that names no version of the package must not fail the run. +#[test] +#[cfg_attr(not(unix), ignore = "dlx bin execution is only exercised on Unix")] +fn dlx_ignores_the_caller_projects_tag_setting() { + let CommandTempCwd { pacquet, root, workspace, .. } = + CommandTempCwd::init().add_mocked_registry(); + append_workspace_yaml_key(&workspace, "tag", "no-such-tag"); + + let output = pacquet + .with_args(["dlx", "@pnpm.e2e/hello-world-js-bin"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + assert!(String::from_utf8_lossy(&output).contains("Hello world!")); + drop(root); +} diff --git a/pnpm/crates/config/src/env_overlay.rs b/pnpm/crates/config/src/env_overlay.rs index b5effbbc38..8875eaf7aa 100644 --- a/pnpm/crates/config/src/env_overlay.rs +++ b/pnpm/crates/config/src/env_overlay.rs @@ -415,6 +415,7 @@ impl WorkspaceSettings { enum_field!(settings, reader, resolution_mode, "RESOLUTION_MODE", ResolutionMode); enum_field!(settings, reader, catalog_mode, "CATALOG_MODE", CatalogMode); string_field!(settings, reader, save_catalog_name, "SAVE_CATALOG_NAME"); + string_field!(settings, reader, tag, "TAG"); if let Some(save_prefix) = reader.value_allow_empty("SAVE_PREFIX") { settings.save_prefix = Some(save_prefix); } diff --git a/pnpm/crates/config/src/env_overlay/tests.rs b/pnpm/crates/config/src/env_overlay/tests.rs index 5bf8c7a083..f17882a968 100644 --- a/pnpm/crates/config/src/env_overlay/tests.rs +++ b/pnpm/crates/config/src/env_overlay/tests.rs @@ -418,3 +418,14 @@ fn publish_wait_timeout_reads_from_environment() { } assert_eq!(WorkspaceSettings::from_pnpm_config_env::().publish_wait_timeout, Some(600000)); } + +#[test] +fn tag_parses_from_env() { + struct EnvTag; + impl EnvVar for EnvTag { + fn var(name: &str) -> Option { + (name == "PNPM_CONFIG_TAG").then(|| "next".to_owned()) + } + } + assert_eq!(WorkspaceSettings::from_pnpm_config_env::().tag.as_deref(), Some("next")); +} diff --git a/pnpm/crates/config/src/settings.rs b/pnpm/crates/config/src/settings.rs index 7f1454ac45..ace7b8f8e4 100644 --- a/pnpm/crates/config/src/settings.rs +++ b/pnpm/crates/config/src/settings.rs @@ -1714,6 +1714,11 @@ pub struct Config { /// `undefined`). pub save_catalog_name: Option, + /// The dist-tag a dependency with no version resolves to, as in + /// `pnpm add foo` or an `npm:foo` alias. The `tag` setting. Unset means + /// `latest`; read it through [`Config::default_tag`]. + pub tag: Option, + /// The range operator `pnpm add` prepends to a resolved version /// when saving it: `^` (the default), `~`, or `""` for an exact /// pin. The `savePrefix` setting, overridden per-invocation by @@ -1866,6 +1871,15 @@ impl Config { self.engine_strict && !self.force } + /// [`tag`](Self::tag), or `latest` when it is unset or empty. + #[must_use] + pub fn default_tag(&self) -> &str { + self.tag + .as_deref() + .filter(|tag| !tag.is_empty()) + .unwrap_or("latest") + } + #[must_use] pub fn new() -> Self { Self::default() diff --git a/pnpm/crates/config/src/workspace_yaml/apply.rs b/pnpm/crates/config/src/workspace_yaml/apply.rs index da6cf760e7..3a052bf3a4 100644 --- a/pnpm/crates/config/src/workspace_yaml/apply.rs +++ b/pnpm/crates/config/src/workspace_yaml/apply.rs @@ -132,6 +132,7 @@ impl WorkspaceSettings { self.prefer_symlinked_executables.take(), ); overlay_some(&mut config.save_catalog_name, self.save_catalog_name.take()); + overlay_some(&mut config.tag, self.tag.take()); overlay_some(&mut config.init_author_name, self.init_author_name.take()); overlay_some(&mut config.init_author_email, self.init_author_email.take()); overlay_some(&mut config.init_author_url, self.init_author_url.take()); diff --git a/pnpm/crates/config/src/workspace_yaml/env.rs b/pnpm/crates/config/src/workspace_yaml/env.rs index 077ead66a0..d227552b6a 100644 --- a/pnpm/crates/config/src/workspace_yaml/env.rs +++ b/pnpm/crates/config/src/workspace_yaml/env.rs @@ -101,6 +101,7 @@ impl WorkspaceSettings { pub(super) fn substitute_env_scalars(&mut self) { substitute_optional_string::(&mut self.scope); + substitute_optional_string::(&mut self.tag); substitute_optional_string::(&mut self.store_dir); substitute_optional_string::(&mut self.state_dir); substitute_optional_string::(&mut self.modules_dir); diff --git a/pnpm/crates/config/src/workspace_yaml/reset.rs b/pnpm/crates/config/src/workspace_yaml/reset.rs index 1b7f11004e..b52f255b63 100644 --- a/pnpm/crates/config/src/workspace_yaml/reset.rs +++ b/pnpm/crates/config/src/workspace_yaml/reset.rs @@ -157,7 +157,7 @@ impl WorkspaceSettings { minimum_release_age_ignore_missing_time, minimum_release_age_strict, trust_lockfile, trust_policy, trust_policy_exclude, trust_policy_exclude_prune, trust_policy_ignore_after, init_author_name, init_author_email, init_author_url, - init_license, init_version, pm_on_fail, versioning, save_catalog_name, + init_license, init_version, pm_on_fail, versioning, save_catalog_name, tag, save_prefix, tag_version_prefix, pipeline_base, child_concurrency, workspace_concurrency, catalogs, allow_builds, concurrency_groups, boot_config, }); diff --git a/pnpm/crates/config/src/workspace_yaml/resolved.rs b/pnpm/crates/config/src/workspace_yaml/resolved.rs index 138e4355c5..44010322d9 100644 --- a/pnpm/crates/config/src/workspace_yaml/resolved.rs +++ b/pnpm/crates/config/src/workspace_yaml/resolved.rs @@ -185,6 +185,7 @@ impl WorkspaceSettings { pm_on_fail: config.pm_on_fail, versioning: Some(config.versioning.clone()), save_catalog_name: config.save_catalog_name.clone(), + tag: config.tag.clone(), save_prefix: config.save_prefix.clone(), tag_version_prefix: Some(config.tag_version_prefix.clone()), pipeline_base: config.pipeline_base.clone(), diff --git a/pnpm/crates/config/src/workspace_yaml/settings.rs b/pnpm/crates/config/src/workspace_yaml/settings.rs index 8bf19c79de..971540b452 100644 --- a/pnpm/crates/config/src/workspace_yaml/settings.rs +++ b/pnpm/crates/config/src/workspace_yaml/settings.rs @@ -840,6 +840,11 @@ pub struct WorkspaceSettings { /// [`Config::save_catalog_name`]: crate::Config::save_catalog_name pub save_catalog_name: Option, + /// `tag` from `pnpm-workspace.yaml`. See [`Config::tag`]. + /// + /// [`Config::tag`]: crate::Config::tag + pub tag: Option, + /// `savePrefix` from `pnpm-workspace.yaml`. See /// [`Config::save_prefix`]. /// diff --git a/pnpm/crates/config/src/workspace_yaml/tests.rs b/pnpm/crates/config/src/workspace_yaml/tests.rs index 27acfaf33b..58d816da90 100644 --- a/pnpm/crates/config/src/workspace_yaml/tests.rs +++ b/pnpm/crates/config/src/workspace_yaml/tests.rs @@ -71,6 +71,8 @@ mod workspace_settings; mod reporting; +mod tag; + mod tag_version_prefix; mod integrity; diff --git a/pnpm/crates/config/src/workspace_yaml/tests/configuration_from_resolved_reports_every.rs b/pnpm/crates/config/src/workspace_yaml/tests/configuration_from_resolved_reports_every.rs index 787ad8bec5..8f39f0b89c 100644 --- a/pnpm/crates/config/src/workspace_yaml/tests/configuration_from_resolved_reports_every.rs +++ b/pnpm/crates/config/src/workspace_yaml/tests/configuration_from_resolved_reports_every.rs @@ -33,6 +33,7 @@ fn from_resolved_reports_every_setting() { save_prefix: Some("~".to_string()), tag_version_prefix: "release-".to_string(), save_catalog_name: Some("default".to_string()), + tag: Some("next".to_string()), pipeline_base: Some("origin/main".to_string()), remote_cache: Some(Box::default()), init_author_name: Some("Example".to_string()), diff --git a/pnpm/crates/config/src/workspace_yaml/tests/tag.rs b/pnpm/crates/config/src/workspace_yaml/tests/tag.rs new file mode 100644 index 0000000000..297eddc277 --- /dev/null +++ b/pnpm/crates/config/src/workspace_yaml/tests/tag.rs @@ -0,0 +1,40 @@ +use super::{BTreeMap, Config, Path, WorkspaceSettings, assert_eq}; + +#[test] +fn tag_defaults_to_latest_and_applies_from_yaml() { + let mut config = Config::default(); + assert_eq!(config.tag, None); + assert_eq!(config.default_tag(), "latest"); + let settings: WorkspaceSettings = serde_saphyr::from_str("tag: next\n").unwrap(); + settings.apply_to(&mut config, Path::new("/workspace")); + assert_eq!(config.default_tag(), "next"); + assert_eq!(WorkspaceSettings::from_resolved(&config).tag.as_deref(), Some("next")); +} + +#[test] +fn an_empty_tag_falls_back_to_latest() { + let mut config = Config::default(); + let settings: WorkspaceSettings = serde_saphyr::from_str("tag: ''\n").unwrap(); + settings.apply_to(&mut config, Path::new("/workspace")); + assert_eq!(config.default_tag(), "latest"); +} + +#[test] +fn tag_is_read_from_a_config_override() { + let values = BTreeMap::from([("tag".to_string(), "beta".to_string())]); + let mut config = Config::default(); + WorkspaceSettings::from_string_values(&values).apply_to(&mut config, Path::new("/workspace")); + assert_eq!(config.default_tag(), "beta"); +} + +#[test] +fn tag_can_be_reset() { + let mut config = Config { tag: Some("next".to_string()), ..Config::default() }; + assert!(WorkspaceSettings::reset_setting_to_default::( + &mut config, + &Config::default(), + "tag", + Path::new("/workspace"), + )); + assert_eq!(config.tag, None); +} diff --git a/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs b/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs index b14d398802..e12968c3a6 100644 --- a/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs +++ b/pnpm/crates/deps-restorer/src/install_package_from_registry/tests.rs @@ -259,6 +259,7 @@ fn create_config( minimum_release_age_exclude_prune: false, catalogs: None, save_catalog_name: None, + tag: None, save_prefix: None, save_exact: false, save_peer: false, diff --git a/pnpm/crates/package-manager/src/add/install.rs b/pnpm/crates/package-manager/src/add/install.rs index 1f66715ab9..73684bb567 100644 --- a/pnpm/crates/package-manager/src/add/install.rs +++ b/pnpm/crates/package-manager/src/add/install.rs @@ -108,29 +108,26 @@ pub(super) fn seed_picked_versions( seed: (&mut HashSet, &mut PreferredVersions), ) { let (dropped_pins, preferred) = seed; - for PickedVersion { name, version, specifier } in picked_versions { - let (Ok(package), Ok(semver)) = (name.parse::(), version.parse::()) - else { + for picked in picked_versions { + let Some(lock) = locked.unsettled_pick(picked) else { continue; }; - let direct = locked.direct(&package); - let pinned_elsewhere = direct.is_some_and(|(locked, _)| { - !locked.is_some_and(|locked| same_build(locked, &semver)) - }); - let settled = match direct { - Some(_) if pinned_elsewhere => false, - Some((_, locked_specifier)) if locked_specifier == specifier => true, - _ => locked.only_version_of(&package, &semver), - }; - if settled { - continue; - } - crate::install_with_fresh_lockfile::prefer_requested_version(preferred, name, version); - if pinned_elsewhere { - dropped_pins.insert(name.clone()); + crate::install_with_fresh_lockfile::prefer_requested_version( + preferred, + &picked.name, + &picked.version, + ); + if lock == PickLock::PinnedElsewhere { + dropped_pins.insert(picked.alias.clone()); } } } +/// How the importer locks a pick the install has to move to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PickLock { + Unpinned, + PinnedElsewhere, +} /// What the wanted lockfile holds for the importer an add rewrites. pub(super) struct LockedVersions<'l> { lockfile: Option<&'l Lockfile>, @@ -150,6 +147,30 @@ impl<'l> LockedVersions<'l> { Self { lockfile, importer } } + /// `None` when the fast lockfile update already lands on `picked`. + fn unsettled_pick(&self, picked: &PickedVersion) -> Option { + let PickedVersion { alias, name, version, specifier } = picked; + let (Ok(alias), Ok(name), Ok(version)) = + (alias.parse::(), name.parse::(), version.parse::()) + else { + return None; + }; + let direct = self.direct(&alias); + let pinned_elsewhere = direct.is_some_and(|(locked, _)| { + !locked.is_some_and(|locked| same_build(locked, &version)) + }); + let settled = match direct { + Some(_) if pinned_elsewhere => false, + Some((_, locked_specifier)) if locked_specifier == specifier => true, + _ => self.only_version_of(&name, &version), + }; + match (settled, pinned_elsewhere) { + (true, _) => None, + (false, false) => Some(PickLock::Unpinned), + (false, true) => Some(PickLock::PinnedElsewhere), + } + } + /// The version and specifier the importer locks `name` at as a direct /// dependency. fn direct(&self, name: &PkgName) -> Option<(Option<&'l Version>, &'l str)> { diff --git a/pnpm/crates/package-manager/src/add/manifest.rs b/pnpm/crates/package-manager/src/add/manifest.rs index 51a84af8c6..ad90676e80 100644 --- a/pnpm/crates/package-manager/src/add/manifest.rs +++ b/pnpm/crates/package-manager/src/add/manifest.rs @@ -222,9 +222,11 @@ pub(super) struct PreparedManifest { pub(super) updated_catalogs: Catalogs, pub(super) picked_versions: Vec, } -/// The version an explicit registry specifier for `name` resolved to, and +/// The version an explicit registry specifier for `alias` resolved to, and /// the specifier the manifest saves for it. pub(super) struct PickedVersion { + pub(super) alias: String, + /// The registry name the version belongs to. pub(super) name: String, pub(super) version: String, pub(super) specifier: String, @@ -290,12 +292,13 @@ impl PreparedManifest { let mut picked_versions = Vec::new(); for dependency in dependencies { merge_catalogs(&mut updated_catalogs, &dependency.updated_catalogs); - if let Some(version) = dependency.picked_version - && dependency.manifest_specifier != version + if let Some(picked) = dependency.picked + && dependency.manifest_specifier != picked.version { picked_versions.push(PickedVersion { - name: dependency.package_name, - version, + alias: dependency.package_name, + name: picked.name, + version: picked.version, specifier: dependency.manifest_specifier, }); } diff --git a/pnpm/crates/package-manager/src/add/specifier.rs b/pnpm/crates/package-manager/src/add/specifier.rs index dc5e66e035..263f6af291 100644 --- a/pnpm/crates/package-manager/src/add/specifier.rs +++ b/pnpm/crates/package-manager/src/add/specifier.rs @@ -27,7 +27,14 @@ pub(super) struct ResolvedAddedDependency { pub(super) types_selector: Option, /// The version an explicit registry specifier resolved to, which the /// install must lock. - pub(super) picked_version: Option, + pub(super) picked: Option, +} +/// A registry package version picked while resolving an added selector. +pub(super) struct PickedPackage { + /// The registry name, which differs from the dependency's alias for a + /// `jsr:` selector. + pub(super) name: String, + pub(super) version: String, } pub(super) async fn resolve_added_dependency( package_selector: &str, @@ -37,10 +44,7 @@ pub(super) async fn resolve_added_dependency( let selector = AddSelector::parse(package_selector, manifest, inputs).await?; let package_name = selector.package_name(package_selector); let prev_specifier = declared_specifier(manifest, package_name); - let SaveSpecifier { - specifier: bare_specifier, - picked_version, - } = bare_save_specifier( + let SaveSpecifier { specifier: bare_specifier, picked } = bare_save_specifier( package_selector, &selector, prev_specifier.as_deref(), @@ -65,7 +69,7 @@ pub(super) async fn resolve_added_dependency( updated_catalogs, warnings, types_selector, - picked_version, + picked, }) } @@ -147,6 +151,8 @@ impl AddSelector { (None, Some(protocol)) => protocol.explicit_spec(package_selector), (None, None) => split_name_spec(package_selector).1, } + // pnpm reads `foo@` as `foo`, which resolves the `tag` setting. + .filter(|spec| !spec.is_empty()) } } /// The dependency's current specifier, so a re-add keeps the @@ -190,7 +196,8 @@ pub(super) fn declared_specifier(manifest: &PackageManifest, package_name: &str) /// `catalog:` reference — naming no version asks for whatever the /// workspace agreed on, which is the entry, not the `latest` range /// it happens to resolve to today; -/// - any other brand-new dependency fetches and pins the `latest` range. +/// - any other brand-new dependency fetches and pins the range of the +/// configured `tag`, `latest` by default. pub(super) async fn bare_save_specifier( package_selector: &str, selector: &AddSelector, @@ -224,8 +231,7 @@ pub(super) async fn bare_save_specifier( } if let Some(ProtocolSelector::Jsr(jsr)) = selector.protocol.as_ref() { return Ok(resolve_jsr_save_specifier(jsr, manifest, inputs).await? - .unwrap_or_else(|| package_selector.to_string()) - .into()); + .unwrap_or_else(|| package_selector.to_string().into())); } registry_save_specifier(package_name, explicit_spec, prev_specifier, manifest, inputs).await } @@ -238,33 +244,57 @@ async fn registry_save_specifier( inputs: &AddResolveInputs<'_, '_>, ) -> Result { match (explicit_spec, prev_specifier) { - (Some(spec), prev) => Ok( - match resolve_explicit_registry_spec(package_name, spec, prev, manifest, inputs).await? - { - Some(picked) => { - SaveSpecifier { specifier: picked.range, picked_version: Some(picked.version) } - } - None => normalized_save_specifier(spec).into(), - }, - ), + (Some(spec), prev) => { + explicit_registry_save_specifier(package_name, spec, prev, manifest, inputs).await + } (None, Some(prev)) => Ok(prev.to_string().into()), (None, None) => match cataloged_specifier(package_name, inputs) { Some(specifier) => Ok(specifier.into()), - None => { - pick_latest_range(package_name, manifest, inputs).await.map(SaveSpecifier::from) - } + None => match inputs.add.config.default_tag() { + // The default tag keeps the shared picker every plain add uses. + "latest" => { + pick_latest_range(package_name, manifest, inputs).await.map(SaveSpecifier::from) + } + tag => { + explicit_registry_save_specifier(package_name, tag, None, manifest, inputs) + .await + } + }, }, } } +/// [`registry_save_specifier`] for a registry selector that names `spec`. +async fn explicit_registry_save_specifier( + package_name: &str, + spec: &str, + prev_specifier: Option<&str>, + manifest: &PackageManifest, + inputs: &AddResolveInputs<'_, '_>, +) -> Result { + Ok( + match resolve_explicit_registry_spec(package_name, spec, prev_specifier, manifest, inputs) + .await? + { + Some(picked) => SaveSpecifier { + specifier: picked.range, + picked: Some(PickedPackage { + name: package_name.to_string(), + version: picked.version, + }), + }, + None => normalized_save_specifier(spec).into(), + }, + ) +} /// What [`bare_save_specifier`] records, and the version it resolved an /// explicit registry specifier to. pub(super) struct SaveSpecifier { specifier: String, - picked_version: Option, + picked: Option, } impl From for SaveSpecifier { fn from(specifier: String) -> Self { - Self { specifier, picked_version: None } + Self { specifier, picked: None } } } /// The `catalog:` reference for a dependency the catalog already lists, or @@ -380,10 +410,11 @@ pub(super) async fn resolve_jsr_save_specifier( spec: &JsrSpec, manifest: &PackageManifest, inputs: &AddResolveInputs<'_, '_>, -) -> Result, AddError> { +) -> Result, AddError> { let inputs = AddResolveInputs { add: super::AddOptions { save_types: false, ..inputs.add }, ..*inputs }; - let version_selector = spec.version_selector.as_deref().unwrap_or("latest"); + let version_selector = + spec.version_selector.as_deref().unwrap_or_else(|| inputs.add.config.default_tag()); let range = resolve_explicit_registry_spec( &spec.npm_pkg_name, version_selector, @@ -395,7 +426,10 @@ pub(super) async fn resolve_jsr_save_specifier( &inputs, ) .await?; - Ok(range.map(|picked| format!("jsr:{}", picked.range))) + Ok(range.map(|picked| SaveSpecifier { + specifier: format!("jsr:{}", picked.range), + picked: Some(PickedPackage { name: spec.npm_pkg_name.clone(), version: picked.version }), + })) } /// Split a `pacquet add` argument into its package name and optional /// `@` part. The version separator is the first `@` at or after diff --git a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs index c6a8fbd95b..628f745a0b 100644 --- a/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs +++ b/pnpm/crates/package-manager/src/install_with_fresh_lockfile/resolve.rs @@ -103,7 +103,7 @@ impl SharedResolveOptions<'_> { }, version: pnpm_resolving_resolver_base::VersionSelectionOptions { preferred_versions, - default_tag: Some("latest".to_string()), + default_tag: Some(self.config.default_tag().to_string()), ..Default::default() }, refresh: pnpm_resolving_resolver_base::ResolutionRefreshOptions { diff --git a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/workspace_pick.rs b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/workspace_pick.rs index 70a6167a85..0dbe18dcac 100644 --- a/pnpm/crates/resolving-npm-resolver/src/npm_resolver/workspace_pick.rs +++ b/pnpm/crates/resolving-npm-resolver/src/npm_resolver/workspace_pick.rs @@ -101,7 +101,10 @@ pub(super) fn wanted_spec( default_tag: &str, registry: &str, ) -> Option { - if let Some(bare) = wanted_dependency.bare_specifier.as_deref() { + if let Some(bare) = wanted_dependency.bare_specifier + .as_deref() + .filter(|bare| !bare.is_empty()) + { return parse_bare_specifier( bare, wanted_dependency.alias.as_deref(), diff --git a/pnpm/docs/settings/other.md b/pnpm/docs/settings/other.md index 9bb7f143d1..6d342895c4 100644 --- a/pnpm/docs/settings/other.md +++ b/pnpm/docs/settings/other.md @@ -59,9 +59,6 @@ If you `pnpm add` a package and you don't provide a specific version, then it will install the package at the version registered under the tag from this setting. -This also sets the tag that is added to the `package@version` specified by the -`pnpm tag` command if no explicit tag is given. - ### globalDir * Default: diff --git a/pnpm11/docs/settings/other.md b/pnpm11/docs/settings/other.md index 6805b77822..4f560b14a0 100644 --- a/pnpm11/docs/settings/other.md +++ b/pnpm11/docs/settings/other.md @@ -34,9 +34,6 @@ If you `pnpm add` a package and you don't provide a specific version, then it will install the package at the version registered under the tag from this setting. -This also sets the tag that is added to the `package@version` specified by the -`pnpm tag` command if no explicit tag is given. - ### globalDir * Default: