From 6d036ca5b0e6cb21e5b12a02d544a27e33a97a14 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 22:11:09 +0900 Subject: fix(update): unlock replacer when --with-deps walks a replaced require MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `expand_with_(direct|all)_dependencies` only looked up dependencies by their literal name in the lock. When a transitive require pointed at a virtual / replaced name (e.g. `replaced/pkg1`) and the lock owned it through another package's `replace` map (e.g. `dep/pkg1` replaces `replaced/pkg1`), the replacer never entered the unlock set. The partial-update resolver then left it pinned at its lock version and silently kept the user on the old release. Mirror Composer's replace branch in `PoolBuilder::loadPackage`: build a `replaced → replacers` index over the lock and route every dep walked during expansion through it before recursing. --- crates/mozart/src/commands/update.rs | 60 ++++++++++++++++++++++++++++++++---- crates/mozart/tests/installer.rs | 2 +- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index bb99e26..185d3be 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -587,6 +587,7 @@ pub fn expand_with_direct_dependencies( repo_requires: &IndexMap>, ) -> Vec { let lock_map = build_lock_map(lock); + let replace_map = build_lock_replace_map(lock); let mut result_set: IndexSet = packages.iter().cloned().collect(); let mut queue: Vec = packages.clone(); let mut result: Vec = packages; @@ -603,9 +604,11 @@ pub fn expand_with_direct_dependencies( if root_requires.contains(&dep_name) { continue; } - if result_set.insert(dep_name.clone()) { - result.push(dep_name.clone()); - queue.push(dep_name); + for actual in resolve_dep_via_replace(&dep_name, &lock_map, &replace_map) { + if result_set.insert(actual.clone()) { + result.push(actual.clone()); + queue.push(actual); + } } } } @@ -622,6 +625,7 @@ pub fn expand_with_all_dependencies( repo_requires: &IndexMap>, ) -> Vec { let lock_map = build_lock_map(lock); + let replace_map = build_lock_replace_map(lock); let mut result_set: IndexSet = packages.iter().cloned().collect(); let mut queue: Vec = packages.clone(); let mut result: Vec = packages; @@ -634,9 +638,11 @@ pub fn expand_with_all_dependencies( if is_platform_dep(&dep_name) { continue; } - if result_set.insert(dep_name.clone()) { - result.push(dep_name.clone()); - queue.push(dep_name); + for actual in resolve_dep_via_replace(&dep_name, &lock_map, &replace_map) { + if result_set.insert(actual.clone()) { + result.push(actual.clone()); + queue.push(actual); + } } } } @@ -644,6 +650,48 @@ pub fn expand_with_all_dependencies( result } +/// Build a `replaced_name → list of replacing package names` index over the +/// lock, so a dependency on a virtual / replaced name reaches the actual +/// locked package that owns it. Mirrors the replace branch of Composer's +/// `PoolBuilder::loadPackage`: a partial update with `--with-dependencies` +/// must unlock the replacer when a transitive require points at the +/// replaced name, otherwise the resolver leaves the replacer pinned at +/// its lock version and silently fails to upgrade. +fn build_lock_replace_map(lock: &lockfile::LockFile) -> IndexMap> { + let mut map: IndexMap> = IndexMap::new(); + for pkg in lock + .packages + .iter() + .chain(lock.packages_dev.iter().flatten()) + { + for replaced in pkg.replace.keys() { + map.entry(replaced.to_lowercase()) + .or_default() + .push(pkg.name.to_lowercase()); + } + } + map +} + +/// Translate a dependency name into the list of locked package names that +/// effectively own it: either the package directly named (the common case) +/// or, when the name is virtual / replaced, every locked package whose +/// `replace` map covers it. The result is what should enter the unlock set +/// during `--with-(all-)dependencies` expansion. +fn resolve_dep_via_replace( + dep_name: &str, + lock_map: &IndexMap, + replace_map: &IndexMap>, +) -> Vec { + if lock_map.contains_key(dep_name) { + vec![dep_name.to_string()] + } else if let Some(replacers) = replace_map.get(dep_name) { + replacers.clone() + } else { + vec![dep_name.to_string()] + } +} + /// Expand the package list applying wildcard matching and optional dependency expansion. /// /// Returns the final list of package names to update (concrete, lowercase, deduplicated). diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index e9f2749..c3e1f7d 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -299,7 +299,7 @@ installer_fixture!( ignore ); installer_fixture!(partial_update_with_dependencies_provide); -installer_fixture!(partial_update_with_dependencies_replace, ignore); +installer_fixture!(partial_update_with_dependencies_replace); installer_fixture!(partial_update_with_deps_warns_root); installer_fixture!(partial_update_with_symlinked_path_repos); installer_fixture!(partial_update_without_lock); -- cgit v1.3.1