aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-03 12:32:10 +0900
committernsfisis <nsfisis@gmail.com>2026-05-03 12:32:10 +0900
commit2af684c6397001944c9d9aac20ca59677d6a9650 (patch)
tree054f40875ca547223c36f19e876269baadb1bf6f /crates
parentab2772b8c85139df7d5e625ac5262d385e5ab4c0 (diff)
downloadphp-mozart-2af684c6397001944c9d9aac20ca59677d6a9650.tar.gz
php-mozart-2af684c6397001944c9d9aac20ca59677d6a9650.tar.zst
php-mozart-2af684c6397001944c9d9aac20ca59677d6a9650.zip
fix(install): emit MarkAliasUninstalled and fix dev-branch upgrade direction
Two pieces of Composer's update-trace machinery were missing: 1. VersionParser::isUpgrade in Composer\Package\Version (which overrides the upstream Semver one) substitutes dev-master / dev-trunk / dev-default with the 9999999-dev default-branch alias, then returns true whenever either side starts with `dev-`. Mozart's is_upgrade compared via the generic version order, so dev-master → dev-foo came out as Downgrading. Port the override. 2. Transaction::calculateOperations seeds removeAliasMap from the currently-installed AliasPackages and emits MarkAliasUninstalled for every entry not covered by the new lock. Mozart never emitted those, so updating away from a branch-aliased package produced no trace line for the alias retirement. Walk installed.json's `extra.branch-alias` map, compare against the new lock's aliases[] block, and emit a MarkAliasUninstalled PackageOperation (a new variant on the executor surface — no filesystem effects, only the trace recorder cares). Unblocks update_alias, update_alias_lock2, and update_no_dev_still_resolves_dev 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/filesystem.rs9
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs43
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs52
-rw-r--r--crates/mozart/src/commands/install.rs82
-rw-r--r--crates/mozart/tests/installer.rs6
5 files changed, 172 insertions, 20 deletions
diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs
index cceb5da..cb1a2cc 100644
--- a/crates/mozart-registry/src/installer_executor/filesystem.rs
+++ b/crates/mozart-registry/src/installer_executor/filesystem.rs
@@ -29,10 +29,11 @@ impl InstallerExecutor for FilesystemExecutor {
op: PackageOperation<'_>,
ctx: &ExecuteContext,
) -> anyhow::Result<()> {
- // Marking an alias as installed has no filesystem side effects —
- // the target package's files are already in vendor/. Mirrors
- // Composer's `MarkAliasInstalledOperation` which the installation
- // manager only uses to update the in-memory installed repository.
+ // Marking an alias as installed/uninstalled has no filesystem side
+ // effects — the target package's files are already in vendor/.
+ // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the
+ // installation manager only uses to update the in-memory installed
+ // repository.
let Some(pkg) = op.package() else {
return Ok(());
};
diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs
index a774490..c344ba4 100644
--- a/crates/mozart-registry/src/installer_executor/mod.rs
+++ b/crates/mozart-registry/src/installer_executor/mod.rs
@@ -54,6 +54,23 @@ pub enum PackageOperation<'a> {
/// reference suffix for the trace line.
target: &'a LockedPackage,
},
+ /// Mark a previously-installed alias as uninstalled. No filesystem
+ /// effects — only the trace recorder cares. Mirrors Composer's
+ /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage
+ /// from the previous installed.json entries (via `extra.branch-alias`),
+ /// then emits this when the alias is no longer in the result. Caller
+ /// pre-renders the display strings so this variant doesn't need to know
+ /// how to spelunk the entry.
+ MarkAliasUninstalled {
+ /// Package name (e.g. `a/a`) used as both the alias's name and the
+ /// target's name on the trace line.
+ name: &'a str,
+ /// Alias's full-pretty form (alias pretty version plus reference
+ /// suffix), e.g. `1.0.x-dev master`.
+ alias_full: &'a str,
+ /// Target's full-pretty form, e.g. `dev-master master`.
+ target_full: &'a str,
+ },
}
impl<'a> PackageOperation<'a> {
@@ -62,7 +79,8 @@ impl<'a> PackageOperation<'a> {
PackageOperation::Install { package } | PackageOperation::Update { package, .. } => {
Some(package)
}
- PackageOperation::MarkAliasInstalled { .. } => None,
+ PackageOperation::MarkAliasInstalled { .. }
+ | PackageOperation::MarkAliasUninstalled { .. } => None,
}
}
}
@@ -92,11 +110,14 @@ pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage)
)
}
-/// 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 {
+/// Same as [`format_full_pretty_version_for_installed`] but lets the caller
+/// supply an alternate pretty version. Used when emitting
+/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered
+/// with the *target installed entry's* reference suffix.
+pub fn format_full_pretty_with_pretty_for_installed(
+ pretty_version: &str,
+ entry: &InstalledPackageEntry,
+) -> String {
let source_ref = entry
.source
.as_ref()
@@ -113,7 +134,7 @@ pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -
.and_then(|v| v.get("type"))
.and_then(|v| v.as_str());
format_full_pretty_with_refs(
- &entry.version,
+ pretty_version,
&entry.version,
source_ref,
dist_ref,
@@ -121,6 +142,14 @@ pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -
)
}
+/// 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 {
+ format_full_pretty_with_pretty_for_installed(&entry.version, entry)
+}
+
/// Render the from/to display strings for an update trace line, mirroring
/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`,
/// then if both sides render identically:
diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
index 5c49160..159d854 100644
--- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs
+++ b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
@@ -91,6 +91,16 @@ impl InstallerExecutor for TraceRecorderExecutor {
alias.package, alias_full, alias.package, target_full
));
}
+ PackageOperation::MarkAliasUninstalled {
+ name,
+ alias_full,
+ target_full,
+ } => {
+ self.trace.push(format!(
+ "Marking {} ({}) as uninstalled, alias of {} ({})",
+ name, alias_full, name, target_full
+ ));
+ }
}
Ok(())
}
@@ -106,12 +116,44 @@ impl InstallerExecutor for TraceRecorderExecutor {
}
}
-/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade` — returns
-/// true when `to` is a strictly higher version than `from`. Both unparseable
-/// or both equal means treat as upgrade (Composer's behavior on edge cases).
+/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade`. Returns true
+/// when `to` should be treated as an upgrade from `from` for the purpose of
+/// the trace verb (`Upgrading` vs `Downgrading`).
+///
+/// The rules:
+/// 1. Same string → upgrade.
+/// 2. `dev-master` / `dev-trunk` / `dev-default` substitute to the
+/// `9999999-dev` default-branch alias before further checks (they are
+/// not literal dev-* names; they are the conventional "latest" branch).
+/// 3. After that substitution, if either side starts with `dev-` (i.e. is
+/// a dev branch other than the defaults) → upgrade. Composer treats
+/// hopping between dev branches as a forward move regardless of order.
+/// 4. Otherwise sort numerically and check the original `from` ended up
+/// first (= the smaller value).
fn is_upgrade(from: &str, to: &str) -> bool {
- match (Version::parse(from), Version::parse(to)) {
+ if from == to {
+ return true;
+ }
+ let original_from = from;
+ let normalize_default = |s: &str| -> String {
+ if matches!(s, "dev-master" | "dev-trunk" | "dev-default") {
+ "9999999-dev".to_string()
+ } else {
+ s.to_string()
+ }
+ };
+ let from_norm = normalize_default(from);
+ let to_norm = normalize_default(to);
+ if from_norm.starts_with("dev-") || to_norm.starts_with("dev-") {
+ return true;
+ }
+ match (Version::parse(&from_norm), Version::parse(&to_norm)) {
(Ok(a), Ok(b)) => b >= a,
- _ => true,
+ _ => {
+ // Mirror Composer's fall-through: with two unparseable strings
+ // there is nothing to compare, treat the move as an upgrade.
+ let _ = original_from;
+ true
+ }
}
}
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index 7aac3af..c7caff4 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -5,7 +5,8 @@ use mozart_core::console_format;
use mozart_registry::installed;
use mozart_registry::installer_executor::{
ExecuteContext, FilesystemExecutor, InstallerExecutor, PackageOperation,
- format_full_pretty_version, format_update_pretty_versions,
+ format_full_pretty_version, format_full_pretty_version_for_installed,
+ format_full_pretty_with_pretty_for_installed, format_update_pretty_versions,
};
use mozart_registry::lockfile;
use std::collections::BTreeMap;
@@ -329,6 +330,64 @@ fn topological_sort<'a>(
ordered
}
+/// Pre-rendered MarkAliasUninstalled operation. Composer derives the alias
+/// from the installed package's `extra.branch-alias` map and the comparison
+/// runs against the new lock's `aliases[]` block; we precompute the trace
+/// strings here so the executor call site can stay simple.
+struct StaleInstalledAlias {
+ name: String,
+ alias_full: String,
+ target_full: String,
+}
+
+/// Walk every `installed.json` entry, expand its `extra.branch-alias` map
+/// into `(target_branch_pretty → alias_pretty)` pairs, and emit a
+/// [`StaleInstalledAlias`] for each pair whose alias version doesn't appear
+/// in the new lock's `aliases[]` block under the same package. Mirrors
+/// Composer's `Transaction::calculateOperations`, which seeds `removeAliasMap`
+/// from the present alias packages and trims it as the result is walked —
+/// whatever's left becomes a `MarkAliasUninstalledOperation`.
+fn collect_stale_installed_aliases(
+ installed: &installed::InstalledPackages,
+ lock_aliases: &[lockfile::LockAlias],
+) -> Vec<StaleInstalledAlias> {
+ let mut stale = Vec::new();
+ for entry in &installed.packages {
+ let Some(branch_alias) = entry
+ .extra_fields
+ .get("extra")
+ .and_then(|e| e.get("branch-alias"))
+ .and_then(|b| b.as_object())
+ else {
+ continue;
+ };
+ for (target_branch, alias_value) in branch_alias {
+ // The map key is the branch name (e.g. `dev-master`); only the
+ // alias for the *currently installed* version applies.
+ if entry.version != *target_branch {
+ continue;
+ }
+ let Some(alias_pretty) = alias_value.as_str() else {
+ continue;
+ };
+ // Already covered by the new lock under the same package +
+ // alias version → not stale.
+ let still_present = lock_aliases
+ .iter()
+ .any(|a| a.package.eq_ignore_ascii_case(&entry.name) && a.alias == alias_pretty);
+ if still_present {
+ continue;
+ }
+ stale.push(StaleInstalledAlias {
+ name: entry.name.clone(),
+ alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry),
+ target_full: format_full_pretty_version_for_installed(entry),
+ });
+ }
+ }
+ stale
+}
+
/// 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
@@ -670,6 +729,27 @@ pub async fn install_from_lock(
executor.uninstall_package(name, from_version, &exec_ctx)?;
}
+ // Mirror Composer's `Transaction::moveUninstallsToFront` +
+ // `MarkAliasUninstalledOperation` emission: any alias declared on a
+ // currently-installed package (via `extra.branch-alias`) that is no
+ // longer present in the new lock's `aliases[]` block needs a trace
+ // line so consumers see the alias was retired alongside its target.
+ // Detection runs before installs/updates since Composer hoists alias
+ // uninstalls to the front of the operations list.
+ let stale_aliases = collect_stale_installed_aliases(&installed, &lock.aliases);
+ for stale in &stale_aliases {
+ executor
+ .install_package(
+ PackageOperation::MarkAliasUninstalled {
+ name: &stale.name,
+ alias_full: &stale.alias_full,
+ target_full: &stale.target_full,
+ },
+ &exec_ctx,
+ )
+ .await?;
+ }
+
if !removals.is_empty() {
executor.cleanup_after_uninstalls(&exec_ctx)?;
}
diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs
index 07e69d2..0cf7e84 100644
--- a/crates/mozart/tests/installer.rs
+++ b/crates/mozart/tests/installer.rs
@@ -366,9 +366,9 @@ installer_fixture!(
update_abandoned_package_required_but_blocked_via_audit_config,
ignore
);
-installer_fixture!(update_alias, ignore);
+installer_fixture!(update_alias);
installer_fixture!(update_alias_lock, ignore);
-installer_fixture!(update_alias_lock2, ignore);
+installer_fixture!(update_alias_lock2);
installer_fixture!(update_all);
installer_fixture!(update_all_dry_run);
installer_fixture!(update_allow_list);
@@ -408,7 +408,7 @@ installer_fixture!(update_installed_reference);
installer_fixture!(update_installed_reference_dry_run);
installer_fixture!(update_mirrors_changes_url, ignore);
installer_fixture!(update_mirrors_fails_with_new_req, ignore);
-installer_fixture!(update_no_dev_still_resolves_dev, ignore);
+installer_fixture!(update_no_dev_still_resolves_dev);
installer_fixture!(update_no_install);
installer_fixture!(update_package_present_in_lock_but_not_at_all_in_remote);
installer_fixture!(update_package_present_in_lock_but_not_in_remote);