aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 12:10:38 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 12:10:38 +0900
commitdffb6244ebb432477b83631d68584bbc7186dd94 (patch)
tree4be972cb21de544c53108a244ba4288f4467d544 /crates
parented77ff97d6c137ef58f0464b7a9b08bc2b875bd2 (diff)
downloadphp-mozart-dffb6244ebb432477b83631d68584bbc7186dd94.tar.gz
php-mozart-dffb6244ebb432477b83631d68584bbc7186dd94.tar.zst
php-mozart-dffb6244ebb432477b83631d68584bbc7186dd94.zip
fix(install): treat dev-reference shifts as upgrades
Composer's Transaction fires an UpdateOperation when an installed package's source/dist reference moved, even if the version string is unchanged — that is how a `dev-main#abcd` root require pinning a new commit propagates through `composer install`. Mozart was checking only (name, version) and short-circuiting to Skip, so the package stayed pinned to whatever reference installed.json carried. Compare references in compute_operations and route mismatches into Action::Update. The trace recorder needs the from-side display string to include the reference suffix (`dev-master abc123`) so the EXPECT output matches Composer's UpdateOperation::format; thread that through PackageOperation::Update as a separate from_full_pretty field while keeping from_version (sans suffix) for the upgrade-vs-downgrade direction check, which has to compare normalized versions like Composer's VersionParser::isUpgrade does. Unblocks update_reference, update_reference_picks_latest, and updating_dev_updates_url_and_reference installer fixtures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs65
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs3
-rw-r--r--crates/mozart/src/commands/install.rs67
-rw-r--r--crates/mozart/tests/installer.rs6
4 files changed, 118 insertions, 23 deletions
diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs
index ff3d7a8..704031f 100644
--- a/crates/mozart-registry/src/installer_executor/mod.rs
+++ b/crates/mozart-registry/src/installer_executor/mod.rs
@@ -15,6 +15,7 @@
use std::path::PathBuf;
+use crate::installed::InstalledPackageEntry;
use crate::lockfile::{LockAlias, LockedPackage};
pub mod filesystem;
@@ -30,9 +31,13 @@ pub enum PackageOperation<'a> {
/// `package.dist`/`package.source`.
Install { package: &'a LockedPackage },
/// Replace an existing install with a new version. `from_version` is the
- /// pretty version that was installed before.
+ /// pretty version that was installed before (no reference suffix —
+ /// drives the upgrade-vs-downgrade direction). `from_full_pretty` is the
+ /// formatted display string (`dev-master abc123`) used verbatim in the
+ /// trace output.
Update {
from_version: &'a str,
+ from_full_pretty: &'a str,
package: &'a LockedPackage,
},
/// Mark an alias of a real package as installed. No filesystem effects —
@@ -72,15 +77,65 @@ pub fn format_full_pretty_version(pkg: &LockedPackage) -> String {
/// alternate pretty version (used by `MarkAliasInstalled` so the alias's
/// `3.2.x-dev` text is rendered with the *target's* reference).
pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String {
- let is_dev = mozart_semver::Version::parse(&pkg.version)
+ let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref());
+ let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
+ let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str());
+ format_full_pretty_with_refs(
+ pretty_version,
+ &pkg.version,
+ source_ref,
+ dist_ref,
+ source_type,
+ )
+}
+
+/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an
+/// `InstalledPackageEntry`. Same display rules as
+/// [`format_full_pretty_version`] but pulls source/dist info out of the
+/// installed.json `source`/`dist` JSON values.
+pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String {
+ let source_ref = entry
+ .source
+ .as_ref()
+ .and_then(|v| v.get("reference"))
+ .and_then(|v| v.as_str());
+ let dist_ref = entry
+ .dist
+ .as_ref()
+ .and_then(|v| v.get("reference"))
+ .and_then(|v| v.as_str());
+ let source_type = entry
+ .source
+ .as_ref()
+ .and_then(|v| v.get("type"))
+ .and_then(|v| v.as_str());
+ format_full_pretty_with_refs(
+ &entry.version,
+ &entry.version,
+ source_ref,
+ dist_ref,
+ source_type,
+ )
+}
+
+/// Core of `BasePackage::getFullPrettyVersion()` factored over raw
+/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share
+/// the rendering logic. `version` drives the dev-stability check; the result
+/// is `pretty_version` plus a reference suffix when the package is a dev
+/// branch backed by git/hg (with sha1 references truncated to 7 chars).
+fn format_full_pretty_with_refs(
+ pretty_version: &str,
+ version: &str,
+ source_ref: Option<&str>,
+ dist_ref: Option<&str>,
+ source_type: Option<&str>,
+) -> String {
+ let is_dev = mozart_semver::Version::parse(version)
.map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch)
.unwrap_or(false);
if !is_dev {
return pretty_version.to_string();
}
- let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref());
- let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
- let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str());
// Composer falls back to dist reference only when no source type is set
// (or the package isn't git/hg — in which case the dev display is skipped
// entirely above).
diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
index 44fceea..785d161 100644
--- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs
+++ b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
@@ -69,6 +69,7 @@ impl InstallerExecutor for TraceRecorderExecutor {
}
PackageOperation::Update {
from_version,
+ from_full_pretty,
package,
} => {
let action = if is_upgrade(from_version, &package.version) {
@@ -80,7 +81,7 @@ impl InstallerExecutor for TraceRecorderExecutor {
"{} {} ({} => {})",
action,
package.name,
- from_version,
+ from_full_pretty,
format_full_pretty_version(package)
));
}
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index e53b6d1..f809a86 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -5,6 +5,7 @@ use mozart_core::console_format;
use mozart_registry::installed;
use mozart_registry::installer_executor::{
ExecuteContext, FilesystemExecutor, InstallerExecutor, PackageOperation,
+ format_full_pretty_version_for_installed,
};
use mozart_registry::lockfile;
use std::collections::BTreeMap;
@@ -185,17 +186,20 @@ pub fn compute_operations<'a>(
let mut ops: Vec<(&'a lockfile::LockedPackage, Action)> = Vec::new();
for pkg in ordered {
- if installed.is_installed(&pkg.name, &pkg.version) {
- ops.push((pkg, Action::Skip));
- } else if installed
+ let installed_entry = installed
.packages
.iter()
- .any(|p| p.name.eq_ignore_ascii_case(&pkg.name))
- {
- ops.push((pkg, Action::Update));
- } else {
- ops.push((pkg, Action::Install));
- }
+ .find(|p| p.name.eq_ignore_ascii_case(&pkg.name));
+ let action = match installed_entry {
+ None => Action::Install,
+ 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`).
+ Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update,
+ Some(_) => Action::Skip,
+ };
+ ops.push((pkg, action));
}
// Compute removals: packages in installed but not in locked
@@ -325,6 +329,30 @@ fn topological_sort<'a>(
ordered
}
+/// Compare an installed-package entry's source/dist references with a
+/// locked package's. Mirrors the reference-equality leg of Composer's
+/// `Transaction::calculateOperations` update-detection: a same-version
+/// install is upgraded (or downgraded) when either reference has shifted,
+/// so users who pinned a new commit via `dev-main#abcd` see the move.
+fn installed_refs_match_locked(
+ entry: &installed::InstalledPackageEntry,
+ locked: &lockfile::LockedPackage,
+) -> bool {
+ let installed_source_ref = entry
+ .source
+ .as_ref()
+ .and_then(|v| v.get("reference"))
+ .and_then(|v| v.as_str());
+ let installed_dist_ref = entry
+ .dist
+ .as_ref()
+ .and_then(|v| v.get("reference"))
+ .and_then(|v| v.as_str());
+ let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref());
+ let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref());
+ installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref
+}
+
/// Convert a LockedPackage to an InstalledPackageEntry.
///
/// `LockedPackage::extra_fields` is forwarded verbatim so flags like
@@ -647,6 +675,10 @@ pub async fn install_from_lock(
}
for (pkg, action) in &ops {
+ // Owned scratch buffer the Update branch borrows for
+ // `PackageOperation::Update::from_version`. Declared at loop
+ // scope so the borrow outlives the await call.
+ let from_version_buf;
let op = match action {
Action::Skip => continue,
Action::Install => {
@@ -665,15 +697,22 @@ pub async fn install_from_lock(
));
// Pull the previously-installed version from installed.json
// so the trace recorder can format
- // `Upgrading pkg (oldVersion => newVersion)`.
- let from_version = installed
+ // `Upgrading pkg (oldVersion => newVersion)`. The plain
+ // version drives the upgrade/downgrade direction; the
+ // full-pretty form (with the dev reference suffix) is
+ // what shows up in the trace, mirroring Composer's
+ // `UpdateOperation::format`.
+ let from_entry = installed
.packages
.iter()
- .find(|p| p.name.eq_ignore_ascii_case(&pkg.name))
- .map(|p| p.version.as_str())
- .unwrap_or("");
+ .find(|p| p.name.eq_ignore_ascii_case(&pkg.name));
+ let from_version = from_entry.map(|p| p.version.as_str()).unwrap_or("");
+ from_version_buf = from_entry
+ .map(format_full_pretty_version_for_installed)
+ .unwrap_or_default();
PackageOperation::Update {
from_version,
+ from_full_pretty: &from_version_buf,
package: pkg,
}
}
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index a0eaab0..93c3496 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -416,8 +416,8 @@ installer_fixture!(update_package_present_in_lock_but_not_in_remote_due_to_min_s
installer_fixture!(update_package_present_in_lower_repo_prio_but_not_main_due_to_min_stability);
installer_fixture!(update_picks_up_change_of_vcs_type, ignore);
installer_fixture!(update_prefer_lowest_stable);
-installer_fixture!(update_reference, ignore);
-installer_fixture!(update_reference_picks_latest, ignore);
+installer_fixture!(update_reference);
+installer_fixture!(update_reference_picks_latest);
installer_fixture!(update_removes_unused_locked_dep, ignore);
installer_fixture!(update_requiring_decision_reverts_and_learning_positive_literals);
installer_fixture!(update_security_advisory_matching_direct_dependency, ignore);
@@ -431,4 +431,4 @@ installer_fixture!(update_to_empty_from_locked, ignore);
installer_fixture!(update_with_all_dependencies);
installer_fixture!(update_without_lock);
installer_fixture!(updating_dev_from_lock_removes_old_deps, ignore);
-installer_fixture!(updating_dev_updates_url_and_reference, ignore);
+installer_fixture!(updating_dev_updates_url_and_reference);