@@ -5516,6 +5516,49 @@ def fail_plan_source(path, *args, **kwargs):
55165516 not in remaining_skill .read_text (encoding = "utf-8" )
55175517 ), "persisted partial ownership must remain removable"
55185518
5519+ def test_partial_skill_install_failure_rolls_back_persisted_writes (
5520+ self , project_dir , temp_dir , monkeypatch
5521+ ):
5522+ """Install rollback must reload partial skill ownership before removal."""
5523+ self ._write_init_options (project_dir , ai = "copilot" , ai_skills = True )
5524+ (project_dir / ".github" / "agents" ).mkdir (parents = True )
5525+ preset_dir = self ._create_multi_command_preset (
5526+ temp_dir ,
5527+ "partial-install-failure-preset" ,
5528+ ["speckit.specify" , "speckit.plan" ],
5529+ )
5530+ manager = PresetManager (project_dir )
5531+ original_read_text = Path .read_text
5532+
5533+ def fail_plan_source (path , * args , ** kwargs ):
5534+ if (
5535+ path .name == "speckit.plan.md"
5536+ and path .parent .name == "commands"
5537+ and "partial-install-failure-preset" in path .parts
5538+ ):
5539+ raise UnicodeDecodeError ("utf-8" , b"\xff " , 0 , 1 , "invalid" )
5540+ return original_read_text (path , * args , ** kwargs )
5541+
5542+ monkeypatch .setattr (Path , "read_text" , fail_plan_source )
5543+ with pytest .raises (UnicodeDecodeError ):
5544+ manager .install_from_directory (preset_dir , "0.1.5" )
5545+
5546+ assert not manager .registry .is_installed (
5547+ "partial-install-failure-preset"
5548+ )
5549+ skill_file = (
5550+ project_dir
5551+ / ".github"
5552+ / "skills"
5553+ / "speckit-specify"
5554+ / "SKILL.md"
5555+ )
5556+ assert (
5557+ not skill_file .exists ()
5558+ or "preset:partial-install-failure-preset"
5559+ not in original_read_text (skill_file , encoding = "utf-8" )
5560+ ), "rollback must not orphan a skill written before the later failure"
5561+
55195562 def test_toggle_skills_to_command_empty_result_preserves_old_skill (
55205563 self , project_dir , temp_dir
55215564 ):
@@ -7216,6 +7259,84 @@ def test_symlinked_skill_subdir_rejected_on_write(self, project_dir, temp_dir):
72167259 "the symlink itself should be left alone"
72177260 )
72187261
7262+ def test_symlinked_skill_file_rejected_on_write (self , project_dir , temp_dir ):
7263+ """Registration must not follow a symlinked SKILL.md destination."""
7264+ self ._write_init_options (project_dir , ai = "copilot" , ai_skills = True )
7265+ (project_dir / ".github" / "agents" ).mkdir (parents = True )
7266+ skill_dir = (
7267+ project_dir / ".github" / "skills" / "speckit-specify"
7268+ )
7269+ skill_dir .mkdir (parents = True )
7270+ outside_file = temp_dir / "outside-registration.md"
7271+ outside_file .write_text ("do-not-touch" , encoding = "utf-8" )
7272+ (skill_dir / "SKILL.md" ).symlink_to (outside_file )
7273+
7274+ preset_dir = self ._create_command_preset (
7275+ temp_dir ,
7276+ "symlink-file-write-preset" ,
7277+ "speckit.specify" ,
7278+ "Symlink file write" ,
7279+ "preset body" ,
7280+ )
7281+ manager = PresetManager (project_dir )
7282+ with pytest .raises (ValueError ):
7283+ manager .install_from_directory (preset_dir , "0.1.5" )
7284+
7285+ assert outside_file .read_text (encoding = "utf-8" ) == "do-not-touch"
7286+ assert (skill_dir / "SKILL.md" ).is_symlink ()
7287+
7288+ def test_symlinked_skill_file_rejected_on_override_reconcile (
7289+ self , project_dir , temp_dir
7290+ ):
7291+ """Project-override reconciliation must not follow SKILL.md symlinks."""
7292+ self ._write_init_options (project_dir , ai = "copilot" , ai_skills = True )
7293+ skill_dir = (
7294+ project_dir / ".github" / "skills" / "speckit-specify"
7295+ )
7296+ skill_dir .mkdir (parents = True )
7297+ outside_file = temp_dir / "outside-reconciliation.md"
7298+ outside_file .write_text ("do-not-touch" , encoding = "utf-8" )
7299+ (skill_dir / "SKILL.md" ).symlink_to (outside_file )
7300+
7301+ preset_dir = self ._create_command_preset (
7302+ temp_dir ,
7303+ "symlink-override-preset" ,
7304+ "speckit.specify" ,
7305+ "Preset" ,
7306+ "Preset body" ,
7307+ )
7308+ manager = PresetManager (project_dir )
7309+ manager .registry .add (
7310+ "symlink-override-preset" ,
7311+ {
7312+ "version" : "1.0.0" ,
7313+ "source" : "local" ,
7314+ "enabled" : True ,
7315+ "priority" : 10 ,
7316+ "registered_commands" : {},
7317+ "registered_skills" : {
7318+ "copilot" : ["speckit-specify" ]
7319+ },
7320+ },
7321+ )
7322+ installed_dir = (
7323+ manager .presets_dir / "symlink-override-preset"
7324+ )
7325+ shutil .copytree (preset_dir , installed_dir )
7326+ overrides_dir = (
7327+ project_dir / ".specify" / "templates" / "overrides"
7328+ )
7329+ overrides_dir .mkdir (parents = True )
7330+ (overrides_dir / "speckit.specify.md" ).write_text (
7331+ "---\n description: Override\n ---\n \n Override body\n " ,
7332+ encoding = "utf-8" ,
7333+ )
7334+
7335+ manager ._reconcile_skills (["speckit.specify" ])
7336+
7337+ assert outside_file .read_text (encoding = "utf-8" ) == "do-not-touch"
7338+ assert (skill_dir / "SKILL.md" ).is_symlink ()
7339+
72197340 def test_is_safe_registry_skill_name_rejects_unsafe_values (self , project_dir ):
72207341 """Unit-test the centralized registry skill-name boundary guard.
72217342
0 commit comments