diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 22:07:34 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 22:07:34 +0900 |
| commit | 38706a6f0ceb773d473c4f5ddebf49e8e5ae46dc (patch) | |
| tree | a38de4fbf27e6834eae3944bbd86ce53b13236cb /crates | |
| parent | 64f8bb0c1aa16d78c5edc3f3de5dd3ff6e5861de (diff) | |
| download | php-mozart-38706a6f0ceb773d473c4f5ddebf49e8e5ae46dc.tar.gz php-mozart-38706a6f0ceb773d473c4f5ddebf49e8e5ae46dc.tar.zst php-mozart-38706a6f0ceb773d473c4f5ddebf49e8e5ae46dc.zip | |
fix(update): apply --minimal-changes via policy preferred versions
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.
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 22 | ||||
| -rw-r--r-- | crates/mozart-sat-resolver/src/policy.rs | 41 | ||||
| -rw-r--r-- | crates/mozart/src/commands/create_project.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 4 | ||||
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 3 | ||||
| -rw-r--r-- | crates/mozart/src/commands/update.rs | 35 | ||||
| -rw-r--r-- | crates/mozart/tests/installer.rs | 2 |
8 files changed, 99 insertions, 10 deletions
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<String>, + /// `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<String, String>, } /// Full data for a lock-pinned package, used in partial updates. Carried on @@ -1337,8 +1342,20 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, 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<u32> = 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<IndexMap<String, String>>, } 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<String, String>, + ) -> 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<Literal> = 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<String, String> = + 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); |
