diff --git a/crates/socket-patch-cli/src/commands/vendor.rs b/crates/socket-patch-cli/src/commands/vendor.rs index 6dedfaf7e..8cc2b1a63 100644 --- a/crates/socket-patch-cli/src/commands/vendor.rs +++ b/crates/socket-patch-cli/src/commands/vendor.rs @@ -2537,15 +2537,25 @@ impl StagedSource { /// neither the pristine release nor this patch's output. The re-vendor /// then takes the pristine artifact from the lock / registry / service, as /// a lock-only checkout does, instead of reporting it not installed. +/// +/// A PyPI install under a live hosted pin (`hosted_pinned`) is the same +/// story after a hosted scan (#1105): `uv sync` / `poetry install` from +/// the hosted lock installs a hosted patch's bytes, whichever generation +/// the pin names now. The pin names no release variant, so it only answers +/// for a sole candidate; gems keep their installed dir, which the gem +/// backend reads to refuse a platform gem. fn superseded_install( ledger: &VendorState, candidate: &str, record: &PatchRecord, sole_candidate: bool, + hosted_pinned: bool, ) -> bool { lookup_entry_kv(&ledger.entries, candidate).is_some_and(|(key, entry)| { entry.uuid != record.uuid && (sole_candidate || key == candidate) - }) + }) || (sole_candidate + && hosted_pinned + && matches!(Ecosystem::from_purl(candidate), Some(Ecosystem::Pypi))) } #[allow(clippy::too_many_arguments)] @@ -2605,8 +2615,13 @@ async fn plan_service_downloads( if !variant_matches_installed(Some( &verify_file_patch(dir, file, info).await.status, )) { - if !superseded_install(ledger, candidate, record, candidates.len() == 1) - { + if !superseded_install( + ledger, + candidate, + record, + candidates.len() == 1, + takeover_blocked(candidate), + ) { continue; } source_path = &uninstalled; @@ -3093,8 +3108,13 @@ pub(crate) async fn vendor_records_reusing( .await .status, )) { - if !superseded_install(&state, candidate, record, candidates.len() == 1) - { + if !superseded_install( + &state, + candidate, + record, + candidates.len() == 1, + hosted_pin_of(candidate).is_some(), + ) { continue; } candidate_source = PackageSource::Installed(&uninstalled); @@ -4909,6 +4929,28 @@ mod plan_gate_tests { } } + /// #1105: a live hosted pin marks a PyPI install superseded (a hosted + /// sync installed a hosted patch's bytes), so the takeover vendors the + /// record instead of reporting it not installed. The pin names no + /// variant, so only a sole candidate counts, and gems keep their + /// installed dir for the platform-gem refusal. + #[test] + fn hosted_pin_marks_a_pypi_install_superseded() { + let ledger = VendorState::default(); + let pypi = "pkg:pypi/six@1.16.0"; + let b = record(UUID_B); + assert!(superseded_install(&ledger, pypi, &b, true, true)); + assert!(!superseded_install(&ledger, pypi, &b, false, true)); + assert!(!superseded_install(&ledger, pypi, &b, true, false)); + assert!(!superseded_install( + &ledger, + "pkg:gem/nokogiri@1.16.0", + &b, + true, + true + )); + } + /// The download plan runs every record through its backend's own gate: /// a package the backend refuses before its first service call is /// never planned, wherever it sits in the loop order. Here the refused diff --git a/crates/socket-patch-cli/tests/mode_migration_pypi.rs b/crates/socket-patch-cli/tests/mode_migration_pypi.rs index 5494b8126..0533424c5 100644 --- a/crates/socket-patch-cli/tests/mode_migration_pypi.rs +++ b/crates/socket-patch-cli/tests/mode_migration_pypi.rs @@ -839,6 +839,67 @@ async fn poetry_revendors_to_a_superseding_patch_over_a_patched_venv() { ); } +/// #1105: a project hosted at patch A whose venv was then synced from the +/// hosted lock (`uv sync`, `poetry install`, `uv pip install -r`), so six +/// holds A's patched bytes, moves to vendored at the superseding patch B. +/// The installed copy matches neither the pristine release nor B's +/// output; it used to be dropped as `package_not_installed` (exit 1, the +/// project left hosted on A) while `scan --mode vendored --dry-run` +/// previewed success. The takeover must restore the upstream entry and +/// vendor B. +#[tokio::test] +async fn hosted_to_vendored_takes_a_superseding_patch_over_a_hosted_venv() { + let lanes: [(&str, StageFn); 3] = [ + ("uv", stage_uv_with_sibling), + ("poetry", stage_poetry), + ("requirements", stage_requirements), + ]; + for (lane, stage) in lanes { + let (_tmp, root) = project(); + let files = stage(&root); + let server = MockServer::start().await; + let hosted_url = mount_hosted_api(&server, true).await; + let (code, env) = hosted_scan(&root, &server); + assert_eq!(code, 0, "{lane}: hosted scan at A: {env:#}"); + assert!( + files.iter().any(|f| std::fs::read_to_string(root.join(f)) + .unwrap() + .contains(&hosted_url)), + "{lane}: hosted mode pins A: {env:#}" + ); + + let venv = root.join("../hosted-venv"); + venv_installed_from_patch_a(&venv); + stage_manifest_with(&root, UUID_B, PATCHED_B); + prebuilt_common::mount_project(&server, &root).await; + mount_pypi_json(&server).await; + let uri = server.uri(); + let pypi = format!("{uri}/pypi"); + let extra = [ + ("VIRTUAL_ENV", venv.to_str().unwrap()), + ("SOCKET_PYPI_JSON_API", pypi.as_str()), + ]; + let args = ["vendor", "--patch-server-url", &uri, "--vendor-url", &uri]; + + let (code, env) = run_cli(&root, &args, &extra); + assert_eq!(code, 0, "{lane}: takeover onto B: {env:#}"); + assert!( + !env.to_string().contains("package_not_installed"), + "{lane}: the installed copy is not reported missing: {env:#}" + ); + let wired: String = files + .iter() + .map(|f| std::fs::read_to_string(root.join(f)).unwrap()) + .collect(); + assert!( + wired.contains(&format!(".socket/vendor/pypi/{UUID_B}/")) + && !wired.contains(&hosted_url) + && !wired.contains(UUID), + "{lane}: wired to vendored B, hosted A gone:\n{wired}" + ); + } +} + const UV_LOCK: &str = r#"version = 1 revision = 2 requires-python = ">=3.9"