@@ -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 # ///\n import six\n " ,
839+ )
840+ . unwrap ( ) ;
841+ std:: fs:: write (
842+ root. join ( "job.py.lock" ) ,
843+ "version = 1\n revision = 3\n requires-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
0 commit comments