Skip to content

Commit 95fcbe4

Browse files
committed
Retire uv script vendor entries after uv remove
After `uv remove --script job.py six`, neither the PEP 723 script nor its lock names the vendored wheel any more, but vendor --revert, scan --prune, remove and rollback all kept the entry as "drift". The wheel and ledger entry stayed forever and vendor --check stayed red, with every remedy it named looping. The script/pylock revert now probes the wired files once before restoring: when none of them names the entry's uuid, each record that routed through the wheel warns vendor_lock_entry_removed and the revert finishes, as #1147 already does for uv projects. Real third-party edits while any file still names the wheel stay drift. Fixes #1214 Assisted-by: Claude Code:claude-opus-5-5
1 parent 76c962f commit 95fcbe4

18 files changed

Lines changed: 392 additions & 50 deletions

File tree

‎crates/socket-patch-cli/tests/e2e_redirect_yarn_classic_build.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,10 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() {
900900
return;
901901
};
902902
assert!(
903-
fx.proj.join("mirror").join(format!("{DEP}-{DEP_VERSION}.tgz")).is_file(),
903+
fx.proj
904+
.join("mirror")
905+
.join(format!("{DEP}-{DEP_VERSION}.tgz"))
906+
.is_file(),
904907
"the fixture install must populate the offline mirror"
905908
);
906909
let fresh = fx.tmp.path().join("fresh");
@@ -926,7 +929,11 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() {
926929
String::from_utf8_lossy(&ci.stderr)
927930
);
928931
assert!(
929-
!fresh.join("node_modules").join(DEP).join("index.js").exists(),
932+
!fresh
933+
.join("node_modules")
934+
.join(DEP)
935+
.join("index.js")
936+
.exists(),
930937
"yarn < 1.7 is expected to install nothing from the mirror"
931938
);
932939
return;
@@ -948,7 +955,10 @@ async fn classic_offline_mirror_refuses_hosted_and_keeps_installs_working() {
948955
);
949956
let installed =
950957
std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap();
951-
assert_eq!(installed, fx.orig, "the untouched lock installs the upstream bytes");
958+
assert_eq!(
959+
installed, fx.orig,
960+
"the untouched lock installs the upstream bytes"
961+
);
952962
std::fs::remove_dir_all(fresh.join("node_modules")).unwrap();
953963
}
954964
}

