Skip to content
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
42 changes: 41 additions & 1 deletion pacquet/crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ mod workspace_yaml;
pub use crate::api::{EnvVar, EnvVarOs, GetCurrentDir, GetHomeDir, Host, LinkProbe};

use indexmap::IndexMap;
use pacquet_patching::{PatchGroupRecord, ResolvePatchedDependenciesError, resolve_and_group};
use pacquet_patching::{
CalcPatchHashError, PatchGroupRecord, ResolvePatchedDependenciesError, calc_patch_hashes,
resolve_and_group,
};
use pacquet_store_dir::StoreDir;
use pacquet_workspace_state::ConfigDependency;
use pipe_trait::Pipe;
Expand Down Expand Up @@ -1525,6 +1528,43 @@ impl Config {
resolve_and_group(workspace_dir, raw)
}

/// Resolve relative patch file paths in
/// [`Config::patched_dependencies`] against
/// [`Config::workspace_dir`] and hash each file, producing the
/// `patchedDependencies` map the lockfile records: each configured
/// key mapped to its patch file's SHA-256 hex digest.
///
/// Mirrors upstream's
/// [`calcPatchHashes(opts.patchedDependencies)`](https://github.com/pnpm/pnpm/blob/39101f5e37/installing/deps-installer/src/install/index.ts#L547-L549),
/// where `opts.patchedDependencies` is the manifest-dir-resolved
/// map produced by
/// [`getOptionsFromRootManifest`](https://github.com/pnpm/pnpm/blob/b4f8f47ac2/config/reader/src/getOptionsFromRootManifest.ts#L44-L46).
/// Distinct from [`Self::resolved_patched_dependencies`], which
/// groups the same entries by package name for the resolver — this
/// keeps the user's verbatim keys so the lockfile is byte-faithful
/// (e.g. a bare `foo` and `foo@*` stay separate keys rather than
/// collapsing into one group bucket).
///
/// Returns `Ok(None)` when either field is unset.
pub fn patched_dependency_hashes(
&self,
) -> Result<Option<BTreeMap<String, String>>, CalcPatchHashError> {
let (Some(workspace_dir), Some(raw)) = (&self.workspace_dir, &self.patched_dependencies)
else {
return Ok(None);
};
let resolved = raw.iter().map(|(key, rel_or_abs)| {
let candidate = Path::new(rel_or_abs);
let path = if candidate.is_absolute() {
candidate.to_path_buf()
} else {
workspace_dir.join(candidate)
};
(key.clone(), path)
});
Ok(Some(calc_patch_hashes(resolved)?))
}

/// Build the runtime config by layering:
/// 1. hard-coded defaults, then
/// 2. the supported `.npmrc` subset read from the nearest `.npmrc`
Expand Down
25 changes: 25 additions & 0 deletions pacquet/crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,31 @@ pub fn peers_suffix_max_length_env_var_overrides_yaml() {
assert_eq!(config.peers_suffix_max_length, 25, "env var must win over pnpm-workspace.yaml");
}

/// `Config::patched_dependency_hashes` resolves each relative patch
/// path against `workspace_dir`, hashes the file, and returns the
/// verbatim key → hash map the lockfile records. Mirrors pnpm's
/// `calcPatchHashes(opts.patchedDependencies)`.
#[test]
fn patched_dependency_hashes_resolves_and_hashes_each_patch() {
let workspace = tempdir().expect("workspace tempdir");
let patch_path = workspace.path().join("patches").join("[email protected]");
std::fs::create_dir_all(patch_path.parent().unwrap()).expect("create patches dir");
std::fs::write(&patch_path, "--- a/index.js\n+++ b/index.js\n").expect("write patch");
let expected =
pacquet_patching::create_hex_hash_from_file(&patch_path).expect("hash patch file");

let mut config = Config::new();
assert!(config.patched_dependency_hashes().expect("no error").is_none(), "unset → None");

config.workspace_dir = Some(workspace.path().to_path_buf());
config.patched_dependencies = Some(indexmap::IndexMap::from([(
"[email protected]".to_string(),
"patches/[email protected]".to_string(),
)]));
let hashes = config.patched_dependency_hashes().expect("hash").expect("present");
assert_eq!(hashes.get("[email protected]"), Some(&expected));
}

