Skip to content

Commit 4fc3896

Browse files
committed
Re-vendor Pipenv locks to a newer patch
A Pipenv project vendored at one patch never moved to a newer patch for the same package: the re-vendor refused with pypi_pipenv_source_already_exists and the run exited 1, although the dry run previewed would_revendor. When the vendor ledger records the Pipfile.lock entry the older patch wrote, and that entry is unchanged, it is now rewired in place to the new wheel. The record carries the older entry's pre-vendor registry original forward, so vendor --revert still restores the user's pin. Without that record, or after an edit, it still refuses as before. Refs #769 Assisted-by: Claude Code:claude-opus-5-5
1 parent a3d9d22 commit 4fc3896

2 files changed

Lines changed: 297 additions & 22 deletions

File tree

‎crates/socket-patch-core/src/vendor/pypi.rs‎

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,9 @@ enum WiringPlan {
426426
Hatch(super::pypi_hatch::HatchProject),
427427
Poetry(Box<PoetryProject>),
428428
Pdm(Box<PdmProject>),
429-
Pipenv(Box<PipenvProject>),
429+
/// The ledger entry of an OLDER patch uuid whose Pipfile.lock wiring the
430+
/// guards admitted for an in-place re-wire (#769), if any.
431+
Pipenv(Box<PipenvProject>, Option<Box<VendorEntry>>),
430432
/// The lock already routes this package through THIS patch uuid's
431433
/// vendored wheel: no wiring — verify (or rebuild) the artifact only.
432434
InSync,
@@ -862,12 +864,25 @@ async fn pypi_prelude<'p>(
862864
),
863865
));
864866
}
865-
let target = match super::pypi_pipenv::check_target_guards(
867+
// A superseding patch (#769): the ledger entry that wired this
868+
// package at an older uuid holds the pre-vendor originals the
869+
// re-wire carries forward. An unreadable ledger leaves none, and
870+
// the guards then refuse the re-wire as before.
871+
let superseded = super::state::load_state_shared(project_root)
872+
.await
873+
.ok()
874+
.and_then(|state| {
875+
super::state::lookup_entry(&state.entries, base)
876+
.filter(|entry| entry.uuid != record.uuid)
877+
.cloned()
878+
});
879+
let target = match super::pypi_pipenv::check_target_guards_superseding(
866880
&project,
867881
&canon_name,
868882
&record.uuid,
869883
version,
870884
hosted_origins,
885+
superseded.as_ref(),
871886
) {
872887
Ok(target) => target,
873888
// A refusal carries no warnings: probe nothing for it.
@@ -888,7 +903,9 @@ async fn pypi_prelude<'p>(
888903
wired_pin = pipenv_wired_pin(&project.lock, &uuid_dir_rel);
889904
WiringPlan::InSync
890905
}
891-
PipenvTarget::Fresh => WiringPlan::Pipenv(Box::new(project)),
906+
PipenvTarget::Fresh => {
907+
WiringPlan::Pipenv(Box::new(project), superseded.map(Box::new))
908+
}
892909
}
893910
}
894911
};
@@ -1285,7 +1302,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>(
12851302
.await
12861303
.map(|(wiring, meta)| (wiring, MetaSlot::Pdm(meta)))
12871304
}
1288-
WiringPlan::Pipenv(project) => super::pypi_pipenv::wire_pipenv(
1305+
WiringPlan::Pipenv(project, superseded) => super::pypi_pipenv::wire_pipenv_superseding(
12891306
&project,
12901307
project_root,
12911308
&canon_name,
@@ -1294,6 +1311,7 @@ pub async fn vendor_pypi_with_pipenv_version<'a>(
12941311
&artifact.sha256_hex,
12951312
&record.uuid,
12961313
&hosted_origins,
1314+
superseded.as_deref(),
12971315
)
12981316
.await
12991317
.map(|(wiring, meta)| (wiring, MetaSlot::Pipenv(meta))),
@@ -4208,6 +4226,161 @@ wheels = [
42084226
}
42094227
"#;
42104228

4229+
/// A Pipenv project (Pipfile.lock, no requirements.txt) over the
4230+
/// [`e2e_fixture`] install and blob store.
4231+
async fn pipenv_e2e_fixture() -> E2eFixture {
4232+
let fx = e2e_fixture().await;
4233+
tokio::fs::remove_file(fx.root.join("requirements.txt"))
4234+
.await
4235+
.unwrap();
4236+
touch(&fx.root, "Pipfile.lock", PIPENV_REGISTRY_LOCK).await;
4237+
fx
4238+
}
4239+
4240+
/// Vendor `record` into the [`pipenv_e2e_fixture`] project.
4241+
async fn pipenv_vendor(fx: &E2eFixture, record: &PatchRecord) -> VendorOutcome {
4242+
let sources = PatchSources::blobs_only(&fx.blobs);
4243+
crate::vendor::test_support::vendor_pypi(
4244+
"pkg:pypi/six@1.16.0",
4245+
&fx.site_packages,
4246+
&fx.root,
4247+
record,
4248+
&sources,
4249+
"2026-06-09T00:00:00Z",
4250+
false,
4251+
false,
4252+
None,
4253+
)
4254+
.await
4255+
}
4256+
4257+
async fn read_json(root: &Path, name: &str) -> serde_json::Value {
4258+
serde_json::from_str(&tokio::fs::read_to_string(root.join(name)).await.unwrap()).unwrap()
4259+
}
4260+
4261+
/// #769: a Pipfile.lock wired to an EARLIER patch uuid re-vendors in
4262+
/// place to the superseding uuid, as the `would_revendor` preview and
4263+
/// the CLI contract promise: the entry moves to the new wheel, its
4264+
/// record carries the pre-vendor registry original forward, and
4265+
/// `vendor --revert` of the NEW entry restores the registry pin.
4266+
#[tokio::test]
4267+
async fn pipenv_superseding_uuid_revendors_in_place() {
4268+
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
4269+
let fx = pipenv_e2e_fixture().await;
4270+
let registry = read_json(&fx.root, "Pipfile.lock").await;
4271+
let VendorOutcome::Done { result, entry, .. } = pipenv_vendor(&fx, &fx.record).await else {
4272+
panic!("first vendor must be Done");
4273+
};
4274+
assert!(result.success, "{:?}", result.error);
4275+
let first = entry.expect("entry on success");
4276+
save_ledger_entry(&fx.root, &first).await;
4277+
4278+
let mut record2 = fx.record.clone();
4279+
record2.uuid = UUID2.to_string();
4280+
let outcome = pipenv_vendor(&fx, &record2).await;
4281+
let VendorOutcome::Done { result, entry, .. } = outcome else {
4282+
panic!("superseding uuid must re-vendor, got {outcome:?}");
4283+
};
4284+
assert!(result.success, "{:?}", result.error);
4285+
let second = entry.expect("entry on success");
4286+
assert_eq!(second.uuid, UUID2);
4287+
assert_eq!(second.wiring.len(), 1);
4288+
assert_eq!(second.wiring[0].key.as_deref(), Some("default:six"));
4289+
assert_eq!(
4290+
second.wiring[0].original,
4291+
Some(registry["default"]["six"].clone()),
4292+
"the pre-vendor registry original is carried forward"
4293+
);
4294+
let lock = tokio::fs::read_to_string(fx.root.join("Pipfile.lock"))
4295+
.await
4296+
.unwrap();
4297+
assert!(!lock.contains(UUID), "Pipfile.lock kept uuid A:\n{lock}");
4298+
assert!(lock.contains(UUID2), "Pipfile.lock not on uuid B:\n{lock}");
4299+
assert!(fx
4300+
.root
4301+
.join(format!(".socket/vendor/pypi/{UUID2}/{WHEEL_NAME}"))
4302+
.is_file());
4303+
4304+
save_ledger_entry(&fx.root, &second).await;
4305+
let reverted = revert_pypi(&second, &fx.root, false).await;
4306+
assert!(reverted.success, "{:?}", reverted.error);
4307+
assert!(reverted.warnings.is_empty(), "{:?}", reverted.warnings);
4308+
assert_eq!(read_json(&fx.root, "Pipfile.lock").await, registry);
4309+
}
4310+
4311+
/// #769: without a ledger entry for the older uuid there is no recorded
4312+
/// pre-vendor original to carry forward, so a re-wire could never be
4313+
/// reverted. That case still refuses, before anything is written.
4314+
#[tokio::test]
4315+
async fn pipenv_superseding_uuid_without_ledger_refuses() {
4316+
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
4317+
let fx = pipenv_e2e_fixture().await;
4318+
let VendorOutcome::Done { result, .. } = pipenv_vendor(&fx, &fx.record).await else {
4319+
panic!("first vendor must be Done");
4320+
};
4321+
assert!(result.success, "{:?}", result.error);
4322+
let wired = tokio::fs::read_to_string(fx.root.join("Pipfile.lock"))
4323+
.await
4324+
.unwrap();
4325+
4326+
let mut record2 = fx.record.clone();
4327+
record2.uuid = UUID2.to_string();
4328+
let outcome = pipenv_vendor(&fx, &record2).await;
4329+
let VendorOutcome::Refused { code, detail } = outcome else {
4330+
panic!("expected Refused, got {outcome:?}");
4331+
};
4332+
assert_eq!(code, "pypi_pipenv_source_already_exists");
4333+
assert!(detail.contains(UUID), "{detail}");
4334+
assert!(detail.contains("records no wiring"), "{detail}");
4335+
assert_eq!(
4336+
tokio::fs::read_to_string(fx.root.join("Pipfile.lock"))
4337+
.await
4338+
.unwrap(),
4339+
wired
4340+
);
4341+
assert!(!fx
4342+
.root
4343+
.join(format!(".socket/vendor/pypi/{UUID2}"))
4344+
.exists());
4345+
}
4346+
4347+
/// #769: a re-wire replays only what the older entry's ledger recorded.
4348+
/// A wired entry edited since vendoring refuses before anything is
4349+
/// written.
4350+
#[tokio::test]
4351+
async fn pipenv_superseding_uuid_drifted_entry_refuses() {
4352+
const UUID2: &str = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d";
4353+
let fx = pipenv_e2e_fixture().await;
4354+
let VendorOutcome::Done { result, entry, .. } = pipenv_vendor(&fx, &fx.record).await else {
4355+
panic!("first vendor must be Done");
4356+
};
4357+
assert!(result.success, "{:?}", result.error);
4358+
save_ledger_entry(&fx.root, &entry.expect("entry on success")).await;
4359+
let mut lock = read_json(&fx.root, "Pipfile.lock").await;
4360+
lock["default"]["six"]["markers"] = serde_json::json!("python_version >= '3.8'");
4361+
let drifted = serde_json::to_string_pretty(&lock).unwrap() + "\n";
4362+
touch(&fx.root, "Pipfile.lock", &drifted).await;
4363+
4364+
let mut record2 = fx.record.clone();
4365+
record2.uuid = UUID2.to_string();
4366+
let outcome = pipenv_vendor(&fx, &record2).await;
4367+
let VendorOutcome::Refused { code, detail } = outcome else {
4368+
panic!("expected Refused, got {outcome:?}");
4369+
};
4370+
assert_eq!(code, "pypi_pipenv_source_already_exists");
4371+
assert!(detail.contains("changed since vendoring"), "{detail}");
4372+
assert_eq!(
4373+
tokio::fs::read_to_string(fx.root.join("Pipfile.lock"))
4374+
.await
4375+
.unwrap(),
4376+
drifted
4377+
);
4378+
assert!(!fx
4379+
.root
4380+
.join(format!(".socket/vendor/pypi/{UUID2}"))
4381+
.exists());
4382+
}
4383+
42114384
/// A relock regenerated the wired entry to a registry reference whose
42124385
/// hash list differs from the recorded original (Pipenv 2022.12.19 does
42134386
/// exactly this; 2026.x reproduces the original and converges silently):

0 commit comments

Comments
 (0)