@@ -841,6 +841,28 @@ pub async fn revert_yarn_berry_opts(
841841 }
842842 }
843843 }
844+ // A user `yarn patch` over the vendored tarball (#962): restoring the
845+ // recorded entry under its `patch:` wrapper leaves a lock every
846+ // `yarn install --immutable` rejects, so refuse before either file is
847+ // written (and before the dry-run return, so a preview agrees).
848+ if let Some ( wired) = user_patch_wrapping ( project_root, & entry. uuid ) . await {
849+ let detail = format ! (
850+ "refusing to revert {uuid_dir_rel}: a `patch:` entry from `yarn patch` in {wired} \
851+ still wraps the vendored tarball, and restoring the registry entry under it would \
852+ leave a lock `yarn install --immutable` rejects — remove the `patch:` resolution \
853+ from {PACKAGE_JSON}, run `yarn install`, re-run `vendor --revert`, then re-run \
854+ `yarn patch` on the registry version"
855+ ) ;
856+ return RevertOutcome {
857+ success : false ,
858+ warnings : vec ! [ VendorWarning :: new(
859+ "vendor_lock_still_wired_revert_blocked" ,
860+ detail. clone( ) ,
861+ ) ] ,
862+ error : Some ( detail) ,
863+ kept_artifact : false ,
864+ } ;
865+ }
844866 if dry_run {
845867 return RevertOutcome :: ok ( ) ;
846868 }
@@ -1102,6 +1124,26 @@ pub async fn revert_yarn_berry_opts(
11021124
11031125// ───────────────────────────── revert internals ─────────────────────────────
11041126
1127+ /// The first wired file (yarn.lock, then package.json) in which a user
1128+ /// `patch:` descriptor wraps the vendored artifact of `uuid` (#962). An
1129+ /// unreadable or missing file wraps nothing here: the wired revert's own
1130+ /// reads report it.
1131+ async fn user_patch_wrapping ( project_root : & Path , uuid : & str ) -> Option < & ' static str > {
1132+ let needle = uuid. to_ascii_lowercase ( ) ;
1133+ for wired in [ YARN_LOCK , PACKAGE_JSON ] {
1134+ let Ok ( text) = read_regular_to_string ( & project_root. join ( wired) ) . await else {
1135+ continue ;
1136+ } ;
1137+ if crate :: formats:: yarn:: patterns:: patch_wrapped_descriptors ( & text)
1138+ . iter ( )
1139+ . any ( |inner| inner. to_ascii_lowercase ( ) . contains ( & needle) )
1140+ {
1141+ return Some ( wired) ;
1142+ }
1143+ }
1144+ None
1145+ }
1146+
11051147/// Put back an entry vendoring pruned (see [`KIND_LOCK_ENTRY_PRUNED`]):
11061148/// the rewritten shared entry returns to its original lines, a removed one
11071149/// is inserted where yarn sorts it. Already restored is silent; anything
@@ -3944,6 +3986,70 @@ __metadata:
39443986 ) ;
39453987 }
39463988
3989+ /// #962: the user ran `yarn patch` / `yarn patch-commit -s` over the
3990+ /// vendored package, so a `patch:` lock entry and `resolutions` value
3991+ /// wrap the vendored tarball. Restoring the recorded entry would leave
3992+ /// that wrapper naming a descriptor no entry resolves: every
3993+ /// `yarn install --immutable` rejects the lock (YN0028). The revert must
3994+ /// refuse BEFORE writing either file, on a dry run too.
3995+ #[ tokio:: test]
3996+ async fn revert_refuses_before_writing_when_a_user_yarn_patch_wraps_the_artifact ( ) {
3997+ let fx = fixture ( ) . await ;
3998+ let ( _, entry, _) = expect_done ( fx. vendor ( false ) . await ) ;
3999+ let entry = entry. unwrap ( ) ;
4000+
4001+ let text = tokio:: fs:: read_to_string ( fx. lock_path ( ) ) . await . unwrap ( ) ;
4002+ let resolution = text
4003+ . lines ( )
4004+ . find_map ( |l| l. strip_prefix ( " resolution: \" left-pad@" ) )
4005+ . and_then ( |r| r. strip_suffix ( '"' ) )
4006+ . expect ( "the vendored lock must carry our resolution line" )
4007+ . to_string ( ) ;
4008+ let wrapped = format ! (
4009+ "patch:left-pad@{}#~/.yarn/patches/left-pad-file-0d6d28.patch" ,
4010+ resolution. replace( ':' , "%3A" )
4011+ ) ;
4012+ let lock = format ! (
4013+ "{text}\n \" left-pad@{wrapped}\" :\n version: 1.3.0\n \
4014+ resolution: \" left-pad@{wrapped}::version=1.3.0&hash=0d6d28\" \n \
4015+ languageName: node\n linkType: hard\n "
4016+ ) ;
4017+ tokio:: fs:: write ( fx. lock_path ( ) , & lock) . await . unwrap ( ) ;
4018+ let pkg_text = tokio:: fs:: read_to_string ( fx. pkg_path ( ) ) . await . unwrap ( ) ;
4019+ let mut pkg: Value = serde_json:: from_str ( & pkg_text) . unwrap ( ) ;
4020+ pkg[ "resolutions" ] [ format ! ( "left-pad@{resolution}" ) ] = Value :: String ( wrapped) ;
4021+ let pkg_text = serde_json:: to_string_pretty ( & pkg) . unwrap ( ) + "\n " ;
4022+ tokio:: fs:: write ( fx. pkg_path ( ) , & pkg_text) . await . unwrap ( ) ;
4023+
4024+ for dry_run in [ true , false ] {
4025+ let outcome = revert_yarn_berry ( & entry, fx. root ( ) , dry_run) . await ;
4026+ assert ! (
4027+ !outcome. success,
4028+ "dry_run={dry_run}: must refuse under a user `yarn patch`: {:?}" ,
4029+ outcome. warnings
4030+ ) ;
4031+ assert ! (
4032+ outcome. warnings. iter( ) . any( |w| {
4033+ w. code == "vendor_lock_still_wired_revert_blocked"
4034+ && w. detail. contains( "yarn patch" )
4035+ } ) ,
4036+ "dry_run={dry_run}: {:?}" ,
4037+ outcome. warnings
4038+ ) ;
4039+ assert_eq ! (
4040+ tokio:: fs:: read_to_string( fx. lock_path( ) ) . await . unwrap( ) ,
4041+ lock,
4042+ "dry_run={dry_run}: yarn.lock untouched"
4043+ ) ;
4044+ assert_eq ! (
4045+ tokio:: fs:: read_to_string( fx. pkg_path( ) ) . await . unwrap( ) ,
4046+ pkg_text,
4047+ "dry_run={dry_run}: package.json untouched"
4048+ ) ;
4049+ assert ! ( fx. tgz_path( ) . exists( ) , "the artifact must survive" ) ;
4050+ }
4051+ }
4052+
39474053 /// The user re-keys our resolutions entry (`"left-pad"` →
39484054 /// `"left-pad@npm:1.3.0"`) without changing its `file:` value. The
39494055 /// recorded key then reads as already-converged (Added record, key
0 commit comments