From e37b12d6e2d95b4d3924859732513e125fc552e0 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 3 May 2026 23:31:31 +0900 Subject: feat(registry): support type: path repositories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `mozart-php-serialize` crate (a byte-compatible port of PHP's `serialize()`) and a `mozart-registry::path_repository` module that expands `type: path` entries into synthetic `type: package` repositories. Each synthesized package carries the same SHA-1 dist reference Composer computes (`sha1(\$json . serialize(\$options))`) so the lockfile and trace lines match Composer byte-for-byte. Two latent bugs surfaced once the path-repo flow exercised real resolutions: - `apply_partial_update` swapped path-repo packages back to their locked version, defeating Composer's "path repos always reload" rule (`PoolBuilder` treats them as canonical, not lock-bound). Mirror the path-repo skip already used when constructing `locked_packages`. - `normalize_root_alias_atom` returned the raw input string for stable numeric atoms (e.g. `1.1.1`), so the alias matcher's `input.version \!= alias.version_normalized` check — comparing against pool inputs that carry the 4-segment normalized form — silently never matched. Run the parsed Version through Display so both sides are in the same shape. `install/update::run` gain a `path_repo_base_override: Option<&Path>` parameter for the in-process test harness: Composer's PHPUnit `InstallerTest::setUp` does `chdir(__DIR__)` so relative path-repo URLs resolve against `composer/tests/Composer/Test/`, but the Rust harness writes `composer.json` into a per-test tempdir and can't chdir safely under parallel tests. Production callers pass `None` and resolve against `working_dir`. Greens 3 ignored installer fixtures: partial_update_loads_root_aliases_for_path_repos alias_in_lock alias_in_lock2 --- crates/mozart/src/commands/install.rs | 26 ++++++++++++++++--- crates/mozart/src/commands/update.rs | 49 +++++++++++++++++++++++++++++++---- 2 files changed, 67 insertions(+), 8 deletions(-) (limited to 'crates/mozart/src/commands') diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index ba9bd8a..a5698ff 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -1325,7 +1325,15 @@ pub async fn execute( )); let mut executor = FilesystemExecutor::new(mozart_registry::cache::Cache::files(&cache_config)); let working_dir = resolve_working_dir(cli); - run(&working_dir, args, console, repositories, &mut executor).await + run( + &working_dir, + None, + args, + console, + repositories, + &mut executor, + ) + .await } /// Library entry point — pure logic, no `Cli` access. @@ -1334,8 +1342,13 @@ pub async fn execute( /// `'packagist' => false` test config) and a tracing `InstallerExecutor`, /// then call this function directly to exercise the install flow without /// spawning the binary. +/// +/// `path_repo_base_override` is the in-process test escape hatch for relative +/// `type: path` repo URLs — see [`super::update::run`] for the full rationale. +/// Production callers pass `None` to anchor against `working_dir`. pub async fn run( working_dir: &Path, + path_repo_base_override: Option<&Path>, args: &InstallArgs, console: &mozart_core::console::Console, repositories: std::sync::Arc, @@ -1420,8 +1433,15 @@ pub async fn run( }; // Forward the caller's repositories + executor so in-process tests // see their mocks honored across the install→update fallback edge. - return super::update::run(working_dir, &update_args, console, repositories, executor) - .await; + return super::update::run( + working_dir, + path_repo_base_override, + &update_args, + console, + repositories, + executor, + ) + .await; } let lock = lockfile::LockFile::read_from_file(&lock_path)?; diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index f065c2a..ac5a685 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -395,8 +395,15 @@ pub fn apply_partial_update( let name_lower = pkg.name.to_lowercase(); // If this package is NOT in the update set and we have an old locked version, // swap it back to the old version to prevent unintended changes. + // + // Exception: path-repo packages always reload from disk (Composer's + // PoolBuilder treats them as canonical sources, not lock-bound), so + // the resolver-picked version must survive the partial-update + // swap-back. The earlier locked-set construction already excludes + // them from `locked_packages` for the same reason; mirror it here. if !update_set.contains(&name_lower) && let Some(old_pkg) = old_pkg_map.get(&name_lower) + && old_pkg.dist.as_ref().map(|d| d.dist_type.as_str()) != Some("path") { pkg.version = old_pkg.version.clone(); pkg.version_normalized = locked_version_normalized(old_pkg); @@ -964,7 +971,15 @@ pub async fn execute( mozart_registry::cache::Cache::files(&cache_config), ); let working_dir = super::install::resolve_working_dir(cli); - run(&working_dir, args, console, repositories, &mut executor).await + run( + &working_dir, + None, + args, + console, + repositories, + &mut executor, + ) + .await } /// Library entry point — pure logic, no CLI / Cli access. @@ -973,8 +988,17 @@ pub async fn execute( /// (Composer's `'packagist' => false` test config) and a tracing /// `InstallerExecutor`, then call this function directly to exercise the /// update flow without spawning the binary. +/// +/// `path_repo_base_override` is for the in-process test harness only: +/// Composer's PHP test suite `chdir(__DIR__)` so that `type: path` repo URLs +/// like `Fixtures/.../pkg` resolve against the test directory, but the +/// Rust harness writes `composer.json` into a per-test tempdir, so we need a +/// way to anchor relative path-repo URLs somewhere other than `working_dir`. +/// Production callers pass `None` to use `working_dir`, matching Composer's +/// "resolve relative to cwd" behaviour. pub async fn run( working_dir: &std::path::Path, + path_repo_base_override: Option<&std::path::Path>, args: &UpdateArgs, console: &mozart_core::console::Console, repositories: std::sync::Arc, @@ -1012,6 +1036,21 @@ pub async fn run( composer_json.validate_root_does_not_self_require()?; let composer_json_content = std::fs::read_to_string(&composer_json_path)?; + // Expand `type: path` repos into synthetic `type: package` entries so the + // resolver and lockfile see them as ordinary inline packages. The + // original `composer_json.repositories` is preserved for writeback paths + // (e.g. `--bump-after-update` rewrites composer.json) — only the cloned + // `composer_json_expanded` carries the synthetic entries. + let path_repo_base = path_repo_base_override.unwrap_or(working_dir); + let composer_json_expanded = { + let mut clone = composer_json.clone(); + clone.repositories = mozart_registry::path_repository::expand_path_repositories( + &clone.repositories, + path_repo_base, + ); + clone + }; + let lock_path = working_dir.join("composer.lock"); let vendor_dir = working_dir.join("vendor"); @@ -1135,7 +1174,7 @@ pub async fn run( // (line 524: when a propagated package's `require` // points at a `skippedLoad` entry, the dep is unlocked // and re-loaded). - let repo_requires = collect_repo_requires(&composer_json.repositories); + let repo_requires = collect_repo_requires(&composer_json_expanded.repositories); let updated: IndexSet = expand_packages( &raw_packages, Some(&l), @@ -1334,7 +1373,7 @@ pub async fn run( ignore_platform_req_list: args.ignore_platform_req.clone(), repositories: repositories.clone(), temporary_constraints, - raw_repositories: composer_json.repositories.clone(), + raw_repositories: composer_json_expanded.repositories.clone(), root_provide: composer_json .provide .iter() @@ -1428,7 +1467,7 @@ pub async fn run( } Some(lock) => { // 1. Expand wildcards - let repo_requires = collect_repo_requires(&composer_json.repositories); + let repo_requires = collect_repo_requires(&composer_json_expanded.repositories); let mut expanded = expand_packages( &effective_packages, Some(lock), @@ -1509,7 +1548,7 @@ pub async fn run( let mut new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { resolved_packages: resolved, composer_json_content: composer_json_content.clone(), - composer_json: composer_json.clone(), + composer_json: composer_json_expanded.clone(), include_dev: true, repositories: repositories.clone(), previous_lock: old_lock.clone(), -- cgit v1.3.1