aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 16:45:12 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 16:45:12 +0900
commit766fec6ed7b3610126abe3619c6c5f98f393d937 (patch)
tree9afba9bd4e305c1836785bd1014d79eb0eac6390 /crates/mozart/src
parent9eecfe303c944c80556b0b25fcd3ce6bbce3aeb8 (diff)
downloadphp-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')
-rw-r--r--crates/mozart/src/commands/install.rs31
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