diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py index 3632661711..d2c419df43 100644 --- a/src/specify_cli/events.py +++ b/src/specify_cli/events.py @@ -2251,6 +2251,8 @@ def _remove_vibe_toml_entries(dst: Path) -> bool: """Remove Specify-marked Vibe TOML hook entries; delete the file if now empty. Returns True if the file was deleted (no user content remained). + + Leaves an unowned file untouched when no Specify-marked hook was removed. """ if not dst.exists(): return False @@ -2272,6 +2274,8 @@ def _remove_vibe_toml_entries(dst: Path) -> bool: existing, flags=re.DOTALL, ) + if cleaned == existing: + return False # If only whitespace/comments remain, the file had no user content stripped = "\n".join( line for line in cleaned.splitlines() diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py index 3c32891288..eff175ff5b 100644 --- a/tests/integrations/test_events.py +++ b/tests/integrations/test_events.py @@ -1046,6 +1046,75 @@ def test_owned_only_config_still_deleted_on_teardown(self, tmp_path): assert not config_path.exists() +class TestVibeTomlNoOpRemoval: + """An unowned Vibe hooks.toml must survive no-op event cleanup intact.""" + + _FIXED_MTIME_NS = 1_700_000_000_123_456_789 + + def _user_hooks_file(self, tmp_path, content): + path = tmp_path / ".vibe" / "hooks.toml" + path.parent.mkdir(parents=True) + path.write_bytes(content) + os.utime(path, ns=(self._FIXED_MTIME_NS, self._FIXED_MTIME_NS)) + return path, path.stat().st_mtime_ns + + def _assert_untouched(self, path, original, original_mtime_ns): + assert path.exists() + assert path.read_bytes() == original + assert path.stat().st_mtime_ns == original_mtime_ns + + def test_empty_events_leave_user_crlf_file_untracked_and_untouched(self, tmp_path): + from specify_cli.integrations import get_integration + + integration = get_integration("vibe") + manifest = _claude_manifest(tmp_path) + original = b'user_option = "keep"\r\nsecond_option = true' + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events(integration, tmp_path, manifest, {}) + + self._assert_untouched(path, original, original_mtime_ns) + manifest.record_existing.assert_not_called() + + def test_empty_events_preserve_comments_only_file(self, tmp_path): + from specify_cli.integrations import get_integration + + original = b"# maintained by the user\n# no hooks yet\n" + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events( + get_integration("vibe"), tmp_path, _claude_manifest(tmp_path), {} + ) + + self._assert_untouched(path, original, original_mtime_ns) + + def test_empty_events_preserve_whitespace_only_file(self, tmp_path): + from specify_cli.integrations import get_integration + + original = b"\r\n \t\r\n" + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + + install_integration_events( + get_integration("vibe"), tmp_path, _claude_manifest(tmp_path), {} + ) + + self._assert_untouched(path, original, original_mtime_ns) + + def test_forced_teardown_preserves_unowned_file_with_manifest_claim(self, tmp_path): + from specify_cli.integrations import get_integration + + integration = get_integration("vibe") + original = b'user_option = "keep"\r\n' + path, original_mtime_ns = self._user_hooks_file(tmp_path, original) + manifest = IntegrationManifest(integration.key, tmp_path, version="test") + manifest.record_existing(".vibe/hooks.toml") + manifest.save() + + integration.teardown(tmp_path, manifest, force=True) + + self._assert_untouched(path, original, original_mtime_ns) + + # -- Opencode TS Plugin merging --------------------------------------------- class TestOpencodePluginMerging: