Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions crates/socket-patch-cli/tests/e2e_vex_build/hatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,98 @@ fn flow(flavor: Flavor, mode: Mode) {
"pass",
);

// ── #385: everyday pyproject edits must not block the revert ──────
// A release bump and a new sibling dependency after vendoring: the
// revert restores six's pin and drops the permission, keeping both.
if mode == Mode::Vendored && flavor == Flavor::Project {
let edited = tmp.path().join("edited");
copy_tree(&project, &edited, &[]);
let edit = |text: &str| {
text.replacen("version = \"0.1.0\"", "version = \"0.2.0\"", 1)
.replacen("dependencies = [", "dependencies = [\"idna==3.7\", ", 1)
};
std::fs::write(edited.join("pyproject.toml"), edit(&wired)).unwrap();
let out = std::process::Command::new(vex_e2e_common::binary())
.args(["vendor", "--revert", "--json", "--cwd"])
.arg(&edited)
.current_dir(&edited)
.output()
.unwrap();
assert_eq!(
out.status.code(),
Some(0),
"{what}: revert after project edits: {}",
out_text(&out)
);
let native = &flavor.native()[0].1;
assert_eq!(
std::fs::read_to_string(edited.join("pyproject.toml")).unwrap(),
edit(native),
"{what}: revert after project edits: {}",
out_text(&out)
);
assert!(
!edited
.join(".socket/vendor/pypi")
.join(mode.uuid())
.exists(),
"{what}: the vendored artifact was kept"
);
record(
"hatch",
&version,
&format!("{cell}/{}", mode.label()),
"revert-after-project-edits",
"pass",
);

// Review on #481: an added, marked copy of the vendored requirement
// still installs from the wheel, so the revert must refuse and keep
// both the file and the artifact.
let copied = tmp.path().join("copied");
copy_tree(&project, &copied, &[]);
let start = wired.find("\"six @").unwrap() + 1;
let requirement = &wired[start..start + wired[start..].find('"').unwrap()];
let with_copy = wired.replacen(
"dependencies = [",
&format!("dependencies = [\"{requirement} ; python_version >= '3.8'\", "),
1,
);
std::fs::write(copied.join("pyproject.toml"), &with_copy).unwrap();
let out = std::process::Command::new(vex_e2e_common::binary())
.args(["vendor", "--revert", "--json", "--cwd"])
.arg(&copied)
.current_dir(&copied)
.output()
.unwrap();
assert_ne!(
out.status.code(),
Some(0),
"{what}: revert with a copied vendored requirement: {}",
out_text(&out)
);
assert_eq!(
std::fs::read_to_string(copied.join("pyproject.toml")).unwrap(),
with_copy,
"{what}: revert with a copied vendored requirement"
);
assert!(
copied
.join(".socket/vendor/pypi")
.join(mode.uuid())
.exists(),
"{what}: the still-referenced artifact was deleted: {}",
out_text(&out)
);
record(
"hatch",
&version,
&format!("{cell}/{}", mode.label()),
"revert-refuses-copied-reference",
"pass",
);
}

// ── fresh checkout, real `hatch env create` ───────────────────────
let fresh = tmp.path().join("fresh");
copy_tree(&project, &fresh, &[".socket/manifest.json"]);
Expand Down
84 changes: 84 additions & 0 deletions crates/socket-patch-cli/tests/vex_e2e_common/uv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1560,6 +1560,11 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) {
&|step, result| report.row(step, result),
);

// ── 5b. a sibling dependency added after vendoring (#474) ─────────
if mode == Mode::Vendored && lane == Lane::Script {
script_sibling_revert(uv, &report, &proj, tmp.path());
}

// ── 6. the real revert ────────────────────────────────────────────
// Vendored: `vendor --revert` restores every wiring file byte for
// byte. Hosted (v5): `rollback` rewrites each pin back to the DEFAULT
Expand Down Expand Up @@ -1682,6 +1687,85 @@ pub fn run_lane(suite: &str, uv: &Uv, mode: Mode, lane: Lane) {
report.row("revert", "byte-identical");
}