‎crates/socket-patch-cli/tests/e2e_socket_yml_policy.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,9 @@ async fn agent_mode_retains_a_recorded_patch_the_policy_now_excludes() {
11001100
async fn agent_mode_judges_nested_project_copies_by_their_own_root() {
11011101
let server = MockServer::start().await;
11021102
mount_api(&server, catalog()).await;
1103-
let repo = Repo::new(Some("version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n"));
1103+
let repo = Repo::new(Some(
1104+
"version: 2\npatches:\n ignorePaths: [\"/services/legacy/\"]\n",
1105+
));
11041106
// left-pad is installed in both web (admitted) and legacy (ignored).
11051107
write_npm_root(&repo.dir("services/legacy"), &["gamma", "left-pad"]);
11061108
let (code, doc) = scan_json(&repo.root, &server.uri(), &["--mode", "agent"], &[]);
@@ -1169,7 +1171,9 @@ async fn agent_mode_judges_nested_project_copies_by_their_own_root() {
11691171

11701172
// includePaths: the docs' headline example selects nested projects from
11711173
// the repo root (which itself is not included).
1172-
let repo = Repo::new(Some("version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n"));
1174+
let repo = Repo::new(Some(
1175+
"version: 2\npatches:\n includePaths: [\"/services/legacy/\"]\n",
1176+
));
11731177
let (code, doc) = scan_json(&repo.root, &server.uri(), &["--mode", "agent"], &[]);
11741178
assert_eq!(code, 0, "{doc:#}");
11751179
assert_eq!(

‎crates/socket-patch-cli/tests/e2e_vendor_yarn_berry_build.rs‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,14 @@ async fn run_berry_capstone(driver: VendorDriver, yarnrc_extra: &str) {
729729

730730
fn git(cwd: &Path, args: &[&str]) -> Output {
731731
let out = Command::new("git")
732-
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
732+
.args([
733+
"-c",
734+
"user.name=t",
735+
"-c",
736+
"user.email=t@t",
737+
"-c",
738+
"init.defaultBranch=main",
739+
])
733740
.args(args)
734741
.current_dir(cwd)
735742
.output()
@@ -810,16 +817,30 @@ fn yarn_berry_vendored_tarball_survives_a_tgz_gitignore_rule() {
810817
};
811818
let (code, stdout, stderr) = run_socket(
812819
&proj,
813-
&["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()],
820+
&[
821+
"vendor",
822+
"--json",
823+
"--offline",
824+
"--cwd",
825+
proj.to_str().unwrap(),
826+
],
827+
);
828+
assert_eq!(
829+
code, 0,
830+
"vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}"
814831
);
815-
assert_eq!(code, 0, "vendor failed.\nstdout:\n{stdout}\nstderr:\n{stderr}");
816832

817833
git(&proj, &["add", "-A"]);
818834
git(&proj, &["commit", "-qm", "vendored"]);
819835
let fresh = tmp.path().join("fresh");
820836
git(
821837
tmp.path(),
822-
&["clone", "-q", proj.to_str().unwrap(), fresh.to_str().unwrap()],
838+
&[
839+
"clone",
840+
"-q",
841+
proj.to_str().unwrap(),
842+
fresh.to_str().unwrap(),
843+
],
823844
);
824845
let fresh_global = tmp.path().join("fresh-yarn-global");
825846
let ci = corepack(
@@ -854,14 +875,26 @@ fn yarn_berry_vendor_refuses_a_gitignored_socket_dir() {
854875
let pkg_before = std::fs::read(proj.join("package.json")).unwrap();
855876
let (code, stdout, stderr) = run_socket(
856877
&proj,
857-
&["vendor", "--json", "--offline", "--cwd", proj.to_str().unwrap()],
878+
&[
879+
"vendor",
880+
"--json",
881+
"--offline",
882+
"--cwd",
883+
proj.to_str().unwrap(),
884+
],
885+
);
886+
assert_eq!(
887+
code, 1,
888+
"vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}"
858889
);
859-
assert_eq!(code, 1, "vendor must fail.\nstdout:\n{stdout}\nstderr:\n{stderr}");
860890
assert!(
861891
stdout.contains("vendor_artifact_gitignored"),
862892
"refusal code expected:\n{stdout}"
863893
);
864894
assert_eq!(std::fs::read(proj.join("yarn.lock")).unwrap(), lock_before);
865-
assert_eq!(std::fs::read(proj.join("package.json")).unwrap(), pkg_before);
895+
assert_eq!(
896+
std::fs::read(proj.join("package.json")).unwrap(),
897+
pkg_before
898+
);
866899
assert!(!proj.join(format!(".socket/vendor/npm/{UUID}")).exists());
867900
}

‎crates/socket-patch-cli/tests/in_process_redirect_pipenv.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,8 +589,7 @@ async fn live_pipfile_lock_conflict_vetoes_the_requirements_redirect() {
589589
#[tokio::test]
590590
#[serial]
591591
async fn platform_wheel_is_not_pinned_into_the_lock() {
592-
assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64")
593-
.await;
592+
assert_wheel_tag_is_not_pinned("cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64").await;
594593
}
595594

596595
/// #1048: a pure wheel bound to one interpreter (`cp311-none-any`) fails

‎crates/socket-patch-cli/tests/in_process_redirect_pnpm.rs‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() {
432432
assert_eq!(code, 0, "scan --mode hosted should succeed on a BOM lock");
433433
let lock = std::fs::read_to_string(&lock_path).unwrap();
434434
assert!(lock.starts_with("\u{feff}lockfileVersion:"), "{lock}");
435-
assert!(lock.contains(HOSTED_URL), "the BOM lock is redirected: {lock}");
435+
assert!(
436+
lock.contains(HOSTED_URL),
437+
"the BOM lock is redirected: {lock}"
438+
);
436439
let ws_path = tmp.path().join("pnpm-workspace.yaml");
437440
assert_eq!(
438441
std::fs::read_to_string(&ws_path).ok().as_deref(),
@@ -447,7 +450,10 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() {
447450
pristine,
448451
"rollback restores the BOM lock byte for byte"
449452
);
450-
assert!(!ws_path.exists(), "the auto-created workspace file goes too");
453+
assert!(
454+
!ws_path.exists(),
455+
"the auto-created workspace file goes too"
456+
);
451457

452458
// A BOM workspace file whose first key is the user's opt-out: left
453459
// byte-identical (no duplicate `trustLockfile`), lock still redirected.
@@ -473,7 +479,11 @@ async fn hosted_bom_lock_and_workspace_read_like_their_plain_twins() {
473479
"the lock is still redirected for {user_ws:?}"
474480
);
475481
let ws = std::fs::read_to_string(tmp.path().join("pnpm-workspace.yaml")).unwrap();
476-
assert_eq!(ws, want.unwrap_or(user_ws), "workspace file for {user_ws:?}");
482+
assert_eq!(
483+
ws,
484+
want.unwrap_or(user_ws),
485+
"workspace file for {user_ws:?}"
486+
);
477487
assert_eq!(ws.matches("trustLockfile").count(), 1, "{ws:?}");
478488
}
479489
}

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,108 @@ fn stage_script_lock(root: &Path) -> &'static [&'static str] {
829829
&["job.py", "job.py.lock"]
830830
}
831831

832+
/// The script lock staged by [`stage_script_lock`] after `uv remove --script
833+
/// job.py six`: uv drops the dependency, its `[tool.uv.sources]` line and
834+
/// the lock package, so neither file names the vendored uuid any more.
835+
fn uv_remove_script_six(root: &Path) {
836+
std::fs::write(
837+
root.join("job.py"),
838+
"# /// script\n# requires-python = \">=3.9\"\n# dependencies = []\n# ///\nimport six\n",
839+
)
840+
.unwrap();
841+
std::fs::write(
842+
root.join("job.py.lock"),
843+
"version = 1\nrevision = 3\nrequires-python = \">=3.9\"\n",
844+
)
845+
.unwrap();
846+
}
847+
848+
/// #1214: after `uv remove --script` drops a vendored package from a PEP 723
849+
/// script and its lock, every unwind must retire the entry: the wheel and
850+
/// the ledger entry go and `vendor --check` turns green. Before the fix each
851+
/// one kept the entry as `vendor_lock_entry_drifted` (nothing to undo), so
852+
/// `vendor --check` stayed red and its own `scan --prune` remedy looped.
853+
#[tokio::test]
854+
async fn script_lock_unwinds_after_uv_remove_script() {
855+
let server = MockServer::start().await;
856+
mount_hosted_api(&server, true).await;
857+
let uri = server.uri();
858+
let prune = vec![
859+
"scan",
860+
"--mode",
861+
"vendored",
862+
"--prune",
863+
"--yes",
864+
"--api-url",
865+
&uri,
866+
"--org",
867+
ORG,
868+
"--api-token",
869+
"fake-token",
870+
];
871+
for unwind in [
872+
vec!["vendor", "--revert"],
873+
prune.clone(),
874+
vec!["remove", PURL, "--yes", "--offline"],
875+
vec!["rollback", "--yes", "--offline"],
876+
hosted_scan_args(&uri),
877+
] {
878+
let (_tmp, root) = project();
879+
let files = stage_script_lock(&root);
880+
vendor_project(&root, files);
881+
uv_remove_script_six(&root);
882+
let removed: Vec<String> = files
883+
.iter()
884+
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
885+
.collect();
886+
let (code, env) = run_cli(&root, &["vendor", "--check"], &[]);
887+
assert_eq!(code, 1, "{unwind:?}: the removal is flagged first: {env:#}");
888+
889+
let (code, env) = run_cli(&root, &unwind, &[]);
890+
assert_eq!(code, 0, "{unwind:?}: {env:#}");
891+
let env = if unwind.contains(&"hosted") {
892+
// A hosted scan never reverts vendored entries; it names the
893+
// vendored prune as the fix, which must now converge.
894+
assert!(
895+
env.to_string().contains("vendor_ledger_entry_unwired"),
896+
"{unwind:?}: {env:#}"
897+
);
898+
let (code, env) = run_cli(&root, &prune, &[]);
899+
assert_eq!(code, 0, "{unwind:?} then prune: {env:#}");
900+
env
901+
} else {
902+
env
903+
};
904+
let rendered = env.to_string();
905+
assert!(
906+
!rendered.contains("vendor_lock_entry_drifted")
907+
&& !rendered.contains("vendor_artifact_kept"),
908+
"{unwind:?}: a removed dependency is not drift: {env:#}"
909+
);
910+
assert!(
911+
!root.join(format!(".socket/vendor/pypi/{UUID}")).exists(),
912+
"{unwind:?}: the vendored wheel is reclaimed"
913+
);
914+
let ledger =
915+
std::fs::read_to_string(root.join(".socket/vendor/state.json")).unwrap_or_default();
916+
assert!(!ledger.contains(UUID), "{unwind:?}: {ledger}");
917+
for (f, text) in files.iter().zip(&removed) {
918+
assert_eq!(
919+
&std::fs::read_to_string(root.join(f)).unwrap(),
920+
text,
921+
"{unwind:?}: {f} stays as uv left it"
922+
);
923+
}
924+
// `vendor --revert` and `rollback` keep the manifest record, so
925+
// check then reports the patch as not vendored; the unwinds that
926+
// retire the record leave check green.
927+
if matches!(unwind[0], "scan" | "remove") {
928+
let (code, env) = run_cli(&root, &["vendor", "--check"], &[]);
929+
assert_eq!(code, 0, "{unwind:?}: check is green afterwards: {env:#}");
930+
}
931+
}
932+
}
933+
832934
/// #742 / #650 / #1136: a vendored uv project, uv script lock, Hatch
833935
/// project and Poetry project (LF and CRLF) pick up a superseding patch. The manifest moves `six` from patch A to patch B
834936
/// (different patched bytes); the next `vendor` must wire B's wheel, remove
@@ -1332,9 +1434,18 @@ async fn ledger_update_failure_changes_nothing() {
13321434
set_mode(0o755);
13331435
assert_eq!(code, 1, "{env:#}");
13341436
assert_eq!(env["status"], "error", "{env:#}");
1335-
assert!(!env.to_string().contains("redirect_takeover_unpatched"), "{env:#}");
1336-
assert_eq!(std::fs::read(root.join("requirements.txt")).unwrap(), vendored);
1337-
assert_eq!(std::fs::read(root.join(".socket/vendor/state.json")).unwrap(), state);
1437+
assert!(
1438+
!env.to_string().contains("redirect_takeover_unpatched"),
1439+
"{env:#}"
1440+
);
1441+
assert_eq!(
1442+
std::fs::read(root.join("requirements.txt")).unwrap(),
1443+
vendored
1444+
);
1445+
assert_eq!(
1446+
std::fs::read(root.join(".socket/vendor/state.json")).unwrap(),
1447+
state
1448+
);
13381449
assert!(root.join(format!(".socket/vendor/pypi/{UUID}")).exists());
13391450
}
13401451

‎crates/socket-patch-core/src/crawlers/cargo_crawler.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,7 @@ fn read_crate_cargo_toml(crate_path: &Path, dir_name: &str) -> Option<(String, S
379379
let content = crate::utils::fs::read_regular_to_string_sync(&cargo_toml_path).ok()?;
380380

381381
// Fallback: parse directory name as <name>-<version>
382-
package_name_version(&content)
383-
.or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
382+
package_name_version(&content).or_else(|| CargoCrawler::parse_dir_name_version(dir_name))
384383
}
385384

386385
impl Default for CargoCrawler {

‎crates/socket-patch-core/src/formats/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@
2929
pub(crate) mod bun;
3030
pub mod cargo;
3131
pub mod composer;
32-
pub mod governing_locks;
3332
pub mod gem;
33+
pub mod governing_locks;
3434
pub(crate) mod maven;
3535
pub(crate) mod nuget;
3636
pub mod pnpm;

‎crates/socket-patch-core/src/hosted/memory/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ use crate::rollout::stage::{
6666
classify, lookup_incomplete, mark_pinned, offers_from_results, Offers, RecordedIndex, Row,
6767
Stage, ROLLOUT_DEFERRED,
6868
};
69+
use crate::utils::purl_key::PurlKey;
6970
use discover::Provider;
7071
use stages::{Planned, RewriteRefused, Rewritten, StageOptions};
71-
use crate::utils::purl_key::PurlKey;
7272

7373
/// `"<crate version>+<git sha or 'unknown'>"`; the sha comes from the
7474
/// `SOCKET_PATCH_GIT_SHA` build-time variable.

0 commit comments

Comments
 (0)