diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-03 16:45:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-03 16:45:12 +0900 |
| commit | 766fec6ed7b3610126abe3619c6c5f98f393d937 (patch) | |
| tree | 9afba9bd4e305c1836785bd1014d79eb0eac6390 /crates/mozart/src/commands | |
| parent | 9eecfe303c944c80556b0b25fcd3ce6bbce3aeb8 (diff) | |
| download | php-mozart-766fec6ed7b3610126abe3619c6c5f98f393d937.tar.gz php-mozart-766fec6ed7b3610126abe3619c6c5f98f393d937.tar.zst php-mozart-766fec6ed7b3610126abe3619c6c5f98f393d937.zip | |
fix(install): reinstall when locked package's abandoned flag drifts
Composer's Transaction::calculateOperations fires an UpdateOperation on
same-version packages when isAbandoned() or getReplacementPackage() shifts
between installed and locked, so vendor/composer/installed.json picks up
the refreshed metadata. Mozart only checked source/dist references and
treated the abandon-flag drift as a no-op skip.
Mirror the canonical bool/string reduction Composer uses (false/null →
not abandoned, true → abandoned without replacement, string → abandoned
with that replacement) so the check is symmetric across the lock and
installed.json shapes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands')
| -rw-r--r-- | crates/mozart/src/commands/install.rs | 31 |
1 files changed, 30 insertions, 1 deletions
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 52e82ec..b41e91a 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -196,8 +196,11 @@ pub fn compute_operations<'a>( Some(entry) if entry.version != pkg.version => Action::Update, // Same version present — Composer's Transaction also fires an // UpdateOperation when the source/dist reference moved (e.g. a - // root require pinned a new commit via `dev-main#abcd`). + // root require pinned a new commit via `dev-main#abcd`), or + // when the `abandoned` flag / replacement target drifted (so + // installed.json picks up the fresh metadata). Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update, + Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update, Some(_) => Action::Skip, }; ops.push((pkg, action)); @@ -412,6 +415,32 @@ fn installed_refs_match_locked( installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref } +/// Reduce a serialized `abandoned` value to the (isAbandoned, replacement) +/// pair Composer compares in `Transaction::calculateOperations`: +/// `isAbandoned()` is the truthy cast of the field, and +/// `getReplacementPackage()` is the field itself when it's a string, else +/// null. Missing / `false` / `null` collapse to "not abandoned"; `true` is +/// abandoned with no replacement; a string is both. +fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { + match v { + Some(serde_json::Value::Bool(b)) => (*b, None), + Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), + _ => (false, None), + } +} + +/// Mirror the `isAbandoned()` / `getReplacementPackage()` leg of Composer's +/// same-version update check: when an installed package's `abandoned` flag +/// (or its replacement target) drifts from the lock, fire an UpdateOperation +/// so vendor/composer/installed.json is rewritten with the fresh value. +fn installed_abandoned_matches_locked( + entry: &installed::InstalledPackageEntry, + locked: &lockfile::LockedPackage, +) -> bool { + abandoned_state(entry.extra_fields.get("abandoned")) + == abandoned_state(locked.extra_fields.get("abandoned")) +} + /// Convert a LockedPackage to an InstalledPackageEntry. /// /// `LockedPackage::extra_fields` is forwarded verbatim so flags like |
