@@ -1290,6 +1290,113 @@ def test_reinstall_after_keep_config_preserves_local_config(
12901290 assert local_cfg .exists ()
12911291 assert "local_override: true" in local_cfg .read_text ()
12921292
1293+ def test_reinstall_with_symlinked_config_rejects_install (
1294+ self , extension_dir , project_dir
1295+ ):
1296+ """A preserved symlinked config must abort reinstall, not be deleted."""
1297+ ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1298+ if not can_create_symlink (ext_dir .parent if ext_dir .parent .exists () else project_dir ):
1299+ pytest .skip ("Current platform/user cannot create symlinks" )
1300+
1301+ manager = ExtensionManager (project_dir )
1302+ packaged_config = extension_dir / "test-ext-config.yml"
1303+ packaged_config .write_text ("model: default-model\n " )
1304+ manager .install_from_directory (
1305+ extension_dir , "0.1.0" , register_commands = False
1306+ )
1307+
1308+ # Replace the installed config with a symlink to a file outside dest_dir.
1309+ config_file = ext_dir / "test-ext-config.yml"
1310+ external_target = project_dir / "external-config.yml"
1311+ external_target .write_text ("model: linked-model\n " )
1312+ config_file .unlink ()
1313+ os .symlink (external_target , config_file )
1314+ assert config_file .is_symlink ()
1315+
1316+ # `remove --keep-config` follows the symlink via is_file() and keeps it.
1317+ manager .remove ("test-ext" , keep_config = True )
1318+ assert not manager .registry .is_installed ("test-ext" )
1319+ assert config_file .is_symlink ()
1320+
1321+ # Plain reinstall must reject rather than silently delete the link.
1322+ with pytest .raises (ValidationError , match = "is a symlink" ):
1323+ manager .install_from_directory (
1324+ extension_dir , "0.1.0" , register_commands = False
1325+ )
1326+
1327+ # The symlink and its target survive; nothing was silently discarded.
1328+ assert config_file .is_symlink ()
1329+ assert external_target .read_text () == "model: linked-model\n "
1330+ assert not manager .registry .is_installed ("test-ext" )
1331+
1332+ def test_retry_with_symlinked_live_config_aborts_and_preserves_both (
1333+ self , extension_dir , project_dir , monkeypatch
1334+ ):
1335+ """A live config replaced by a symlink on retry is a conflict, not overwritten."""
1336+ ext_dir = project_dir / ".specify" / "extensions" / "test-ext"
1337+ if not can_create_symlink (project_dir ):
1338+ pytest .skip ("Current platform/user cannot create symlinks" )
1339+
1340+ manager = ExtensionManager (project_dir )
1341+
1342+ packaged_config = extension_dir / "test-ext-config.yml"
1343+ packaged_config .write_text ("model: default-model\n " )
1344+
1345+ manager .install_from_directory (
1346+ extension_dir , "0.1.0" , register_commands = False
1347+ )
1348+
1349+ config_file = ext_dir / "test-ext-config.yml"
1350+ config_file .write_text ("model: custom-model\n max_iterations: 99\n " )
1351+ staged_bytes = config_file .read_bytes ()
1352+
1353+ manager .remove ("test-ext" , keep_config = True )
1354+ assert not manager .registry .is_installed ("test-ext" )
1355+
1356+ staging_dir = manager ._rescue_staging_dir ("test-ext" )
1357+
1358+ original_copytree = shutil .copytree
1359+ copytree_calls = 0
1360+
1361+ def flaky_copytree (* args , ** kwargs ):
1362+ nonlocal copytree_calls
1363+ copytree_calls += 1
1364+ if copytree_calls == 1 :
1365+ dst = args [1 ]
1366+ Path (dst ).mkdir (parents = True , exist_ok = True )
1367+ (Path (dst ) / "_partial.txt" ).write_text ("partial" )
1368+ raise OSError ("simulated disk full" )
1369+ return original_copytree (* args , ** kwargs )
1370+
1371+ monkeypatch .setattr (_ext_module .shutil , "copytree" , flaky_copytree )
1372+
1373+ with pytest .raises (OSError , match = "simulated disk full" ):
1374+ manager .install_from_directory (
1375+ extension_dir , "0.1.0" , register_commands = False
1376+ )
1377+
1378+ assert staging_dir .exists ()
1379+ assert (staging_dir / ".rescue-complete" ).exists ()
1380+
1381+ # Simulate the user replacing the live config with a symlink before retry.
1382+ external_target = project_dir / "external-config.yml"
1383+ external_target .write_text ("model: newer-linked-model\n " )
1384+ config_file .unlink ()
1385+ os .symlink (external_target , config_file )
1386+ assert config_file .is_symlink ()
1387+
1388+ with pytest .raises (ValidationError , match = "Preserved extension config conflict" ):
1389+ manager .install_from_directory (
1390+ extension_dir , "0.1.0" , register_commands = False
1391+ )
1392+
1393+ # Both copies survive: the live symlink choice and the staged backup.
1394+ assert config_file .is_symlink ()
1395+ assert external_target .read_text () == "model: newer-linked-model\n "
1396+ assert staging_dir .exists ()
1397+ assert (staging_dir / "test-ext-config.yml" ).read_bytes () == staged_bytes
1398+ assert not manager .registry .is_installed ("test-ext" )
1399+
12931400 def test_copytree_failure_restores_stranded_config (
12941401 self , extension_dir , project_dir , monkeypatch
12951402 ):
0 commit comments