collab-runtime 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. collab/__init__.py +77 -0
  2. collab/__main__.py +11 -0
  3. collab_runtime-0.2.9.dist-info/METADATA +218 -0
  4. collab_runtime-0.2.9.dist-info/RECORD +82 -0
  5. collab_runtime-0.2.9.dist-info/WHEEL +5 -0
  6. collab_runtime-0.2.9.dist-info/entry_points.txt +3 -0
  7. collab_runtime-0.2.9.dist-info/licenses/LICENSE +21 -0
  8. collab_runtime-0.2.9.dist-info/top_level.txt +10 -0
  9. scripts/cleanup.py +395 -0
  10. scripts/collab_git_hook.py +190 -0
  11. scripts/format_code.py +594 -0
  12. scripts/generate_tests.py +560 -0
  13. scripts/validate_code.py +1397 -0
  14. src/__init__.py +4 -0
  15. src/dashboard/index.html +1131 -0
  16. src/live_locks_watcher.py +1982 -0
  17. src/lock_client.py +4268 -0
  18. src/logging_config.py +259 -0
  19. src/main.py +436 -0
  20. tests/backend/__init__.py +0 -0
  21. tests/backend/functional/__init__.py +0 -0
  22. tests/backend/functional/test_package_imports.py +43 -0
  23. tests/backend/integration/__init__.py +0 -0
  24. tests/backend/integration/test_cli_contract_parity.py +220 -0
  25. tests/backend/performance/__init__.py +0 -0
  26. tests/backend/reliability/__init__.py +0 -0
  27. tests/backend/security/__init__.py +0 -0
  28. tests/backend/unit/live_locks_watcher/__init__.py +5 -0
  29. tests/backend/unit/live_locks_watcher/_helpers.py +123 -0
  30. tests/backend/unit/live_locks_watcher/conftest.py +18 -0
  31. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_dashboard.py +188 -0
  32. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_developer.py +56 -0
  33. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_graceful_shutdown.py +459 -0
  34. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_main.py +1925 -0
  35. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_module.py +187 -0
  36. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_multi_session.py +320 -0
  37. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_notify.py +67 -0
  38. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_parsing.py +155 -0
  39. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_process_helpers.py +684 -0
  40. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_processing.py +173 -0
  41. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_prompt_abort.py +71 -0
  42. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_reconcile.py +516 -0
  43. tests/backend/unit/live_locks_watcher/test_live_locks_watcher_scan.py +296 -0
  44. tests/backend/unit/lock_client/__init__.py +1 -0
  45. tests/backend/unit/lock_client/_helpers.py +132 -0
  46. tests/backend/unit/lock_client/test_lock_client_acquire.py +214 -0
  47. tests/backend/unit/lock_client/test_lock_client_active.py +104 -0
  48. tests/backend/unit/lock_client/test_lock_client_api.py +63 -0
  49. tests/backend/unit/lock_client/test_lock_client_cli.py +682 -0
  50. tests/backend/unit/lock_client/test_lock_client_daemon.py +3730 -0
  51. tests/backend/unit/lock_client/test_lock_client_dashboard.py +438 -0
  52. tests/backend/unit/lock_client/test_lock_client_discover.py +241 -0
  53. tests/backend/unit/lock_client/test_lock_client_force_release.py +354 -0
  54. tests/backend/unit/lock_client/test_lock_client_helper_branches.py +1890 -0
  55. tests/backend/unit/lock_client/test_lock_client_history.py +301 -0
  56. tests/backend/unit/lock_client/test_lock_client_isolation.py +316 -0
  57. tests/backend/unit/lock_client/test_lock_client_pid.py +75 -0
  58. tests/backend/unit/lock_client/test_lock_client_reconcile.py +464 -0
  59. tests/backend/unit/lock_client/test_lock_client_release.py +77 -0
  60. tests/backend/unit/lock_client/test_lock_client_shutdown.py +1110 -0
  61. tests/backend/unit/lock_client/test_lock_client_utils.py +474 -0
  62. tests/backend/unit/lock_client/test_lock_client_watch.py +866 -0
  63. tests/backend/unit/scripts/__init__.py +1 -0
  64. tests/backend/unit/scripts/_helpers.py +42 -0
  65. tests/backend/unit/scripts/test_cleanup.py +285 -0
  66. tests/backend/unit/scripts/test_collab_git_hook.py +280 -0
  67. tests/backend/unit/scripts/test_collab_git_hook_ported.py +50 -0
  68. tests/backend/unit/scripts/test_format_code.py +368 -0
  69. tests/backend/unit/scripts/test_format_code_ported.py +177 -0
  70. tests/backend/unit/scripts/test_generate_tests.py +305 -0
  71. tests/backend/unit/scripts/test_hook_templates.py +357 -0
  72. tests/backend/unit/scripts/test_setup_hook_overlay.py +95 -0
  73. tests/backend/unit/scripts/test_validate_code.py +867 -0
  74. tests/backend/unit/scripts/test_validate_code_ported.py +237 -0
  75. tests/backend/unit/test_entrypoints_main_run.py +83 -0
  76. tests/backend/unit/test_logging_config.py +529 -0
  77. tests/backend/unit/test_main_watch_pid_file.py +278 -0
  78. tests/conftest.py +167 -0
  79. tests/frontend/__init__.py +0 -0
  80. tests/frontend/jest/__init__.py +0 -0
  81. tests/frontend/playwright/__init__.py +0 -0
  82. tests/packaging/test_smoke_install.py +76 -0