/// #474: `uv add --script` after vendoring adds an unrelated requirement
/// to the script and its lock (a new `[manifest] requirements` element and
/// `[[package]]`). `vendor --revert` must still restore six's registry
/// wiring and keep the user's addition, leaving a lock uv accepts as is.
/// Runs on a copy so the byte-identical revert below is unaffected.
fn script_sibling_revert(uv: &Uv, report: &Report<'_>, proj: &Path, tmp: &Path) {
let dir = tmp.join("sibling");
std::fs::create_dir_all(&dir).unwrap();
for f in [SCRIPT, "tool.py.lock"] {
std::fs::copy(proj.join(f), dir.join(f)).unwrap();
}
copy_tree(&proj.join(".socket"), &dir.join(".socket"));
let cache = tmp.join("sibling-cache");
let out = uv.run_py(&dir, &["add", "--script", SCRIPT, "idna==3.7"], &cache);
if !ok(&out) {
report.row("sibling-revert", "n/a (`uv add --script` failed)");
println!("{}", dump(&out));
return;
}
let lock = std::fs::read_to_string(dir.join("tool.py.lock")).unwrap();
assert!(
lock.contains("name = \"idna\"") && lock.contains(".socket/vendor"),
"{}: uv add did not keep the vendored lock:\n{lock}",
report.what("sibling-revert")
);
let out = socket_patch(
&dir,
&[
"vendor",
"--revert",
"--json",
"--cwd",
dir.to_str().unwrap(),
],
);
let text = format!("{}\n{}", String::from_utf8_lossy(&out.stdout), dump(&out));
assert_eq!(
out.status.code(),
Some(0),
"{}:\n{text}",
report.what("sibling-revert")
);
assert!(
!text.contains("vendor_lock_entry_drifted"),
"{}: an added sibling counted as drift:\n{text}",
report.what("sibling-revert")
);
for f in [SCRIPT, "tool.py.lock"] {
let body = std::fs::read_to_string(dir.join(f)).unwrap();
assert!(
!body.contains(".socket/vendor") && body.contains("idna"),
"{}: {f} still vendored or lost idna:\n{body}",
report.what("sibling-revert")
);
}
assert!(
!dir.join(".socket/vendor/pypi")
.join(Mode::Vendored.uuid())
.exists(),
"{}: the vendored artifact was kept",
report.what("sibling-revert")
);
let reverted = std::fs::read(dir.join("tool.py.lock")).unwrap();
let out = uv.run_py(&dir, &["lock", "--script", SCRIPT], &cache);
assert!(
ok(&out),
"{}: uv lock --script:\n{}",
report.what("sibling-revert"),
dump(&out)
);
assert_eq!(
String::from_utf8_lossy(&std::fs::read(dir.join("tool.py.lock")).unwrap()),
String::from_utf8_lossy(&reverted),
"{}: uv rewrote the reverted lock",
report.what("sibling-revert")
);
report.row("sibling-revert", "restored, user addition kept");
}

// ── production legs ────────────────────────────────────────────────────

