From 38706a6f0ceb773d473c4f5ddebf49e8e5ae46dc Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 22:07:34 +0900 Subject: fix(update): apply --minimal-changes via policy preferred versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation pinned every resolved package back to its locked version after the resolve, which discarded the new versions the solver had to pick when a root constraint moved off the lock (e.g. a require bumped from `1.*` to `2.*`). The lock effectively never moved, so transitive cascades from a forced root-level update were lost. Mirror Composer's `Installer::createPolicy(forUpdate=true, minimalUpdate=true)` instead: thread the lock's `name → normalized version` map through the policy as `preferred_versions`. The solver now picks the locked version as a tiebreaker when it still satisfies the active constraints, but moves freely when a constraint forces a different version. Drop the post-process hook entirely. --- crates/mozart-registry/src/lockfile.rs | 1 + crates/mozart-registry/src/resolver.rs | 22 +++++++++++++-- crates/mozart-sat-resolver/src/policy.rs | 41 ++++++++++++++++++++++++++++ crates/mozart/src/commands/create_project.rs | 1 + crates/mozart/src/commands/remove.rs | 4 +++ crates/mozart/src/commands/require.rs | 3 ++ crates/mozart/src/commands/update.rs | 35 +++++++++++++++++++----- crates/mozart/tests/installer.rs | 2 +- 8 files changed, 99 insertions(+), 10 deletions(-) (limited to 'crates') diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index 701a6f7..197335f 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1680,6 +1680,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: IndexMap::new(), }; let resolved = resolve(&resolve_request) diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 66e923d..33c3659 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -733,6 +733,11 @@ pub struct ResolveRequest { /// alias's version for any link originally written as `self.version`. /// `None` when the root carries no matching `branch-alias` entry. pub root_branch_alias: Option, + /// `name → normalized version` map fed to the policy's preferred-version + /// override. Used by `update --minimal-changes` so the solver only moves + /// a package when a constraint actually forces a different version. + /// Empty for a normal full update. + pub preferred_versions: IndexMap, } /// Full data for a lock-pinned package, used in partial updates. Carried on @@ -1337,8 +1342,20 @@ pub async fn resolve(request: &ResolveRequest) -> Result, R return Err(ResolveError::NoSolution(report)); } - // Create policy and solve - let policy = DefaultPolicy::new(request.prefer_stable, request.prefer_lowest); + // Create policy and solve. When `preferred_versions` is non-empty (the + // `--minimal-changes` flow) feed it through the policy so the locked + // version wins over the regular highest/lowest pick whenever a candidate + // matches it. Mirrors Composer's + // `Installer::createPolicy` minimal-update branch. + let policy = if request.preferred_versions.is_empty() { + DefaultPolicy::new(request.prefer_stable, request.prefer_lowest) + } else { + DefaultPolicy::with_preferred( + request.prefer_stable, + request.prefer_lowest, + request.preferred_versions.clone(), + ) + }; let fixed_set: IndexSet = fixed_ids.into_iter().collect(); let solver = Solver::new(rules, &pool, policy, fixed_set); @@ -1786,6 +1803,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: IndexMap::new(), }; let result = resolve(&request).await; diff --git a/crates/mozart-sat-resolver/src/policy.rs b/crates/mozart-sat-resolver/src/policy.rs index a253c39..f45c4f5 100644 --- a/crates/mozart-sat-resolver/src/policy.rs +++ b/crates/mozart-sat-resolver/src/policy.rs @@ -10,6 +10,14 @@ pub struct DefaultPolicy { pub prefer_stable: bool, /// Whether to prefer lowest versions. pub prefer_lowest: bool, + /// `name → normalized version` overrides used when more than one + /// candidate could satisfy a requirement: a literal pinned at the + /// preferred version wins outright over the usual highest/lowest pick. + /// Mirrors Composer's `DefaultPolicy::pruneToBestVersion` behavior under + /// `--minimal-changes`, where the lock's previously-installed versions + /// are passed in so the solver only moves a package when a constraint + /// actually forces a different version. + pub preferred_versions: Option>, } impl DefaultPolicy { @@ -17,6 +25,19 @@ impl DefaultPolicy { DefaultPolicy { prefer_stable, prefer_lowest, + preferred_versions: None, + } + } + + pub fn with_preferred( + prefer_stable: bool, + prefer_lowest: bool, + preferred_versions: IndexMap, + ) -> Self { + DefaultPolicy { + prefer_stable, + prefer_lowest, + preferred_versions: Some(preferred_versions), } } @@ -123,6 +144,26 @@ impl DefaultPolicy { return vec![]; } + // Mirror Composer's `DefaultPolicy::pruneToBestVersion` short-circuit: + // when a preferred version is set for this package and one of the + // candidates matches it exactly, that wins over the regular + // highest/lowest pick. Falls through otherwise (e.g. the locked + // version no longer satisfies the constraint and was filtered out + // before reaching this method). + if let Some(ref preferred) = self.preferred_versions { + let name = pool.literal_to_package(literals[0]).name.clone(); + if let Some(preferred_ver) = preferred.get(&name) { + let preferred_lits: Vec = literals + .iter() + .filter(|&&lit| pool.literal_to_package(lit).version == *preferred_ver) + .copied() + .collect(); + if !preferred_lits.is_empty() { + return preferred_lits; + } + } + } + // The first literal is the best after sorting let best_version = &pool.literal_to_package(literals[0]).version; literals diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index 89b3e4f..a7964ae 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -444,6 +444,7 @@ pub async fn execute( locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; console.info("Resolving dependencies..."); diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index f41d8b5..c52d410 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -278,6 +278,7 @@ pub async fn execute( locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; // Print header messages @@ -565,6 +566,7 @@ async fn remove_unused( locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; console.info("Resolving dependencies to detect unused packages..."); @@ -922,6 +924,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; let resolved = resolve(&request) .await @@ -982,6 +985,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; let resolved2 = resolve(&request2) .await diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 0816d13..3ff5ced 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -666,6 +666,7 @@ pub async fn execute( locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; // Print header messages @@ -1077,6 +1078,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; let resolved = resolver::resolve(&request) @@ -1155,6 +1157,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: indexmap::IndexMap::new(), }; let resolved = resolver::resolve(&request) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index c4ebdf7..bb99e26 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -1119,6 +1119,30 @@ pub async fn run( .and_then(|v| v.as_bool()) .unwrap_or(false); + // For `--minimal-changes` without a per-package update list, feed the + // lock's pinned versions into the resolver as preferred-version + // overrides. Mirrors Composer's + // `Installer::createPolicy(forUpdate=true, minimalUpdate=true)` branch. + let preferred_versions: IndexMap = + if args.minimal_changes && raw_packages.is_empty() && lock_path.exists() { + match lockfile::LockFile::read_from_file(&lock_path) { + Ok(lock) => { + let mut map = IndexMap::new(); + for pkg in lock + .packages + .iter() + .chain(lock.packages_dev.iter().flatten()) + { + map.insert(pkg.name.to_lowercase(), locked_version_normalized(pkg)); + } + map + } + Err(_) => IndexMap::new(), + } + } else { + IndexMap::new() + }; + let request = ResolveRequest { root_name: composer_json.name.clone(), root_version: composer_json.version.clone(), @@ -1159,6 +1183,7 @@ pub async fn run( locked_packages, block_abandoned, root_branch_alias: extract_root_branch_alias(&composer_json), + preferred_versions, }; // Step 6: Print header and run resolver @@ -1294,13 +1319,8 @@ pub async fn run( resolved = apply_partial_update(resolved, lock, &update_packages); } } - } else if args.minimal_changes && update_packages.is_empty() { - // Full update with --minimal-changes: pin everything to locked versions - // (only updates packages whose constraints have changed in composer.json) - if let Some(ref lock) = old_lock { - console.info("Minimal changes mode: preserving locked versions where possible."); - resolved = apply_minimal_changes(resolved, lock); - } + } else if args.minimal_changes && update_packages.is_empty() && old_lock.is_some() { + console.info("Minimal changes mode: preserving locked versions where possible."); } // Apply --patch-only filter: restrict updates to patch-level changes only @@ -2298,6 +2318,7 @@ mod tests { locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, + preferred_versions: IndexMap::new(), }; let resolved = resolve(&request).await.expect("Resolution should succeed"); diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index efd0aa2..e9f2749 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -245,7 +245,7 @@ installer_fixture!(conflict_with_alias_prevents_update_if_not_required); installer_fixture!(conflict_with_all_dependencies_option_dont_recommend_to_use_it); installer_fixture!(deduplicate_solver_problems); installer_fixture!(disjunctive_multi_constraints); -installer_fixture!(full_update_minimal_changes, ignore); +installer_fixture!(full_update_minimal_changes); installer_fixture!(github_issues_4319); installer_fixture!(github_issues_4795); installer_fixture!(github_issues_4795_2); -- cgit v1.3.1