fix(add): resolve a version-less dependency to the tag setting (#17260)
pnpm v12 resolved `pnpm add <pkg>` without a version, and a version-less `npm:` or `jsr:` dependency, to the `latest` dist-tag even when the `tag` setting named another one. pnpm v11 reads `tag` from pnpm-workspace.yaml, the global config, `PNPM_CONFIG_TAG`, and `--config.tag`, and threads it as `defaultTag` into `parseWantedDependencies` (installSome.ts) and the resolution context (createResolutionContext.ts). `Config` now carries `tag`, read from the same layers, with `Config::default_tag` falling back to `latest`. A brand-new `add` with no version resolves a non-default tag the way an explicit `add <pkg>@<tag>` does, so the picked version is also locked. A `jsr:` add and the install resolver take the same default. `dlx`, `add --config`, `update --latest`, and `outdated` keep `latest`, as they do in v11. Tested with e2e tests for the yaml setting, `--config.tag`, and a version-less `npm:` alias, each seen to fail with `default_tag` forced to `latest`, plus config unit tests for apply, override, reset, and the resolved projection. Part of pnpm/tasks#191 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be2f02ffc8
commit
6d736a02a8
25 files changed
+371
-66
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"pacquet": patch
|
||||
---
|
||||
|
||||
`pnpm add <pkg>` 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`.
|
||||
@@ -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` /
|
||||
|
||||
@@ -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 `<pkg>@` as `<pkg>`, 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 {
|
||||
|
||||
@@ -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<AddMockedRegistry>,
|
||||
) -> (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));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -418,3 +418,14 @@ fn publish_wait_timeout_reads_from_environment() {
|
||||
}
|
||||
assert_eq!(WorkspaceSettings::from_pnpm_config_env::<Env>().publish_wait_timeout, Some(600000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_parses_from_env() {
|
||||
struct EnvTag;
|
||||
impl EnvVar for EnvTag {
|
||||
fn var(name: &str) -> Option<String> {
|
||||
(name == "PNPM_CONFIG_TAG").then(|| "next".to_owned())
|
||||
}
|
||||
}
|
||||
assert_eq!(WorkspaceSettings::from_pnpm_config_env::<EnvTag>().tag.as_deref(), Some("next"));
|
||||
}
|
||||
@@ -1714,6 +1714,11 @@ pub struct Config {
|
||||
/// `undefined`).
|
||||
pub save_catalog_name: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -101,6 +101,7 @@ impl WorkspaceSettings {
|
||||
|
||||
pub(super) fn substitute_env_scalars<Sys: EnvVar>(&mut self) {
|
||||
substitute_optional_string::<Sys>(&mut self.scope);
|
||||
substitute_optional_string::<Sys>(&mut self.tag);
|
||||
substitute_optional_string::<Sys>(&mut self.store_dir);
|
||||
substitute_optional_string::<Sys>(&mut self.state_dir);
|
||||
substitute_optional_string::<Sys>(&mut self.modules_dir);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -840,6 +840,11 @@ pub struct WorkspaceSettings {
|
||||
/// [`Config::save_catalog_name`]: crate::Config::save_catalog_name
|
||||
pub save_catalog_name: Option<String>,
|
||||
|
||||
/// `tag` from `pnpm-workspace.yaml`. See [`Config::tag`].
|
||||
///
|
||||
/// [`Config::tag`]: crate::Config::tag
|
||||
pub tag: Option<String>,
|
||||
|
||||
/// `savePrefix` from `pnpm-workspace.yaml`. See
|
||||
/// [`Config::save_prefix`].
|
||||
///
|
||||
|
||||
@@ -71,6 +71,8 @@ mod workspace_settings;
|
||||
|
||||
mod reporting;
|
||||
|
||||
mod tag;
|
||||
|
||||
mod tag_version_prefix;
|
||||
|
||||
mod integrity;
|
||||
|
||||
+1
@@ -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()),
|
||||
|
||||
@@ -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::<crate::Host>(
|
||||
&mut config,
|
||||
&Config::default(),
|
||||
"tag",
|
||||
Path::new("/workspace"),
|
||||
));
|
||||
assert_eq!(config.tag, None);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -108,29 +108,26 @@ pub(super) fn seed_picked_versions(
|
||||
seed: (&mut HashSet<String>, &mut PreferredVersions),
|
||||
) {
|
||||
let (dropped_pins, preferred) = seed;
|
||||
for PickedVersion { name, version, specifier } in picked_versions {
|
||||
let (Ok(package), Ok(semver)) = (name.parse::<PkgName>(), version.parse::<Version>())
|
||||
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<PickLock> {
|
||||
let PickedVersion { alias, name, version, specifier } = picked;
|
||||
let (Ok(alias), Ok(name), Ok(version)) =
|
||||
(alias.parse::<PkgName>(), name.parse::<PkgName>(), version.parse::<Version>())
|
||||
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)> {
|
||||
|
||||
@@ -222,9 +222,11 @@ pub(super) struct PreparedManifest {
|
||||
pub(super) updated_catalogs: Catalogs,
|
||||
pub(super) picked_versions: Vec<PickedVersion>,
|
||||
}
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,7 +27,14 @@ pub(super) struct ResolvedAddedDependency {
|
||||
pub(super) types_selector: Option<String>,
|
||||
/// The version an explicit registry specifier resolved to, which the
|
||||
/// install must lock.
|
||||
pub(super) picked_version: Option<String>,
|
||||
pub(super) picked: Option<PickedPackage>,
|
||||
}
|
||||
/// 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<SaveSpecifier, AddError> {
|
||||
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<SaveSpecifier, AddError> {
|
||||
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<String>,
|
||||
picked: Option<PickedPackage>,
|
||||
}
|
||||
impl From<String> 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<Option<String>, AddError> {
|
||||
) -> Result<Option<SaveSpecifier>, 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
|
||||
/// `@<version>` part. The version separator is the first `@` at or after
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -101,7 +101,10 @@ pub(super) fn wanted_spec(
|
||||
default_tag: &str,
|
||||
registry: &str,
|
||||
) -> Option<RegistryPackageSpec> {
|
||||
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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user