@@ -0,0 +1,296 @@
1
+ """Tests for _scan_remote_locks behavior in live_locks_watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._helpers import load_watcher_module
6
+
7
+
8
+ class FakeScanClient:
9
+ """Mock Supabase client for _scan_remote_locks tests."""
10
+
11
+ def __init__(self, data=None, raise_exc=None):
12
+ self._data = data or []
13
+ self._raise_exc = raise_exc
14
+
15
+ def table(self, *args, **kwargs):
16
+ return self
17
+
18
+ def select(self, *args, **kwargs):
19
+ if self._raise_exc:
20
+ raise self._raise_exc
21
+ return self
22
+
23
+ def execute(self):
24
+ if self._raise_exc:
25
+ raise self._raise_exc
26
+
27
+ class Resp:
28
+ data = self._data
29
+
30
+ return Resp()
31
+
32
+
33
+ def test_scan_remote_locks_warns_about_other_devs(monkeypatch, caplog):
34
+ mod = load_watcher_module()
35
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
36
+ mod._warned_remote_locks.clear()
37
+ monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
38
+
39
+ client = FakeScanClient(
40
+ data=[
41
+ {"file_path": "src/app.py", "developer_id": "bob"},
42
+ ]
43
+ )
44
+
45
+ import logging
46
+
47
+ with caplog.at_level(logging.WARNING, logger="collab.pycharm_watcher"):
48
+ mod._scan_remote_locks(client)
49
+
50
+ assert "src/app.py" in mod._warned_remote_locks
51
+ assert any("REMOTE LOCK" in r.message for r in caplog.records)
52
+ mod._warned_remote_locks.clear()
53
+
54
+
55
+ def test_scan_remote_locks_skips_own_locks(monkeypatch):
56
+ mod = load_watcher_module()
57
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
58
+ mod._warned_remote_locks.clear()
59
+
60
+ client = FakeScanClient(data=[{"file_path": "src/app.py", "developer_id": "alice"}])
61
+ mod._scan_remote_locks(client)
62
+
63
+ assert "src/app.py" not in mod._warned_remote_locks
64
+ mod._warned_remote_locks.clear()
65
+
66
+
67
+ def test_scan_remote_locks_clears_released_warnings(monkeypatch, caplog):
68
+ mod = load_watcher_module()
69
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
70
+ mod._warned_remote_locks.clear()
71
+ monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
72
+
73
+ # First scan: bob holds a lock
74
+ client_with_lock = FakeScanClient(
75
+ data=[{"file_path": "src/app.py", "developer_id": "bob"}]
76
+ )
77
+ mod._scan_remote_locks(client_with_lock)
78
+ assert "src/app.py" in mod._warned_remote_locks
79
+
80
+ # Second scan: lock released
81
+ import logging
82
+
83
+ with caplog.at_level(logging.INFO, logger="collab.pycharm_watcher"):
84
+ client_empty = FakeScanClient(data=[])
85
+ mod._scan_remote_locks(client_empty)
86
+
87
+ assert "src/app.py" not in mod._warned_remote_locks
88
+ assert any("Remote lock cleared" in r.message for r in caplog.records)
89
+ mod._warned_remote_locks.clear()
90
+
91
+
92
+ def test_scan_remote_locks_no_duplicate_warnings(monkeypatch):
93
+ mod = load_watcher_module()
94
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
95
+ mod._warned_remote_locks.clear()
96
+ notify_calls = []
97
+ monkeypatch.setattr(mod, "_notify", lambda t, m: notify_calls.append((t, m)))
98
+
99
+ client = FakeScanClient(data=[{"file_path": "src/app.py", "developer_id": "bob"}])
100
+
101
+ mod._scan_remote_locks(client)
102
+ mod._scan_remote_locks(client) # second call — should NOT notify again
103
+
104
+ assert len(notify_calls) == 1
105
+ mod._warned_remote_locks.clear()
106
+
107
+
108
+ def test_scan_remote_locks_handles_exception(monkeypatch):
109
+ mod = load_watcher_module()
110
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
111
+ mod._warned_remote_locks.clear()
112
+
113
+ client = FakeScanClient(raise_exc=RuntimeError("network down"))
114
+ mod._scan_remote_locks(client) # should not raise
115
+
116
+ assert len(mod._warned_remote_locks) == 0
117
+
118
+
119
+ def test_scan_remote_locks_skips_empty_file_path(monkeypatch):
120
+ mod = load_watcher_module()
121
+ monkeypatch.setattr(mod, "DEVELOPER_ID", "alice")
122
+ mod._warned_remote_locks.clear()
123
+ monkeypatch.setattr(mod, "_notify", lambda *a, **k: None)
124
+
125
+ client = FakeScanClient(data=[{"file_path": "", "developer_id": "bob"}])
126
+ mod._scan_remote_locks(client)
127
+
128
+ assert len(mod._warned_remote_locks) == 0
129
+
130
+
131
+ def test_scan_remote_locks_client_exception(monkeypatch):
132
+ mod = load_watcher_module()
133
+
134
+ class FakeResp:
135
+ def __init__(self, data=None):
136
+ self.data = data
137
+
138
+ class FakeClient:
139
+ def __init__(self, data=None, explode=False):
140
+ self._data = data
141
+ self._explode = explode
142
+
143
+ def table(self, *a, **k):
144
+ return self
145
+
146
+ def select(self, *a, **k):
147
+ return self
148
+
149
+ def execute(self):
150
+ if self._explode:
151
+ raise RuntimeError("backend down")
152
+ return FakeResp(self._data)
153
+
154
+ fake = FakeClient(explode=True)
155
+ mod._warned_remote_locks.clear()
156
+ mod._known_remote_locks.clear()
157
+ mod._scan_remote_locks(fake)
158
+
159
+
160
+ def test_scan_remote_locks_warns_for_other_owner(monkeypatch):
161
+ mod = load_watcher_module()
162
+ fake_data = [
163
+ {
164
+ "developer_id": "other_user",
165
+ "file_path": "src/locked.txt",
166
+ "branch_name": None,
167
+ "reason": None,
168
+ }
169
+ ]
170
+ fake = type(
171
+ "C",
172
+ (),
173
+ {
174
+ "_data": fake_data,
175
+ "table": lambda self, *a, **k: self,
176
+ "select": lambda self, *a, **k: self,
177
+ "execute": lambda self: type("R", (), {"data": self._data})(),
178
+ },
179
+ )()
180
+ mod.DEVELOPER_ID = "me"
181
+ mod._warned_remote_locks.clear()
182
+ mod._known_remote_locks.clear()
183
+ mod._scan_remote_locks(fake)
184
+ assert "src/locked.txt" in mod._warned_remote_locks
185
+
186
+
187
+ def test_scan_remote_locks_same_owner_updates_known():
188
+ mod = load_watcher_module()
189
+ fake_data = [
190
+ {
191
+ "developer_id": "me",
192
+ "file_path": "src/mine.txt",
193
+ "branch_name": None,
194
+ "reason": None,
195
+ }
196
+ ]
197
+
198
+ class FakeClient3:
199
+ def __init__(self, data):
200
+ self._data = data
201
+
202
+ def table(self, *a, **k):
203
+ return self
204
+
205
+ def select(self, *a, **k):
206
+ return self
207
+
208
+ def execute(self):
209
+ class R:
210
+ data = self._data
211
+
212
+ return R()
213
+
214
+ fake = FakeClient3(fake_data)
215
+ mod.DEVELOPER_ID = "me"
216
+ mod._known_remote_locks.clear()
217
+ mod._warned_remote_locks.clear()
218
+ mod._scan_remote_locks(fake)
219
+ assert "src/mine.txt" in mod._known_remote_locks
220
+
221
+
222
+ # ---- Auto-migrated from migrated_remaining ----
223
+
224
+
225
+ def test_scan_remote_locks_skips_local_owned(monkeypatch):
226
+ mod = load_watcher_module()
227
+ # If a remote lock entry belongs to our developer and we already have it in
228
+ # _local_owned_locks, the scan should skip notifications for it.
229
+ fake_data = [
230
+ {
231
+ "developer_id": "me",
232
+ "file_path": "src/owned.txt",
233
+ "branch_name": None,
234
+ "reason": None,
235
+ }
236
+ ]
237
+
238
+ class FakeClientLocal:
239
+ def __init__(self, data=None):
240
+ self._data = data
241
+
242
+ def table(self, *a, **k):
243
+ return self
244
+
245
+ def select(self, *a, **k):
246
+ return self
247
+
248
+ def execute(self):
249
+ class R:
250
+ data = self._data
251
+
252
+ return R()
253
+
254
+ fake = FakeClientLocal(data=fake_data)
255
+ mod.DEVELOPER_ID = "me"
256
+ mod._local_owned_locks.clear()
257
+ mod._local_owned_locks.add("src/owned.txt")
258
+ mod._warned_remote_locks.clear()
259
+ mod._known_remote_locks.clear()
260
+
261
+ # Should not raise and should not add to _warned_remote_locks
262
+ mod._scan_remote_locks(fake)
263
+ assert "src/owned.txt" not in mod._warned_remote_locks
264
+
265
+
266
+ def test_scan_remote_locks_removed_discards_local_owned(monkeypatch):
267
+ mod = load_watcher_module()
268
+ # Simulate a previously-known remote lock that was released; if we had it
269
+ # recorded locally, the code path should discard it from _local_owned_locks.
270
+ mod._known_remote_locks.clear()
271
+ mod._known_remote_locks.add("src/released.txt")
272
+ mod._local_owned_locks.clear()
273
+ mod._local_owned_locks.add("src/released.txt")
274
+
275
+ # Fake client returns no locks (empty list)
276
+ class EmptyClient:
277
+ def table(self, *a, **k):
278
+ return self
279
+
280
+ def select(self, *a, **k):
281
+ return self
282
+
283
+ def execute(self):
284
+ class R:
285
+ data = []
286
+
287
+ return R()
288
+
289
+ fake = EmptyClient()
290
+
291
+ mod._scan_remote_locks(fake)
292
+ # After scanning, released lock should be removed from local-owned set
293
+ assert "src/released.txt" not in mod._local_owned_locks
294
+
295
+
296
+ watcher = load_watcher_module()
@@ -0,0 +1 @@
1
+ # lock_client test package
@@ -0,0 +1,132 @@
1
+ """Shared helpers for LockClient tests in the lock_client test package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import importlib.util
7
+ import sys
8
+ from pathlib import Path
9
+ from unittest import mock
10
+
11
+ # Ensure supabase is mocked so importing the module doesn't raise if supabase
12
+ # isn't installed in the test environment.
13
+ sys.modules.setdefault("supabase", mock.MagicMock())
14
+
15
+
16
+ def _find_lock_client_path() -> Path:
17
+ p = Path(__file__).resolve()
18
+ for parent in p.parents:
19
+ candidate = parent / "src" / "lock_client.py"
20
+ if candidate.exists():
21
+ return candidate
22
+ candidate = Path(__file__).resolve().parents[4] / "src" / "lock_client.py"
23
+ if candidate.exists():
24
+ return candidate
25
+ raise FileNotFoundError("lock_client.py not found in repo")
26
+
27
+
28
+ def load_lock_client_module():
29
+ candidate = _find_lock_client_path()
30
+ repo_root = candidate.parents[2]
31
+ root_str = str(repo_root)
32
+ if root_str not in sys.path:
33
+ sys.path.insert(0, root_str)
34
+
35
+ # Always import via package machinery so src.main and tests share the
36
+ # exact same module object for monkeypatching.
37
+ if "src.lock_client" in sys.modules:
38
+ return sys.modules["src.lock_client"]
39
+ return importlib.import_module("src.lock_client")
40
+
41
+
42
+ def _load_lock_client_module():
43
+ """Backward-compatible alias for older tests that call `_load_lock_client_module()`.
44
+
45
+ Prefer `load_lock_client_module()` in new tests.
46
+ """
47
+ return load_lock_client_module()
48
+
49
+
50
+ def _find_watcher_path() -> Path:
51
+ p = Path(__file__).resolve()
52
+ for parent in p.parents:
53
+ candidate = parent / "src" / "live_locks_watcher.py"
54
+ if candidate.exists():
55
+ return candidate
56
+ candidate = Path(__file__).resolve().parents[5] / "src" / "live_locks_watcher.py"
57
+ if candidate.exists():
58
+ return candidate
59
+ raise FileNotFoundError("live_locks_watcher.py not found in repo")
60
+
61
+
62
+ def load_watcher_module():
63
+ candidate = _find_watcher_path()
64
+ spec = importlib.util.spec_from_file_location(
65
+ "src.live_locks_watcher", str(candidate)
66
+ )
67
+ mod = importlib.util.module_from_spec(spec)
68
+ assert spec and spec.loader
69
+ spec.loader.exec_module(mod) # type: ignore[arg-type]
70
+ return mod
71
+
72
+
73
+ class FakeResponse:
74
+ def __init__(self, status=200, data=None, error=None):
75
+ self.status = status
76
+ self.data = data if data is not None else []
77
+ self.error = error
78
+
79
+
80
+ class FakeClient:
81
+ def __init__(self, resp):
82
+ self._resp = resp
83
+
84
+ def rpc(self, *a, **k):
85
+ return self
86
+
87
+ def table(self, *a, **k):
88
+ return self
89
+
90
+ def select(self, *a, **k):
91
+ return self
92
+
93
+ def delete(self, *a, **k):
94
+ return self
95
+
96
+ def eq(self, *a, **k):
97
+ return self
98
+
99
+ def insert(self, *a, **k):
100
+ return self
101
+
102
+ def update(self, *a, **k):
103
+ return self
104
+
105
+ def order(self, *a, **k):
106
+ return self
107
+
108
+ def limit(self, *a, **k):
109
+ return self
110
+
111
+ def ilike(self, *a, **k):
112
+ return self
113
+
114
+ def execute(self):
115
+ return self._resp
116
+
117
+
118
+ def make_create_client(resp):
119
+ def fake_create(url, key):
120
+ return FakeClient(resp)
121
+
122
+ return fake_create
123
+
124
+
125
+ def make_get_create_client(resp):
126
+ def fake_get_create_client():
127
+ def create_client(url, key):
128
+ return FakeClient(resp)
129
+
130
+ return create_client
131
+
132
+ return fake_get_create_client
@@ -0,0 +1,214 @@
1
+ """Acquire-related tests for LockClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._helpers import (
6
+ FakeClient,
7
+ FakeResponse,
8
+ load_lock_client_module,
9
+ make_create_client,
10
+ make_get_create_client,
11
+ )
12
+
13
+ mod = load_lock_client_module()
14
+
15
+
16
+ def test_acquire_multiple_success(monkeypatch, tmp_path):
17
+ """Test batch lock acquisition."""
18
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
19
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
20
+
21
+ file1 = tmp_path / "src" / "app.py"
22
+ file2 = tmp_path / "src" / "routes.py"
23
+ file1.parent.mkdir(parents=True)
24
+ file1.write_text("# code")
25
+ file2.write_text("# code")
26
+
27
+ response = FakeResponse(status=200, data=[{"status": "ok", "lock_id": "batch123"}])
28
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
29
+
30
+ lc = mod.LockClient(developer_id="test_user")
31
+ ok, failed, msg = lc.acquire_multiple([str(file1), str(file2)])
32
+ assert isinstance(ok, bool)
33
+ assert isinstance(failed, list)
34
+
35
+
36
+ # RESTORED: test_acquire_and_release_ephemeral (migrated from additional)
37
+ def test_acquire_and_release_ephemeral(tmp_path, monkeypatch):
38
+ # Create a temp file to acquire
39
+ f = tmp_path / "file.txt"
40
+ f.write_text("x")
41
+
42
+ client = object.__new__(mod.LockClient)
43
+ # Simulate ephemeral developer
44
+ client.developer_id = "test_dev_123"
45
+ client._is_ephemeral = True
46
+
47
+ ok, token = mod.LockClient.acquire(client, str(f))
48
+ assert ok and token.startswith("ephemeral-")
49
+
50
+ ok_rel, msg = mod.LockClient.release(client, str(f))
51
+ assert ok_rel and "ephemeral" in msg
52
+
53
+
54
+ # RESTORED: test_acquire_api_exception (migrated from monolith)
55
+ def test_acquire_api_exception(monkeypatch, tmp_path):
56
+ """Test acquire when API call raises an exception."""
57
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
58
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
59
+
60
+ test_file = tmp_path / "src" / "app.py"
61
+ test_file.parent.mkdir(parents=True)
62
+ test_file.write_text("# code")
63
+
64
+ class ExplodingClient(FakeClient):
65
+ def execute(self):
66
+ raise RuntimeError("API exploded")
67
+
68
+ monkeypatch.setattr(
69
+ mod,
70
+ "_get_create_client",
71
+ lambda: lambda url, key: ExplodingClient(FakeResponse()),
72
+ )
73
+ monkeypatch.setattr(mod, "_PROJECT_ROOT", str(tmp_path))
74
+
75
+ lc = mod.LockClient(developer_id="test_user")
76
+ ok, msg = lc.acquire(str(test_file))
77
+ assert ok is False
78
+ assert "API Error" in msg
79
+
80
+
81
+ # RESTORED: test_acquire_missing_file_returns_false (migrated from monolith)
82
+ def test_acquire_missing_file_returns_false(monkeypatch):
83
+ monkeypatch.setenv("SUPABASE_URL", "https://example.invalid")
84
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "anon:fake")
85
+ monkeypatch.setattr(
86
+ mod,
87
+ "_get_create_client",
88
+ make_get_create_client({"status": 200, "data": []}),
89
+ )
90
+
91
+ lc = mod.LockClient(developer_id="tester")
92
+ ok, msg = lc.acquire("this/path/does/not/exist.file")
93
+ assert not ok
94
+ assert "does not exist" in msg
95
+
96
+
97
+ def test_acquire_missing_file_allowed_when_in_progress(monkeypatch):
98
+ """Deleted paths remain lockable when git marks them in progress."""
99
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
100
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
101
+
102
+ response = FakeResponse(status=200, data=[{"status": "ok"}])
103
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
104
+
105
+ lc = mod.LockClient(developer_id="tester")
106
+ target = ".github/workflows/validate-on-pr.yml"
107
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", lambda: [target])
108
+
109
+ ok, token = lc.acquire(target)
110
+ assert ok is True
111
+ assert isinstance(token, str) and len(token) > 0
112
+
113
+
114
+ def test_acquire_missing_file_in_progress_lookup_error(monkeypatch):
115
+ """If in-progress detection fails, missing paths are rejected safely."""
116
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
117
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
118
+ monkeypatch.setattr(
119
+ mod,
120
+ "_get_create_client",
121
+ make_get_create_client({"status": 200, "data": []}),
122
+ )
123
+
124
+ lc = mod.LockClient(developer_id="tester")
125
+
126
+ def _boom():
127
+ raise RuntimeError("git scan failed")
128
+
129
+ monkeypatch.setattr(lc, "_get_modified_and_unpushed_files", _boom)
130
+ ok, msg = lc.acquire("deleted/path.py")
131
+ assert ok is False
132
+ assert "does not exist" in msg
133
+
134
+
135
+ def test_acquire_directory_returns_false(monkeypatch, tmp_path):
136
+ """Directory paths are not lockable."""
137
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
138
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
139
+ monkeypatch.setattr(
140
+ mod, "_get_create_client", make_get_create_client({"status": 200, "data": []})
141
+ )
142
+
143
+ instance_dir = tmp_path / "apps" / "reporting" / "instance"
144
+ instance_dir.mkdir(parents=True)
145
+ monkeypatch.setattr(mod, "_PROJECT_ROOT", str(tmp_path))
146
+
147
+ lc = mod.LockClient(developer_id="tester")
148
+ ok, msg = lc.acquire("apps/reporting/instance")
149
+ assert ok is False
150
+ assert "directory" in msg.lower()
151
+
152
+
153
+ def test_acquire_ephemeral(tmp_path):
154
+ """Acquire a file with ephemeral developer ID (local_only)."""
155
+ mod = load_lock_client_module()
156
+ f = tmp_path / "a.txt"
157
+ f.write_text("x")
158
+ client = mod.LockClient(local_only=True, developer_id="test_dev_joe")
159
+ ok, token = client.acquire(str(f))
160
+ assert ok is True
161
+ assert isinstance(token, str) and token.startswith("ephemeral-")
162
+
163
+
164
+ def test_acquire_success_and_conflict(tmp_path):
165
+ """Acquire success and conflict scenarios via supabase client."""
166
+ mod = load_lock_client_module()
167
+ f = tmp_path / "b.txt"
168
+ f.write_text("x")
169
+
170
+ # Success
171
+ client = mod.LockClient(local_only=True, developer_id="alice")
172
+ client._client = FakeClient(FakeResponse(status=200, data=[{"status": "ok"}]))
173
+ ok, token = client.acquire(str(f))
174
+ assert ok is True
175
+ assert isinstance(token, str) and len(token) == 16
176
+
177
+ # Conflict
178
+ client2 = mod.LockClient(local_only=True, developer_id="bob")
179
+ client2._client = FakeClient(
180
+ FakeResponse(status=200, data=[{"status": "conflict", "owner": "eve"}])
181
+ )
182
+ ok2, msg2 = client2.acquire(str(f))
183
+ assert ok2 is False
184
+ assert "@eve" in msg2
185
+
186
+
187
+ def test_acquire_api_error(tmp_path):
188
+ """Acquire when supabase returns a 500 error."""
189
+ mod = load_lock_client_module()
190
+ f = tmp_path / "c.txt"
191
+ f.write_text("x")
192
+ client = mod.LockClient(local_only=True, developer_id="carol")
193
+ client._client = FakeClient(
194
+ FakeResponse(status=500, data=None, error={"message": "rpc fail"})
195
+ )
196
+ ok, msg = client.acquire(str(f))
197
+ assert ok is False
198
+ assert "API Error" in msg
199
+
200
+
201
+ def test_acquire_unexpected_response(tmp_path):
202
+ """Cover line 738 unexpected-response path.
203
+
204
+ acquire returns "Unexpected response" when status is not 200/201 and data is empty.
205
+ """
206
+ mod = load_lock_client_module()
207
+ f = tmp_path / "u.txt"
208
+ f.write_text("x")
209
+ client = mod.LockClient(local_only=True, developer_id="user1")
210
+ # Return empty data list and non-200/201 status to trigger line 738
211
+ client._client = FakeClient(FakeResponse(status=503, data=[], error=None))
212
+ ok, msg = client.acquire(str(f))
213
+ assert ok is False
214
+ assert "Unexpected response" in msg