@@ -762,6 +762,77 @@ def _boom(step_id, *args, **kwargs):
762762 )
763763
764764
765+ def test_step_refresh_restores_null_entry_when_removal_fails (
766+ tmp_path : Path , monkeypatch
767+ ):
768+ """A removal ``BundlerError`` restores the snapshot instead of deleting it.
769+
770+ ``_remove_step_locked`` drops the registry key before ``rmtree``. A JSON
771+ null value cannot be put back by that function, and the removal error used
772+ to escape before rollback, so ``finally`` deleted the only copy of the
773+ entry and the package.
774+ """
775+ import json
776+ import shutil
777+
778+ import specify_cli
779+ from specify_cli .workflows .catalog import StepRegistry
780+
781+ steps_dir = tmp_path / ".specify" / "workflows" / "steps"
782+ (steps_dir / "my-step" ).mkdir (parents = True )
783+ (steps_dir / "my-step" / "step.yml" ).write_text (
784+ "step:\n type_key: my-step\n " , encoding = "utf-8"
785+ )
786+ (steps_dir / "my-step" / "__init__.py" ).write_text ("" , encoding = "utf-8" )
787+ (steps_dir / StepRegistry .REGISTRY_FILE ).write_text (
788+ json .dumps ({"schema_version" : "1.0" , "steps" : {"my-step" : None }}),
789+ encoding = "utf-8" ,
790+ )
791+ package_text = (steps_dir / "my-step" / "step.yml" ).read_text (encoding = "utf-8" )
792+
793+ def _add (* _args , ** _kwargs ):
794+ raise AssertionError ("reinstall ran after a failed removal" )
795+
796+ monkeypatch .setattr (specify_cli , "workflow_step_add" , _add )
797+
798+ real_rmtree = shutil .rmtree
799+ failed = {"done" : False }
800+
801+ def _failing_rmtree (path , * args , ** kwargs ):
802+ target = Path (path )
803+ if (
804+ not failed ["done" ]
805+ and target .name == "my-step"
806+ and "speckit-step-refresh-" not in str (target )
807+ ):
808+ # Directory is gone, then removal reports failure. The null key
809+ # was already deleted and cannot be restored by ``_remove_step_locked``.
810+ failed ["done" ] = True
811+ real_rmtree (path , * args , ** kwargs )
812+ raise OSError ("simulated rmtree failure" )
813+ return real_rmtree (path , * args , ** kwargs )
814+
815+ monkeypatch .setattr (shutil , "rmtree" , _failing_rmtree )
816+
817+ manager = primitive_manager ("steps" , tmp_path , allow_network = True )
818+ with pytest .raises (BundlerError , match = "Failed to remove step 'my-step'" ) as caught :
819+ manager .refresh (_component ("steps" , "my-step" ))
820+
821+ assert failed ["done" ]
822+ assert not getattr (caught .value , "__notes__" , ())
823+ restored = StepRegistry (tmp_path )
824+ assert restored .is_installed ("my-step" )
825+ assert restored .get ("my-step" ) is None
826+ saved = json .loads (
827+ (steps_dir / StepRegistry .REGISTRY_FILE ).read_text (encoding = "utf-8" )
828+ )
829+ assert saved ["steps" ]["my-step" ] is None
830+ assert (steps_dir / "my-step" / "step.yml" ).read_text (encoding = "utf-8" ) == (
831+ package_text
832+ )
833+ assert (steps_dir / "my-step" / "__init__.py" ).read_text (encoding = "utf-8" ) == ""
834+
835+
765836def _seed_refresh_step (root : Path ) -> tuple [Path , dict ]:
766837 """Install ``my-step`` on disk the way a previous ``step add`` would have."""
767838 import json
@@ -967,7 +1038,12 @@ def _save(self):
9671038def test_step_refresh_skips_backup_when_offline_or_not_installed (
9681039 tmp_path : Path , monkeypatch
9691040):
970- """Offline and not-installed refresh delegate to install with no backup."""
1041+ """Offline refresh, and a step absent under the lock, do not back up.
1042+
1043+ Online refresh still takes the install lock to read the registry. Only
1044+ the offline gate may skip that lock; a missing locked snapshot delegates
1045+ to install with no backup.
1046+ """
9711047 import tempfile
9721048
9731049 import specify_cli
@@ -979,7 +1055,7 @@ def _add(step_id: str) -> None:
9791055 calls .append ((step_id , Path .cwd ()))
9801056
9811057 def _forbid_step_lock (* _args , ** _kwargs ):
982- raise AssertionError ("step refresh took the step lock" )
1058+ raise AssertionError ("offline step refresh took the step lock" )
9831059
9841060 real_mkdtemp = tempfile .mkdtemp
9851061
@@ -990,13 +1066,13 @@ def _mkdtemp(*args, **kwargs):
9901066 return real_mkdtemp (* args , ** kwargs )
9911067
9921068 monkeypatch .setattr (specify_cli , "workflow_step_add" , _add )
993- monkeypatch .setattr (step_installer , "_step_install_transaction" , _forbid_step_lock )
9941069 monkeypatch .setattr (tempfile , "mkdtemp" , _mkdtemp )
9951070
9961071 missing = primitive_manager ("steps" , tmp_path , allow_network = True )
9971072 missing .refresh (_component ("steps" , "missing-step" ))
9981073 assert calls == [("missing-step" , tmp_path )]
9991074
1075+ monkeypatch .setattr (step_installer , "_step_install_transaction" , _forbid_step_lock )
10001076 _seed_refresh_step (tmp_path )
10011077 offline = primitive_manager ("steps" , tmp_path , allow_network = False )
10021078 with pytest .raises (
@@ -1096,6 +1172,86 @@ def _mkdtemp(*args, **kwargs):
10961172 assert backup_dirs == []
10971173
10981174
1175+ def test_step_refresh_snapshots_step_added_after_manager_construction (
1176+ tmp_path : Path , monkeypatch
1177+ ):
1178+ """Online refresh reads presence under the lock, not the constructor registry.
1179+
1180+ The manager is built while the step is absent, so ``self._registry`` would
1181+ skip straight to non-force ``install``. A concurrent ``step add`` then
1182+ commits. Refresh must snapshot, remove, and reinstall; ``check_installable``
1183+ would reject the step if removal had not happened first.
1184+ """
1185+ import contextlib
1186+ import tempfile
1187+
1188+ import specify_cli
1189+ import specify_cli .workflows .step .command_remove as command_remove
1190+ import specify_cli .workflows .step .installer as step_installer
1191+ import typer
1192+
1193+ component = _component ("steps" , "my-step" )
1194+ manager = primitive_manager ("steps" , tmp_path , allow_network = True )
1195+ assert not manager .is_installed (component )
1196+
1197+ _seed_refresh_step (tmp_path )
1198+ assert StepRegistry (tmp_path ).is_installed ("my-step" )
1199+ assert not manager .is_installed (component )
1200+
1201+ hold = {"depth" : 0 }
1202+ remove_depths : list [int ] = []
1203+ install_calls : list [tuple [str , int ]] = []
1204+ backup_dirs : list [Path ] = []
1205+ real_txn = step_installer ._step_install_transaction
1206+ real_remove = command_remove ._remove_step_locked
1207+ real_mkdtemp = tempfile .mkdtemp
1208+
1209+ @contextlib .contextmanager
1210+ def _tracking_transaction (project_root ):
1211+ if hold ["depth" ] >= 1 :
1212+ raise AssertionError ("nested _step_install_transaction" )
1213+ hold ["depth" ] += 1
1214+ try :
1215+ with real_txn (project_root ):
1216+ yield
1217+ finally :
1218+ hold ["depth" ] -= 1
1219+
1220+ def _tracking_remove (project_root , step_id ):
1221+ remove_depths .append (hold ["depth" ])
1222+ assert backup_dirs
1223+ assert (backup_dirs [- 1 ] / step_id / "step.yml" ).is_file ()
1224+ return real_remove (project_root , step_id )
1225+
1226+ def _add (step_id : str , * args , ** kwargs ) -> None :
1227+ try :
1228+ step_installer .check_installable (tmp_path , step_id , force = False )
1229+ except step_installer .StepInstallError as exc :
1230+ raise typer .Exit (1 ) from exc
1231+ install_calls .append ((step_id , hold ["depth" ]))
1232+
1233+ def _mkdtemp (* args , ** kwargs ):
1234+ prefix = kwargs .get ("prefix" , args [0 ] if args else "" )
1235+ path = Path (real_mkdtemp (* args , ** kwargs ))
1236+ if str (prefix ).startswith ("speckit-step-refresh-" ):
1237+ backup_dirs .append (path )
1238+ return str (path )
1239+
1240+ monkeypatch .setattr (
1241+ step_installer , "_step_install_transaction" , _tracking_transaction
1242+ )
1243+ monkeypatch .setattr (command_remove , "_remove_step_locked" , _tracking_remove )
1244+ monkeypatch .setattr (specify_cli , "workflow_step_add" , _add )
1245+ monkeypatch .setattr (tempfile , "mkdtemp" , _mkdtemp )
1246+
1247+ manager .refresh (component )
1248+
1249+ assert remove_depths == [1 ]
1250+ assert install_calls == [("my-step" , 0 )]
1251+ assert backup_dirs
1252+ assert all (not path .exists () for path in backup_dirs )
1253+
1254+
10991255def _backup_root_from_note (note : str ) -> Path :
11001256 return Path (note .split ("from backup '" , 1 )[1 ].split ("'" , 1 )[0 ])
11011257
0 commit comments