From 766fec6ed7b3610126abe3619c6c5f98f393d937 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 16:45:12 +0900 Subject: fix(install): reinstall when locked package's abandoned flag drifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/mozart/src/commands/install.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) (limited to 'crates/mozart/src') 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 -- cgit v1.3.1