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,464 @@
1
+ """Reconcile and git-status parsing tests for LockClient._reconcile()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+
8
+ from ._helpers import (
9
+ FakeClient,
10
+ FakeResponse,
11
+ load_lock_client_module,
12
+ make_create_client,
13
+ )
14
+
15
+ mod = load_lock_client_module()
16
+
17
+
18
+ def test_reconcile_stale_locks(monkeypatch, tmp_path):
19
+ """Test _reconcile releases stale locks and acquires missing ones."""
20
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
21
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
22
+
23
+ monkeypatch.setattr(
24
+ mod.LockClient,
25
+ "_run_git_status",
26
+ staticmethod(lambda: " M src/new.py"),
27
+ )
28
+
29
+ locks_data = [
30
+ {"file_path": "src/old.py", "developer_id": "test_user"},
31
+ {"file_path": "src/new.py", "developer_id": "test_user"},
32
+ ]
33
+ response = FakeResponse(status=200, data=locks_data)
34
+ monkeypatch.setattr(mod, "_get_create_client", lambda: make_create_client(response))
35
+
36
+ lc = mod.LockClient(developer_id="test_user")
37
+ result = lc._reconcile()
38
+ assert "src/new.py" in result
39
+
40
+
41
+ def test_reconcile_git_error(monkeypatch, tmp_path):
42
+ """Test _reconcile handles git status errors."""
43
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
44
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
45
+
46
+ def error_git_status():
47
+ raise RuntimeError("Git broken")
48
+
49
+ monkeypatch.setattr(
50
+ mod.LockClient, "_run_git_status", staticmethod(error_git_status)
51
+ )
52
+ monkeypatch.setattr(
53
+ mod, "_get_create_client", lambda: make_create_client(FakeResponse())
54
+ )
55
+
56
+ lc = mod.LockClient(developer_id="test_user")
57
+ result = lc._reconcile()
58
+ assert isinstance(result, set)
59
+
60
+
61
+ def test_reconcile_supabase_error(monkeypatch, tmp_path):
62
+ """Test _reconcile handles Supabase errors."""
63
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
64
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
65
+
66
+ monkeypatch.setattr(
67
+ mod.LockClient, "_run_git_status", staticmethod(lambda: " M src/app.py")
68
+ )
69
+
70
+ class ErrorClient(FakeClient):
71
+ def execute(self):
72
+ raise RuntimeError("Supabase down")
73
+
74
+ monkeypatch.setattr(
75
+ mod, "_get_create_client", lambda: lambda url, key: ErrorClient(FakeResponse())
76
+ )
77
+
78
+ lc = mod.LockClient(developer_id="test_user")
79
+ result = lc._reconcile()
80
+ assert "src/app.py" in result
81
+
82
+
83
+ def test_run_git_status(monkeypatch):
84
+ """Test _run_git_status runs git command."""
85
+
86
+ def mock_check_output(cmd, *args, **kwargs):
87
+ return b" M src/app.py\n M src/routes.py\n"
88
+
89
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
90
+ result = mod.LockClient._run_git_status()
91
+ assert "src/app.py" in result
92
+
93
+
94
+ def test_parse_git_status_path_simple():
95
+ """Test parsing simple modified file."""
96
+ assert mod.LockClient._parse_git_status_path(" M src/app.py") == "src/app.py"
97
+
98
+
99
+ def test_parse_git_status_path_rename():
100
+ """Test parsing renamed file."""
101
+ result = mod.LockClient._parse_git_status_path("R old.py -> new.py")
102
+ assert result == "new.py"
103
+
104
+
105
+ def test_parse_git_status_path_quoted():
106
+ """Test parsing quoted paths."""
107
+ result = mod.LockClient._parse_git_status_path('M "src/my file.py"')
108
+ assert "my file" in result
109
+
110
+
111
+ def test_should_ignore_path_for_instance_runtime_dirs():
112
+ """Runtime instance folders must never be lock candidates."""
113
+ assert mod.LockClient._should_ignore_path("instance") is True
114
+ assert mod.LockClient._should_ignore_path("apps/reporting/instance/") is True
115
+ assert mod.LockClient._should_ignore_path("apps/reporting/instance") is True
116
+ assert mod.LockClient._should_ignore_path("apps/planning/instance/state.db") is True
117
+ assert mod.LockClient._should_ignore_path("src/services/db_utils.py") is False
118
+
119
+
120
+ def test_run_git_status_unix(monkeypatch):
121
+ mod_local = load_lock_client_module()
122
+ monkeypatch.setattr(sys, "platform", "linux")
123
+
124
+ def fake_check_output(args, *a, **k):
125
+ return b" M src/foo.py\n"
126
+
127
+ monkeypatch.setattr(subprocess, "check_output", fake_check_output)
128
+ out = mod_local.LockClient._run_git_status()
129
+ assert "src/foo.py" in out
130
+
131
+
132
+ def test_reconcile_supabase_lock_query_error(monkeypatch, tmp_path):
133
+ """Test _reconcile handles Supabase lock query error."""
134
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
135
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
136
+
137
+ monkeypatch.setattr(
138
+ mod.LockClient,
139
+ "_run_git_status",
140
+ staticmethod(lambda: " M src/app.py"),
141
+ )
142
+
143
+ call_count = [0]
144
+
145
+ class SelectiveErrorClient:
146
+ """Errors only on the second execute call (active locks)."""
147
+
148
+ def __init__(self, resp):
149
+ self._resp = resp
150
+
151
+ def rpc(self, *args, **kwargs):
152
+ return self
153
+
154
+ def table(self, *args, **kwargs):
155
+ return self
156
+
157
+ def select(self, *args, **kwargs):
158
+ call_count[0] += 1
159
+ if call_count[0] >= 1:
160
+ raise RuntimeError("Supabase query failed")
161
+ return self
162
+
163
+ def delete(self, *args, **kwargs):
164
+ return self
165
+
166
+ def eq(self, *args, **kwargs):
167
+ return self
168
+
169
+ def execute(self):
170
+ return self._resp
171
+
172
+ monkeypatch.setattr(
173
+ mod,
174
+ "_get_create_client",
175
+ lambda: lambda url, key: SelectiveErrorClient(FakeResponse()),
176
+ )
177
+
178
+ # Make modified-files detection deterministic for tests
179
+ def _fixed_modified(self):
180
+ return ["src/app.py"]
181
+
182
+ monkeypatch.setattr(
183
+ mod.LockClient, "_get_modified_and_unpushed_files", _fixed_modified
184
+ )
185
+
186
+ lc = mod.LockClient(developer_id="test_user")
187
+ result = lc._reconcile()
188
+ assert "src/app.py" in result
189
+
190
+
191
+ def test_get_current_branch_error_lock_client(monkeypatch):
192
+ """Test _get_current_branch returns None when git command fails."""
193
+
194
+ def mock_check_output(cmd, *args, **kwargs):
195
+ raise subprocess.CalledProcessError(128, cmd)
196
+
197
+ monkeypatch.setattr(subprocess, "check_output", mock_check_output)
198
+
199
+ result = mod.LockClient._get_current_branch()
200
+ assert result is None
201
+
202
+
203
+ def test_get_current_branch_win32(monkeypatch):
204
+ """Ensure _get_current_branch uses the Windows code path when platform is win32."""
205
+ monkeypatch.setattr(sys, "platform", "win32")
206
+
207
+ def fake_check_output(cmd, *a, **k):
208
+ return b"feature/win-branch\n"
209
+
210
+ monkeypatch.setattr(subprocess, "check_output", fake_check_output)
211
+ got = mod.LockClient._get_current_branch()
212
+ assert got == "feature/win-branch"
213
+
214
+
215
+ def test_get_current_branch_non_win_error(monkeypatch):
216
+ """When git command fails, _get_current_branch should return None."""
217
+ monkeypatch.setattr(sys, "platform", "linux")
218
+
219
+ def fail_check_output(cmd, *a, **k):
220
+ raise subprocess.CalledProcessError(2, cmd)
221
+
222
+ monkeypatch.setattr(subprocess, "check_output", fail_check_output)
223
+ got = mod.LockClient._get_current_branch()
224
+ assert got is None
225
+
226
+
227
+ def test_parse_git_status_path_unicode_escape():
228
+ """Test _parse_git_status_path with unicode-escaped quoted path."""
229
+ result = mod.LockClient._parse_git_status_path(' M "src/file.py"')
230
+ assert "file" in result
231
+
232
+
233
+ def test_parse_git_status_path_bad_unicode_escape():
234
+ """Test _parse_git_status_path with invalid unicode escape."""
235
+ result = mod.LockClient._parse_git_status_path(' M "src/\\xZZfile.py"')
236
+ assert "file" in result
237
+
238
+
239
+ def test_reconcile_returns_my_locks(monkeypatch):
240
+ """_reconcile returns set containing only current developer locks."""
241
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
242
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
243
+
244
+ data = [
245
+ {"file_path": "src/app.py", "developer_id": "test_user"},
246
+ {"file_path": "src/other.py", "developer_id": "other_dev"},
247
+ ]
248
+ monkeypatch.setattr(
249
+ mod,
250
+ "_get_create_client",
251
+ lambda: make_create_client(FakeResponse(status=200, data=data)),
252
+ )
253
+ monkeypatch.setattr(
254
+ mod.LockClient, "_run_git_status", staticmethod(lambda: " M src/app.py\n")
255
+ )
256
+ monkeypatch.setattr(
257
+ mod.LockClient,
258
+ "_get_modified_and_unpushed_files",
259
+ lambda self: ["src/app.py"],
260
+ )
261
+
262
+ client = mod.LockClient(developer_id="test_user")
263
+ result = client._reconcile()
264
+ assert "src/app.py" in result
265
+ assert "src/other.py" not in result
266
+
267
+
268
+ def test_git_status_parsing_and_modified(monkeypatch):
269
+ sample = " M src/a.py\nR src/old.py -> src/new.py\n?? src/new_file.py\n"
270
+ monkeypatch.setattr(mod.LockClient, "_run_git_status", staticmethod(lambda: sample))
271
+
272
+ c = mod.LockClient(local_only=True)
273
+ out = c._get_modified_and_unpushed_files()
274
+ assert "src/a.py" in out
275
+ assert "src/new.py" in out
276
+
277
+
278
+ def test_reconcile_modified_files_error_active_fallback_error(monkeypatch):
279
+ """If modified detection fails and active() also fails, reconcile returns empty
280
+ set."""
281
+ c = mod.LockClient(local_only=True, developer_id="alice")
282
+
283
+ def _boom_modified():
284
+ raise RuntimeError("git exploded")
285
+
286
+ def _boom_active():
287
+ raise RuntimeError("active exploded")
288
+
289
+ monkeypatch.setattr(c, "_get_modified_and_unpushed_files", _boom_modified)
290
+ monkeypatch.setattr(c, "active", _boom_active)
291
+
292
+ assert c._reconcile() == set()
293
+
294
+
295
+ def test_reconcile_handles_resume_multi_refresh_and_summary_cleanup_paths(monkeypatch):
296
+ """Drive reconcile through resume/multi/refresh categories and cleanup error
297
+ path."""
298
+ c = mod.LockClient(local_only=True, developer_id="alice")
299
+
300
+ # Modified files: one resumed, one multi-session, one refreshed (no token),
301
+ # and one missing.
302
+ monkeypatch.setattr(
303
+ c,
304
+ "_get_modified_and_unpushed_files",
305
+ lambda: ["a.py", "b.py", "c.py", "d.py"],
306
+ )
307
+
308
+ active_rows = [
309
+ {"file_path": "a.py", "developer_id": "alice", "lock_token": "tok-current"},
310
+ {"file_path": "b.py", "developer_id": "alice", "lock_token": "tok-other"},
311
+ {"file_path": "c.py", "developer_id": "alice", "lock_token": ""},
312
+ {"file_path": "stale.py", "developer_id": "alice", "lock_token": "tok-current"},
313
+ ]
314
+ monkeypatch.setattr(c, "active", lambda: active_rows)
315
+ monkeypatch.setattr(c, "_get_session_token", lambda: "tok-current")
316
+ monkeypatch.setattr(c, "_is_same_machine_token", lambda t: t == "tok-current")
317
+
318
+ # Force resumed token update exception branch
319
+ class _FailingUpdateClient:
320
+ def table(self, name):
321
+ return self
322
+
323
+ def update(self, *a, **k):
324
+ return self
325
+
326
+ def eq(self, *a, **k):
327
+ return self
328
+
329
+ def execute(self):
330
+ raise RuntimeError("update failed")
331
+
332
+ c._client = _FailingUpdateClient()
333
+
334
+ released = []
335
+ acquired_calls = []
336
+ monkeypatch.setattr(c, "release_multiple", lambda fps: released.extend(sorted(fps)))
337
+ monkeypatch.setattr(
338
+ c,
339
+ "acquire_multiple",
340
+ lambda fps, branch_name=None, reason=None: acquired_calls.append(
341
+ (sorted(fps), branch_name, reason)
342
+ ),
343
+ )
344
+ monkeypatch.setattr(c, "_get_current_branch", lambda: "main")
345
+
346
+ # Trigger summary write + repo-summary write failure, then marker cleanup
347
+ # remove failure.
348
+ real_open = open
349
+
350
+ def _open_side_effect(path, mode="r", *args, **kwargs):
351
+ p = str(path)
352
+ if p.endswith(".collab\\.startup_summary.json") and "w" in mode:
353
+ raise RuntimeError("repo summary write failed")
354
+ return real_open(path, mode, *args, **kwargs)
355
+
356
+ monkeypatch.setattr(mod, "open", _open_side_effect, raising=False)
357
+ monkeypatch.setattr(mod.os.path, "exists", lambda p: True)
358
+ monkeypatch.setattr(
359
+ mod.os, "remove", lambda p: (_ for _ in ()).throw(RuntimeError("remove failed"))
360
+ )
361
+ monkeypatch.setattr(mod.time, "sleep", lambda s: None)
362
+
363
+ class _ImmediateThread:
364
+ def __init__(self, target, daemon=True):
365
+ self._target = target
366
+
367
+ def start(self):
368
+ self._target()
369
+
370
+ monkeypatch.setattr(mod.threading, "Thread", _ImmediateThread)
371
+
372
+ out = c._reconcile()
373
+
374
+ assert out == {"a.py", "b.py", "c.py", "d.py"}
375
+ assert "stale.py" in released
376
+ # Reconcile should acquire missing dirty files (d.py) and refresh missing
377
+ # token locks through acquire RPC (c.py).
378
+ assert len(acquired_calls) == 2
379
+
380
+
381
+ def test_get_modified_and_unpushed_files_non_windows_paths(monkeypatch):
382
+ """Cover non-Windows upstream check + diff code path and status-only fallback."""
383
+ c = mod.LockClient(local_only=True)
384
+ monkeypatch.setattr(mod.sys, "platform", "linux")
385
+
386
+ calls = {"n": 0}
387
+
388
+ def _check_output(args, *a, **k):
389
+ # status
390
+ if args[:3] == ["git", "status", "--porcelain"]:
391
+ return b" M src/dirty.py\n"
392
+ # upstream check
393
+ if args[:2] == ["git", "rev-parse"]:
394
+ calls["n"] += 1
395
+ if calls["n"] == 1:
396
+ return b"origin/main\n"
397
+ raise RuntimeError("no upstream")
398
+ # diff against upstream
399
+ if args[:3] == ["git", "diff", "--name-status"]:
400
+ return b"M\tsrc/unpushed.py\n"
401
+ return b""
402
+
403
+ monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
404
+ monkeypatch.setattr(c, "_normalize_file_path", lambda p: p)
405
+ monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
406
+
407
+ first = set(c._get_modified_and_unpushed_files())
408
+ assert any(p.endswith("dirty.py") for p in first)
409
+ assert "src/unpushed.py" in first
410
+
411
+ # Second call exercises rev-parse failure -> except fallback to status-only
412
+ second = set(c._get_modified_and_unpushed_files())
413
+ assert any(p.endswith("dirty.py") for p in second)
414
+
415
+
416
+ def test_get_modified_and_unpushed_files_keeps_deleted_upstream_paths(monkeypatch):
417
+ """Deleted files from unpushed history remain in-progress for locking."""
418
+ c = mod.LockClient(local_only=True)
419
+ monkeypatch.setattr(mod.sys, "platform", "linux")
420
+
421
+ def _check_output(args, *a, **k):
422
+ if args[:3] == ["git", "status", "--porcelain"]:
423
+ return b""
424
+ if args[:2] == ["git", "rev-parse"]:
425
+ return b"origin/main\n"
426
+ if args[:3] == ["git", "diff", "--name-status"]:
427
+ return (
428
+ b"D\t.collab/core/watcher.py\n"
429
+ b"D\t.collab/dashboard/server.py\n"
430
+ b"M\tsrc/live.py\n"
431
+ b"R100\told/name.py\tnew/name.py\n"
432
+ )
433
+ return b""
434
+
435
+ monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
436
+ monkeypatch.setattr(c, "_normalize_file_path", lambda p: p.replace("\\", "/"))
437
+ monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
438
+
439
+ out = set(c._get_modified_and_unpushed_files())
440
+ assert ".collab/core/watcher.py" in out
441
+ assert ".collab/dashboard/server.py" in out
442
+ assert "src/live.py" in out
443
+ assert "new/name.py" in out
444
+
445
+
446
+ def test_get_modified_and_unpushed_files_skips_status_dir_suffix(monkeypatch):
447
+ """Directory-like status entries ending in '/' are ignored."""
448
+ c = mod.LockClient(local_only=True)
449
+ monkeypatch.setattr(mod.sys, "platform", "linux")
450
+
451
+ def _check_output(args, *a, **k):
452
+ if args[:3] == ["git", "status", "--porcelain"]:
453
+ return b" M apps/reporting/instance/\n M src/real.py\n"
454
+ if args[:2] == ["git", "rev-parse"]:
455
+ raise RuntimeError("no upstream")
456
+ return b""
457
+
458
+ monkeypatch.setattr(mod.subprocess, "check_output", _check_output)
459
+ monkeypatch.setattr(c, "_normalize_file_path", lambda p: p.replace("\\", "/"))
460
+ monkeypatch.setattr(c, "_should_ignore_path", lambda p: False)
461
+
462
+ out = set(c._get_modified_and_unpushed_files())
463
+ assert "apps/reporting/instance/" not in out
464
+ assert "src/real.py" in out
@@ -0,0 +1,77 @@
1
+ """Release error/edge-case tests for LockClient.release().
2
+
3
+ NOTE: Standard ephemeral release and success release are covered in
4
+ test_lock_client_api.py (test_release_ephemeral_and_success). All force_release /
5
+ force_release_all tests are covered in test_lock_client_force_release.py. This file
6
+ covers only unique error paths for release().
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ._helpers import FakeResponse, load_lock_client_module, make_create_client
12
+
13
+ mod = load_lock_client_module()
14
+
15
+
16
+ def test_release_api_error(monkeypatch):
17
+ """Release that returns a 500 API error."""
18
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
19
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
20
+ monkeypatch.setattr(
21
+ mod,
22
+ "_get_create_client",
23
+ lambda: make_create_client(
24
+ FakeResponse(status=500, data=None, error={"message": "release fail"})
25
+ ),
26
+ )
27
+ client = mod.LockClient(developer_id="releaser")
28
+ ok, msg = client.release("tmp/x")
29
+ assert ok is False
30
+
31
+
32
+ def test_release_api_exception(monkeypatch):
33
+ """Release when supabase client raises an exception."""
34
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
35
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
36
+
37
+ class ExplodingClient:
38
+ def table(self, *a, **k):
39
+ return self
40
+
41
+ def delete(self, *a, **k):
42
+ return self
43
+
44
+ def eq(self, *a, **k):
45
+ return self
46
+
47
+ def execute(self):
48
+ raise RuntimeError("Connection refused")
49
+
50
+ monkeypatch.setattr(
51
+ mod,
52
+ "_get_create_client",
53
+ lambda: (lambda url, key: ExplodingClient()),
54
+ )
55
+ monkeypatch.setattr(mod.time, "sleep", lambda x: None)
56
+
57
+ client = mod.LockClient(developer_id="releaser")
58
+ ok, msg = client.release("tmp/x")
59
+ assert ok is False
60
+
61
+
62
+ def test_release_not_acquired(monkeypatch):
63
+ """Release when the file was not actually acquired (no supabase data)."""
64
+ monkeypatch.setenv("SUPABASE_URL", "https://test.supabase.co")
65
+ monkeypatch.setenv("SUPABASE_ANON_KEY", "test_key")
66
+
67
+ monkeypatch.setattr(
68
+ mod,
69
+ "_get_create_client",
70
+ lambda: make_create_client(
71
+ FakeResponse(status=404, data=None, error="not found")
72
+ ),
73
+ )
74
+ client = mod.LockClient(developer_id="releaser")
75
+ ok, msg = client.release("tmp/x")
76
+ assert ok is False
77
+ assert "API Error" in msg