/// The uv program a production leg drives: `SOCKET_PATCH_UV_E2E_BIN`, else
Expand Down
115 changes: 114 additions & 1 deletion crates/socket-patch-core/src/vendor/pypi_hatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,21 @@ pub(super) async fn revert(entry: &VendorEntry, root: &Path, dry_run: bool) -> R
return RevertOutcome::failed("missing Hatch wiring document");
};
match super::pypi_lock::restore_document(live, original, new) {
Ok((restored, false)) => {
Ok((restored, false))
if !super::pypi_lock::still_references_artifact(
&restored,
original,
&entry.uuid,
) =>
{
edits.insert(record.file.clone(), restored);
}
Ok((_, false)) => {
return RevertOutcome::failed(format!(
"{} still references the vendored artifact after restoring the recorded entries",
record.file
))
}
Ok((_, true)) => {
return RevertOutcome::failed(format!("{} changed since patching", record.file))
}
Expand Down Expand Up @@ -464,4 +476,105 @@ mod tests {
ORIGINAL
);
}

/// #385: ordinary pyproject edits after vendoring (a release bump, a
/// comment on `name`, a new sibling dependency) must not block rollback.
#[tokio::test]
async fn revert_keeps_unrelated_project_edits() {
let original =
"[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n";
let edits: [(&str, &str); 4] = [
("version = \"0.1.0\"", "version = \"0.2.0\""),
("name = \"app\"", "name = \"app\" # renamed soon"),
("dependencies = [", "dependencies = [\"idna==3.7\", "),
(
"version = \"0.1.0\"\n",
"version = \"0.3.0\"\ndescription = \"x\"\n",
),
];
for (from, to) in edits {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
tokio::fs::write(root.join("pyproject.toml"), original)
.await
.unwrap();
let project = load(root, "six", "1.16.0", UUID).await.unwrap();
let wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl");
let wiring = wire(&project, root, "six", "1.16.0", &wheel, &"0".repeat(64))
.await
.unwrap();
let entry = entry(UUID, "six", &wheel, &"0".repeat(64), wiring);
let mut state = VendorState::default();
state.entries.insert("six".into(), entry.clone());
save_state(root, &state).await.unwrap();
let patched = tokio::fs::read_to_string(root.join("pyproject.toml"))
.await
.unwrap();
assert!(patched.contains(&wheel));
assert!(patched.contains(from), "{patched}");
tokio::fs::write(root.join("pyproject.toml"), patched.replacen(from, to, 1))
.await
.unwrap();
let outcome = revert(&entry, root, false).await;
assert!(outcome.success, "{from} -> {to}: {:?}", outcome.error);
assert_eq!(
tokio::fs::read_to_string(root.join("pyproject.toml"))
.await
.unwrap(),
original.replacen(from, to, 1),
"{from} -> {to}"
);
}
}

/// Review on #481: a user-added copy of the vendored requirement (here
/// with an environment marker) survives the merge as a sibling, so the
/// revert must refuse rather than delete the wheel it installs from.
#[tokio::test]
async fn revert_refuses_while_an_added_line_references_the_artifact() {
let original =
"[project]\nname = \"app\"\nversion = \"0.1.0\"\ndependencies = [\"six==1.16.0\"]\n";
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
tokio::fs::write(root.join("pyproject.toml"), original)
.await
.unwrap();
let project = load(root, "six", "1.16.0", UUID).await.unwrap();
let wheel = format!(".socket/vendor/pypi/{UUID}/six-1.16.0-py2.py3-none-any.whl");
let wiring = wire(&project, root, "six", "1.16.0", &wheel, &"0".repeat(64))
.await
.unwrap();
let entry = entry(UUID, "six", &wheel, &"0".repeat(64), wiring);
let patched = tokio::fs::read_to_string(root.join("pyproject.toml"))
.await
.unwrap();
let start = patched.find("\"six @").unwrap();
let end = start + 1 + patched[start + 1..].find('"').unwrap();
let requirement = &patched[start + 1..end];
let edited = patched.replacen(
"dependencies = [",
&format!("dependencies = [\"{requirement} ; python_version >= '3.8'\", "),
1,
);
tokio::fs::write(root.join("pyproject.toml"), &edited)
.await
.unwrap();
let outcome = revert(&entry, root, false).await;
assert!(!outcome.success, "{:?}", outcome.error);
assert!(
outcome
.error
.as_deref()
.unwrap_or_default()
.contains("still references the vendored artifact"),
"{:?}",
outcome.error
);
assert_eq!(
tokio::fs::read_to_string(root.join("pyproject.toml"))
.await
.unwrap(),
edited
);
}
}
Loading
Loading