Skip to content
This repository was archived by the owner on Jul 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ use pacquet_store_dir::StoreDir;
use pipe_trait::Pipe;
use serde::Deserialize;
use smart_default::SmartDefault;
use std::{collections::HashMap, fs, path::PathBuf};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs,
path::PathBuf,
};

pub use crate::defaults::{
available_parallelism, default_git_shallow_hosts, default_unsafe_perm, is_unsafe_perm_posix,
Expand Down Expand Up @@ -363,6 +367,39 @@ pub struct Config {
#[default = true]
pub hoist_workspace_packages: bool,

/// Per-importer block-list of package aliases that may NOT be
/// hoisted past that importer's slot. Outer key is the
/// importer locator (e.g. `'.@'` for the root project, or the
/// percent-encoded importer id with the `@` slot suffix);
/// inner set is the alias names whose hoisting is bordered.
///
/// Programmatic-only upstream — pnpm exposes it through the
/// embedded API and Bit CLI rather than `pnpm-workspace.yaml`,
/// because the ergonomics of the locator-keyed map don't
/// translate cleanly to a yaml setting. Pacquet exposes it
/// via `HoistOpts::hoisting_limits` (in `pacquet-real-hoist`)
/// and reads the same yaml shape (`hoistingLimits: { ".@": [...] }`)
/// for parity.
///
/// Default empty (no aliases bordered). Mirrors upstream's
/// [`hoistingLimits`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L10).
/// No effect under `nodeLinker: isolated`.
pub hoisting_limits: BTreeMap<String, BTreeSet<String>>,

/// Name slots reserved at the root for an external linker
/// (the Bit CLI is the only known consumer upstream). Any
/// dependency whose alias matches one of these names is
/// stripped from the hoist tree's top-level entries — the
/// external linker materializes those slots itself.
///
/// Programmatic-only upstream; pacquet exposes the same yaml
/// shape (`externalDependencies: ["bit-bin"]`) for parity.
///
/// Default empty. Mirrors upstream's
/// [`externalDependencies`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L18).
/// No effect under `nodeLinker: isolated`.
pub external_dependencies: BTreeSet<String>,

/// When this setting is set to true, packages with peer dependencies will be deduplicated after peers resolution.
#[default = true]
pub dedupe_peer_dependents: bool,
Expand Down
15 changes: 14 additions & 1 deletion crates/config/src/workspace_yaml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use pacquet_store_dir::StoreDir;
use pipe_trait::Pipe;
use serde::{Deserialize, Deserializer};
use std::{
collections::HashMap,
collections::{BTreeMap, BTreeSet, HashMap},
fs,
io::{self, ErrorKind},
path::{Path, PathBuf},
Expand Down Expand Up @@ -119,6 +119,18 @@ pub struct WorkspaceSettings {
pub registry: Option<String>,
pub auto_install_peers: Option<bool>,
pub hoist_workspace_packages: Option<bool>,
/// `hoistingLimits` from `pnpm-workspace.yaml`. Outer key is
/// the importer locator (e.g. `'.@'`); inner list is the
/// alias names whose hoisting is bordered. Mirrors upstream's
/// programmatic-only knob shape, exposed here as yaml for
/// parity. Empty / missing → no limits.
pub hoisting_limits: Option<BTreeMap<String, BTreeSet<String>>>,
/// `externalDependencies` from `pnpm-workspace.yaml`. Names
/// whose top-level slot is reserved for an external linker
/// and stripped from the hoist tree. Mirrors upstream's
/// programmatic-only knob shape, exposed here as yaml for
/// parity. Empty / missing → no externals.
pub external_dependencies: Option<BTreeSet<String>>,
pub dedupe_peer_dependents: Option<bool>,
pub strict_peer_dependencies: Option<bool>,
pub resolve_peers_from_workspace_root: Option<bool>,
Expand Down Expand Up @@ -296,6 +308,7 @@ impl WorkspaceSettings {
lockfile, prefer_frozen_lockfile, offline, prefer_offline,
lockfile_include_tarball_url,
auto_install_peers, hoist_workspace_packages,
hoisting_limits, external_dependencies,
dedupe_peer_dependents, strict_peer_dependencies,
resolve_peers_from_workspace_root, verify_store_integrity,
side_effects_cache, side_effects_cache_readonly,
Expand Down
64 changes: 64 additions & 0 deletions crates/config/src/workspace_yaml/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -649,3 +649,67 @@ fn omitting_ignored_optional_dependencies_keeps_default() {
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert!(config.ignored_optional_dependencies.is_none());
}

/// `hoistingLimits` deserializes as a map keyed by importer
/// locator (e.g. `'.@'`); inner value is a list of alias names.
/// Mirrors upstream's [`HoistingLimits`](https://github.com/pnpm/pnpm/blob/94240bc046/installing/linking/real-hoist/src/index.ts#L10)
/// shape and threads straight into [`pacquet_real_hoist::HoistOpts`]
/// via the install pipeline. Yaml-empty / missing keeps the
/// `Config` field at its `BTreeMap::default()` empty value.
#[test]
fn parses_hoisting_limits_from_yaml_and_applies() {
let yaml = r#"
hoistingLimits:
".@":
- foo
- bar
"#;
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
let raw = settings.hoisting_limits.clone().expect("field present");
let aliases = raw.get(".@").expect("locator present");
assert!(aliases.contains("foo") && aliases.contains("bar"));

let mut config = Config::new();
assert!(config.hoisting_limits.is_empty(), "default is empty");
settings.apply_to(&mut config, Path::new("/irrelevant"));
let aliases = config.hoisting_limits.get(".@").expect("locator present in config");
assert!(aliases.contains("foo") && aliases.contains("bar"));
}

/// `externalDependencies` deserializes as a flat list of names.
/// Yaml-empty / missing keeps the `Config` field at its
/// `BTreeSet::default()` empty value.
#[test]
fn parses_external_dependencies_from_yaml_and_applies() {
let yaml = r#"
externalDependencies:
- bit-bin
- some-other-external
"#;
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
let raw = settings.external_dependencies.clone().expect("field present");
assert!(raw.contains("bit-bin") && raw.contains("some-other-external"));

let mut config = Config::new();
assert!(config.external_dependencies.is_empty(), "default is empty");
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert!(config.external_dependencies.contains("bit-bin"));
assert!(config.external_dependencies.contains("some-other-external"));
}

/// Both knobs absent → both `Config` fields stay at their empty
/// defaults. Pins the `apply_to` skip-on-None branch so future
/// edits don't accidentally overwrite with empty when the yaml
/// just doesn't mention these settings.
#[test]
fn omitting_hoisting_limits_and_external_dependencies_keeps_defaults() {
let yaml = "";
let settings: WorkspaceSettings = serde_saphyr::from_str(yaml).unwrap();
assert!(settings.hoisting_limits.is_none());
assert!(settings.external_dependencies.is_none());

let mut config = Config::new();
settings.apply_to(&mut config, Path::new("/irrelevant"));
assert!(config.hoisting_limits.is_empty());
assert!(config.external_dependencies.is_empty());
}
63 changes: 60 additions & 3 deletions crates/package-manager/src/hoisted_dep_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,24 @@ pub struct LockfileToHoistedDepGraphOptions {
/// under `<lockfile_dir>/<importer_id>/node_modules`. When
/// `false`, only the root importer's subtree is emitted (the
/// hoister also skips adding the workspace children to its
/// shared tree). Pacquet's [`pacquet_config::Config::hoist_workspace_packages`]
/// drives this from the install pipeline.
/// shared tree). Pacquet's `Config::hoist_workspace_packages`
/// (in `pacquet-config`) drives this from the install pipeline.
pub hoist_workspace_packages: bool,

/// Per-importer block-list passed straight through to
/// [`pacquet_real_hoist::HoistOpts::hoisting_limits`]. See the
/// hoister's doc-comment for the locator-keyed shape and
/// `Config::hoisting_limits` in `pacquet-config` for how the
/// install pipeline derives this from `pnpm-workspace.yaml`.
pub hoisting_limits: pacquet_real_hoist::HoistingLimits,

/// Reserved-name list passed straight through to
/// [`pacquet_real_hoist::HoistOpts::external_dependencies`].
/// See the hoister's doc-comment for the strip semantics and
/// `Config::external_dependencies` in `pacquet-config` for how
/// the install pipeline derives this from
/// `pnpm-workspace.yaml`.
pub external_dependencies: BTreeSet<String>,
}

impl Default for LockfileToHoistedDepGraphOptions {
Expand All @@ -260,6 +275,8 @@ impl Default for LockfileToHoistedDepGraphOptions {
// `..Default::default()`-style construction at the call
// site doesn't silently disable workspace hoisting.
hoist_workspace_packages: true,
hoisting_limits: pacquet_real_hoist::HoistingLimits::new(),
external_dependencies: BTreeSet::new(),
}
}
}
Expand Down Expand Up @@ -377,7 +394,8 @@ fn build_dep_graph(
let hoist_opts = HoistOpts {
auto_install_peers: opts.auto_install_peers,
hoist_workspace_packages: opts.hoist_workspace_packages,
..HoistOpts::default()
hoisting_limits: opts.hoisting_limits.clone(),
external_dependencies: opts.external_dependencies.clone(),
};
let hoister_result = hoist(lockfile, &hoist_opts)?;

Expand Down Expand Up @@ -1837,4 +1855,43 @@ mod tests {
let foo_a = &result.direct_dependencies_by_importer_id["packages/foo"]["a"];
assert_ne!(root_a, foo_a, "conflict resolves to two distinct dirs");
}

/// `external_dependencies` flows through to the hoister and
/// strips matching aliases from the post-hoist result. Pins
/// the slice 10 plumbing end-to-end: the walker observes an
/// empty graph for the `external` alias because the hoister
/// stripped it, even though the lockfile listed it as a
/// direct dep.
#[test]
fn walker_forwards_external_dependencies_to_hoister() {
let mut root_deps = ResolvedDependencyMap::new();
root_deps.insert(pkg_name("a"), resolved_dep("1.0.0"));

let mut packages = HashMap::new();
packages.insert(dep_key("a", "1.0.0"), metadata_stub());

let mut snapshots = HashMap::new();
snapshots.insert(dep_key("a", "1.0.0"), SnapshotEntry::default());

let lockfile = lockfile_with(root_deps, packages, snapshots);
let lockfile_dir = PathBuf::from("/repo");
let mut externals = BTreeSet::new();
externals.insert("a".to_string());
let opts = LockfileToHoistedDepGraphOptions {
lockfile_dir: lockfile_dir.clone(),
external_dependencies: externals,
..LockfileToHoistedDepGraphOptions::default()
};
let result =
lockfile_to_hoisted_dep_graph(&lockfile, None, &opts).expect("walker succeeds");

// `a` was reserved as an external; hoister stripped it
// from the top-level result, so the walker emits an
// empty graph and an empty root direct-deps map.
assert!(result.graph.is_empty(), "external strips the alias from the hoist result");
assert!(
result.direct_dependencies_by_importer_id[Lockfile::ROOT_IMPORTER_KEY].is_empty(),
"root direct deps drop the externalised alias",
);
}
}
2 changes: 2 additions & 0 deletions crates/package-manager/src/install_frozen_lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,8 @@ where
current_libc: pacquet_graph_hasher::host_libc().to_string(),
supported_architectures: supported_architectures.cloned(),
hoist_workspace_packages: config.hoist_workspace_packages,
hoisting_limits: config.hoisting_limits.clone(),
external_dependencies: config.external_dependencies.clone(),
};
let walker_result =
lockfile_to_hoisted_dep_graph(lockfile, current_lockfile, &walker_opts)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ fn create_config(store_dir: &Path, modules_dir: &Path, virtual_store_dir: &Path)
registry: "https://registry.npmjs.com/".to_string(),
auto_install_peers: false,
hoist_workspace_packages: true,
hoisting_limits: Default::default(),
external_dependencies: Default::default(),
dedupe_peer_dependents: false,
strict_peer_dependencies: false,
resolve_peers_from_workspace_root: false,
Expand Down
Loading