@@ -169,7 +169,8 @@ def test_generic_skill_symlinked_directory_is_not_owned(
169169 source .write_text (source .read_text (encoding = "utf-8" ) + "\n new source\n " , encoding = "utf-8" )
170170 manager .register_enabled_extensions_for_agent ("generic" , force = True )
171171 assert manager ._generic_owned_names (
172- manager .registry .get ("sample" ), ["speckit-sample-run" ], skills = True ,
172+ manager .registry .get ("sample" ), ["speckit-sample-run" ],
173+ skills = True , extension_id = "sample" ,
173174 ) == []
174175 elif operation == "force" :
175176 with pytest .raises (ExtensionError , match = "cannot be replaced safely" ):
@@ -1453,6 +1454,71 @@ def install_update(self, _zip_path, speckit_version, *, catalog_name=None):
14531454 assert not artifact .exists ()
14541455
14551456
1457+ def test_generic_update_rollback_preserves_cross_extension_skill_link (
1458+ tmp_path , generic_extension , monkeypatch ,
1459+ ):
1460+ from typer .testing import CliRunner
1461+ from specify_cli import app
1462+
1463+ project = generic_project (tmp_path , skills = True )
1464+ manager = ExtensionManager (project )
1465+ manager .install_from_directory (generic_extension , "1.0.0" , link_commands = True )
1466+ artifact = project / ".custom/commands/speckit-sample-run/SKILL.md"
1467+ if not artifact .is_symlink ():
1468+ pytest .skip ("dev-mode symlinks are unavailable" )
1469+ other_file = manager .extensions_dir / "other/file.md"
1470+ other_file .parent .mkdir ()
1471+ original = artifact .read_bytes ()
1472+ other_file .write_bytes (original )
1473+ artifact .unlink ()
1474+ artifact .symlink_to (os .path .relpath (other_file , artifact .parent ))
1475+ metadata = manager .registry .get ("sample" )
1476+
1477+ updated_source = tmp_path / "updated-source"
1478+ shutil .copytree (generic_extension , updated_source )
1479+ manifest_path = updated_source / "extension.yml"
1480+ manifest = yaml .safe_load (manifest_path .read_text (encoding = "utf-8" ))
1481+ manifest ["extension" ]["version" ] = "2.0.0"
1482+ manifest_path .write_text (yaml .safe_dump (manifest ), encoding = "utf-8" )
1483+ archive = tmp_path / "sample-update.zip"
1484+ with zipfile .ZipFile (archive , "w" ) as zip_file :
1485+ for file in updated_source .rglob ("*" ):
1486+ if file .is_file ():
1487+ zip_file .write (file , file .relative_to (updated_source ))
1488+
1489+ original_unlink = Path .unlink
1490+ removed_user_link = []
1491+
1492+ def track_unlink (path , * args , ** kwargs ):
1493+ if path == artifact :
1494+ removed_user_link .append (path )
1495+ return original_unlink (path , * args , ** kwargs )
1496+
1497+ monkeypatch .setattr (Path , "unlink" , track_unlink )
1498+ with (
1499+ patch .object (Path , "cwd" , return_value = project ),
1500+ patch .object (ExtensionCatalog , "get_extension_info" , return_value = {
1501+ "id" : "sample" ,
1502+ "name" : "Sample" ,
1503+ "version" : "2.0.0" ,
1504+ "_install_allowed" : True ,
1505+ }),
1506+ patch .object (ExtensionCatalog , "download_extension" , return_value = archive ),
1507+ patch .object (ExtensionManager , "install_from_zip" , side_effect = RuntimeError ("update failed" )),
1508+ ):
1509+ result = CliRunner ().invoke (
1510+ app , ["extension" , "update" , "sample" ], input = "y\n " ,
1511+ )
1512+
1513+ assert result .exit_code == 1
1514+ assert "update failed" in result .output
1515+ assert not removed_user_link
1516+ assert artifact .is_symlink ()
1517+ assert artifact .resolve () == other_file .resolve ()
1518+ assert other_file .read_bytes () == original
1519+ assert ExtensionManager (project ).registry .get ("sample" ) == metadata
1520+
1521+
14561522@pytest .mark .parametrize ("skills" , [False , True ])
14571523@pytest .mark .parametrize ("collision" , [False , True ])
14581524@pytest .mark .parametrize ("multiple_prior_dirs" , [False , True ])
@@ -1723,6 +1789,46 @@ def test_generic_dev_extension_removes_links(tmp_path, generic_extension, skills
17231789 assert not artifact .is_symlink ()
17241790
17251791
1792+ @pytest .mark .parametrize ("skills" , [False , True ])
1793+ @pytest .mark .parametrize ("operation" , ["refresh" , "force" , "remove" ])
1794+ def test_generic_retargeted_cross_extension_link_is_not_owned (
1795+ tmp_path , generic_extension , skills , operation , capsys ,
1796+ ):
1797+ project = generic_project (tmp_path , skills = skills )
1798+ manager = ExtensionManager (project )
1799+ manager .install_from_directory (generic_extension , "1.0.0" , link_commands = True )
1800+ artifact = project / ".custom/commands" / (
1801+ "speckit-sample-run/SKILL.md" if skills else "speckit.sample.run.md"
1802+ )
1803+ if not artifact .is_symlink ():
1804+ pytest .skip ("dev-mode symlinks are unavailable" )
1805+ other_file = manager .extensions_dir / "other" / "file.md"
1806+ other_file .parent .mkdir ()
1807+ original = artifact .read_bytes ()
1808+ other_file .write_bytes (original )
1809+ artifact .unlink ()
1810+ artifact .symlink_to (os .path .relpath (other_file , artifact .parent ))
1811+ metadata = manager .registry .get ("sample" )
1812+
1813+ if operation == "refresh" :
1814+ source = manager .extensions_dir / "sample/commands/run.md"
1815+ source .write_text (source .read_text (encoding = "utf-8" ) + "\n new source\n " , encoding = "utf-8" )
1816+ manager .register_enabled_extensions_for_agent ("generic" , force = skills )
1817+ warning = capsys .readouterr ().out
1818+ assert "Missing" in warning and "invocation artifacts" in warning
1819+ assert manager .registry .get ("sample" ) == metadata
1820+ elif operation == "force" :
1821+ with pytest .raises (ExtensionError , match = "cannot be replaced safely" ):
1822+ manager .install_from_directory (generic_extension , "1.0.0" , force = True )
1823+ assert manager .registry .get ("sample" ) == metadata
1824+ else :
1825+ assert manager .remove ("sample" )
1826+
1827+ assert artifact .is_symlink ()
1828+ assert artifact .resolve () == other_file .resolve ()
1829+ assert other_file .read_bytes () == original
1830+
1831+
17261832@pytest .mark .parametrize ("skills" , [False , True ])
17271833def test_generic_dev_extension_preserves_edited_link (tmp_path , generic_extension , skills ):
17281834 project = generic_project (tmp_path , skills = skills )
0 commit comments