/// `minimumReleaseAge: 0` disables the maturity cutoff (returns
/// `None`), matching pnpm's falsy check; any positive value passes
/// through, and the built-in default (1440) is non-zero.
Expand Down
47 changes: 43 additions & 4 deletions pacquet/crates/lockfile/src/freshness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ pub enum StalenessReason {
"`packageExtensionsChecksum` in the lockfile ({lockfile:?}) doesn't match the current config ({config:?})"
)]
PackageExtensionsChecksumChanged { lockfile: Option<String>, config: Option<String> },

/// The lockfile's `patchedDependencies` (key → patch-file hash)
/// doesn't match the map the current install would write. Mirrors
/// upstream's
/// [`getOutdatedLockfileSetting.ts:61-63`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L61-L63):
/// upstream returns `'patchedDependencies'` and `needsFullResolution`
/// flips on. Pacquet has no resolver, so the matching action is to
/// surface this as `OutdatedLockfile`. A changed patch file changes
/// its hash here, which is what catches an edited patch whose
/// `(patch_hash=...)` depPath suffix would otherwise go stale. Both
/// values are normalized into a `BTreeMap` so the comparison is
/// order-insensitive (matching upstream's Ramda `equals`).
#[display(
"`patchedDependencies` in the lockfile ({lockfile:?}) doesn't match the current config ({config:?})"
)]
PatchedDependenciesChanged {
lockfile: BTreeMap<String, String>,
config: BTreeMap<String, String>,
},
}

/// Per-bucket diff against the manifest's flat union of deps.
Expand Down Expand Up @@ -213,10 +232,11 @@ impl SpecDiff {

/// Verify that lockfile-level settings the install pipeline reads
/// from `pnpm-workspace.yaml` haven't drifted since the lockfile
/// was written. Today this covers `overrides` and
/// `ignoredOptionalDependencies` (umbrella [#434] slice 7); the
/// variants below will grow as more upstream settings land
/// (`catalogs`, `patchedDependencies`, `pnpmfileChecksum`, etc.).
/// was written. Today this covers `overrides`,
/// `packageExtensionsChecksum`, `ignoredOptionalDependencies`,
/// `patchedDependencies`, and the relevant `settings.*` keys (umbrella
/// [#434] slice 7); the variants below will grow as more upstream
/// settings land (`catalogs`, `pnpmfileChecksum`, etc.).
///
/// Mirrors upstream's
/// [`getOutdatedLockfileSetting`](https://github.com/pnpm/pnpm/blob/606f53e78f/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts).
Expand All @@ -233,6 +253,7 @@ pub fn check_lockfile_settings(
overrides: Option<&HashMap<String, String>>,
package_extensions_checksum: Option<&str>,
ignored_optional_dependencies: Option<&[String]>,
patched_dependencies: Option<&BTreeMap<String, String>>,
inject_workspace_packages: bool,
peers_suffix_max_length: u64,
) -> Result<(), StalenessReason> {
Expand Down Expand Up @@ -284,6 +305,24 @@ pub fn check_lockfile_settings(
});
}

// Upstream checks `patchedDependencies` right after
// `ignoredOptionalDependencies` via
// `!equals(lockfile.patchedDependencies ?? {}, patchedDependencies ?? {})`.
// Both maps are already key-sorted (`BTreeMap`), so `==` reproduces
// the order-insensitive `equals`; absent on either side normalizes
// to an empty map. A changed patch file changes its hash here, so
// this is what invalidates a lockfile whose `(patch_hash=...)` depPath
// suffixes would otherwise go stale.
let empty_patches: BTreeMap<String, String> = BTreeMap::new();
let lockfile_patches = lockfile.patched_dependencies.as_ref().unwrap_or(&empty_patches);
let config_patches = patched_dependencies.unwrap_or(&empty_patches);
if lockfile_patches != config_patches {
return Err(StalenessReason::PatchedDependenciesChanged {
lockfile: lockfile_patches.clone(),
config: config_patches.clone(),
});
}

// `Boolean(lockfile.settings?.injectWorkspacePackages) !==
// Boolean(injectWorkspacePackages)` at upstream's
// [`getOutdatedLockfileSetting.ts:80-82`](https://github.com/pnpm/pnpm/blob/39101f5e37/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts#L80-L82).
Expand Down
Loading
Loading