Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/specify_cli/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions tests/integrations/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down